├── .github ├── ISSUE_TEMPLATE │ └── bug-report---something-is-not-working.md └── workflows │ └── python-package.yml ├── .gitignore ├── .readthedocs.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── api.md ├── css │ └── custom.css ├── examples.md ├── index.md └── requirements.txt ├── mkdocs.yml ├── poetry.lock ├── pyproject.toml ├── renovate.json ├── slowapi ├── __init__.py ├── errors.py ├── extension.py ├── middleware.py ├── py.typed ├── util.py └── wrappers.py └── tests ├── __init__.py ├── test_base.py ├── test_fastapi_extension.py └── test_starlette_extension.py /.github/ISSUE_TEMPLATE/bug-report---something-is-not-working.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report - Something is not working 3 | about: You found a bug, or your code is not working as you expect 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Show us your code, only copy the relevant parts, the shorter it is, the easier it is to help you. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Your app (please complete the following information):** 23 | - fastapi or starlette? 24 | - Version? 25 | - slowapi version (have you tried with the latest version)? 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install build dependencies for requests in python 3.9 27 | # it's not clear why this is needed only for this version of python 28 | run: sudo apt-get install libxml2-dev libxslt-dev 29 | - name: Install Poetry 30 | uses: snok/install-poetry@v1 31 | with: 32 | # Version of Poetry to use 33 | version: 1.4.2 34 | - name: Install dependencies 35 | run: | 36 | poetry install 37 | - name: Check formatting with black 38 | run: | 39 | poetry run black --check . 40 | - name: Check typing annotations with mypy 41 | run: | 42 | poetry run mypy . 43 | - name: Verify unused imports 44 | run: | 45 | poetry run flake8 --select F401 46 | - name: Test with pytest 47 | # Wrapped by coverage to generate coverage data 48 | run: | 49 | poetry run coverage run --omit="tests*" -m pytest 50 | - name: Generate coverage report 51 | run: | 52 | poetry run coverage report 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # testing 2 | .pytest_cache 3 | __pycache__ 4 | 5 | # typing 6 | .mypy_cache 7 | 8 | # editors 9 | .idea 10 | 11 | # coverage 12 | .coverage -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation with MkDocs 9 | mkdocs: 10 | configuration: mkdocs.yml 11 | 12 | # Optionally build your docs in additional formats such as PDF 13 | formats: 14 | - pdf 15 | 16 | # Optionally set the version of Python and requirements required to build your docs 17 | python: 18 | version: 3.7 19 | install: 20 | - requirements: docs/requirements.txt 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [0.1.10] - 2024-06-04 4 | 5 | ### Changed 6 | 7 | - Breaking change: allow usage of the request object in the except_when function (thanks @colin99d) 8 | 9 | ## [0.1.9] - 2024-02-05 10 | 11 | ### Added 12 | 13 | - Fix `limit_value` typehint in `limit()` function (thanks @PookieBuns) 14 | - Fix `limit_value` typehint in `shared_limit()` function (thanks @aberlioz) 15 | - Only pass `".env"` to starlette `Config` if `".env"` file exists (thanks @daniellok-db) 16 | 17 | ## [0.1.8] - 2023-04-07 18 | 19 | ### Added 20 | 21 | - Loosen restriction on the limits dependency (thanks @sanders41) 22 | - Fix redis install error (thanks @sanders41) 23 | - Add Python 3.11 support (thanks @sanders41) 24 | 25 | 26 | ## [0.1.7] - 2022-11-09 27 | 28 | ### Added 29 | 30 | - Added ASGI middleware alternative (thanks @thentgesMindee) 31 | - Added support for custom cost per hit (thanks @nootr) 32 | - Added `key_style` parameter to choose between endpoint or url (thanks @thentgesMindee) 33 | 34 | ## [0.1.6] - 2022-08-20 35 | 36 | ### Added 37 | - Added feature to support providing functions for dynamically defined limits (thanks @maratsarbasov) 38 | - Added github action to check for unused imports (thanks @twcurrie) 39 | - Added coverage report in CI (thanks @karlnewell) 40 | - Added Python 3.10 to CI (thanks @Reuben Thomas-Davis) 41 | 42 | ### Changed 43 | - Shifted redis to extras, removed test imports of library (thanks @ME-ON1) 44 | - Upgraded dependencies (thanks @dependabot, @Rested, @laurents) 45 | - Updated documentation and example code (thanks @Dustyposa, @laurents, @nootr) 46 | - Set minimum Python version to 3.6.2 (thanks @Rested) 47 | 48 | ### Fixed 49 | - Fixed exempt decorator for async routes (thanks @laurents) 50 | - Handled newly raised exception from parsing library (thanks @Rested) 51 | 52 | ## [0.1.5] - 2021-08-28 53 | 54 | ### Changed 55 | 56 | - Switched to poetry-core for building #54 (thanks @fabaff) 57 | - Improved the docs 58 | - Upgraded a few dependencies (thanks @dependabot) 59 | 60 | ### Fixed 61 | 62 | - Resolved bug of unregistered endpoints in the disabled state #46 (thanks @twcurrie) 63 | - Fixed bug with Retry-After headers #60 (thanks again @twcurrie) 64 | 65 | 66 | ## [0.1.4] - 2021-02-21 67 | 68 | - Made the enabled option actually useful (thanks @kodekracker for the report) #35 69 | - Fixed 2 bugs in middleware #30 and #37 (thanks @xuxygo for the PR, and @papapumpnz for the report) 70 | - Fixed errors in docs 71 | - Bump lxml to 4.6.2 (dependabot - only used for doc generation) 72 | 73 | ## [0.1.3] - 2020-12-24 74 | 75 | ### Added 76 | 77 | - Added some setup examples in documentation 78 | 79 | ### Fixed 80 | 81 | - Routes returning a dict don't error when turning on headers (#18), thanks to @glinmac 82 | - Fix CI crash following github actions changes in env settings 83 | 84 | ## [0.1.2] - 2020-10-01 85 | 86 | ### Added 87 | 88 | - Added support for default limits and exempt routes, thanks to @Rested 89 | - Added documentation 90 | - Added more tests, thanks to @thomasleveil 91 | - Fix documentation bug, thanks to @brumar 92 | - Added CI checks for formatting, typing and tests 93 | 94 | ### Changed 95 | 96 | - Upgraded supported version of Starlette (0.13.6) and FastApi (0.61.1) 97 | 98 | ## [0.1.1] - 2020-03-11 99 | 100 | ### Added 101 | 102 | - Added explicit support for typing 103 | 104 | ### Changed 105 | 106 | - Upgraded supported version of Starlette (0.13.2) and FastApi (0.52.0) 107 | 108 | ## [0.1.0] - 2020-02-21 109 | 110 | Initial release 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Laurent Savaete 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SlowApi 2 | 3 | A rate limiting library for Starlette and FastAPI adapted from [flask-limiter](http://github.com/alisaifee/flask-limiter). 4 | 5 | This package is used in various production setups, handling millions of requests per month, and seems to behave as expected. 6 | There might be some API changes when changing the code to be fully `async`, but we will notify users via appropriate `semver` version changes. 7 | 8 | The documentation is on [read the docs](https://slowapi.readthedocs.io/en/latest/). 9 | 10 | # Quick start 11 | 12 | ## Installation 13 | 14 | `slowapi` is available from [pypi](https://pypi.org/project/slowapi/) so you can install it as usual: 15 | 16 | ``` 17 | $ pip install slowapi 18 | ``` 19 | 20 | # Features 21 | 22 | Most feature are coming from FlaskLimiter and the underlying [limits](https://limits.readthedocs.io/). 23 | 24 | Supported now: 25 | 26 | - Single and multiple `limit` decorator on endpoint functions to apply limits 27 | - redis, memcached and memory backends to track your limits (memory as a fallback) 28 | - support for sync and async HTTP endpoints 29 | - Support for shared limits across a set of routes 30 | 31 | 32 | # Limitations and known issues 33 | 34 | * The `request` argument must be explicitly passed to your endpoint, or `slowapi` won't be able to hook into it. In other words, write: 35 | 36 | ```python 37 | @limiter.limit("5/minute") 38 | async def myendpoint(request: Request) 39 | pass 40 | ``` 41 | 42 | and not: 43 | 44 | ```python 45 | @limiter.limit("5/minute") 46 | async def myendpoint() 47 | pass 48 | ``` 49 | 50 | * `websocket` endpoints are not supported yet. 51 | 52 | # Developing and contributing 53 | 54 | PRs are more than welcome! Please include tests for your changes :) 55 | 56 | The package uses [poetry](https://python-poetry.org) to manage dependencies. To setup your dev env: 57 | 58 | ```bash 59 | $ poetry install 60 | ``` 61 | 62 | To run the tests: 63 | ```bash 64 | $ pytest 65 | ``` 66 | 67 | # Credits 68 | 69 | Credits go to [flask-limiter](https://github.com/alisaifee/flask-limiter) of which SlowApi is a (still partial) adaptation to Starlette and FastAPI. 70 | It's also important to mention that the actual rate limiting work is done by [limits](https://github.com/alisaifee/limits/), `slowapi` is just a wrapper around it. 71 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API reference 2 | 3 | ## Limiter class 4 | 5 | :::slowapi.extension.Limiter 6 | :docstring: 7 | :members: limit shared_limit 8 | 9 | ## Wrappers around Limit objects 10 | 11 | These wrap the `RateLimitItem` from [alisaifee/limits](https://limits.readthedocs.io/). 12 | 13 | :::slowapi.wrappers.Limit 14 | :docstring: 15 | 16 | :::slowapi.wrappers.LimitGroup 17 | :docstring: 18 | 19 | ## Utility functions 20 | 21 | :::slowapi.util.get_ipaddr 22 | :docstring: 23 | 24 | :::slowapi.util.get_remote_address 25 | :docstring: 26 | -------------------------------------------------------------------------------- /docs/css/custom.css: -------------------------------------------------------------------------------- 1 | div.autodoc-docstring { 2 | padding-left: 20px; 3 | margin-bottom: 30px; 4 | border-left: 5px solid rgba(230, 230, 230); 5 | } 6 | 7 | div.autodoc-members { 8 | padding-left: 20px; 9 | margin-bottom: 15px; 10 | } 11 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | Here are some examples of setup to get you started. Please open an issue if you have a use case that is not included here. 4 | 5 | The tests show a lot of different use cases that are not all covered here. 6 | 7 | ## Apply a global (default) limit to all routes 8 | 9 | ```python 10 | from starlette.applications import Starlette 11 | from slowapi import Limiter, _rate_limit_exceeded_handler 12 | from slowapi.util import get_remote_address 13 | from slowapi.middleware import SlowAPIMiddleware 14 | from slowapi.errors import RateLimitExceeded 15 | 16 | limiter = Limiter(key_func=get_remote_address, default_limits=["1/minute"]) 17 | app = Starlette() 18 | app.state.limiter = limiter 19 | app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) 20 | app.add_middleware(SlowAPIMiddleware) 21 | 22 | # this will be limited by the default_limits 23 | async def homepage(request: Request): 24 | return PlainTextResponse("Only once per minute") 25 | 26 | app.add_route("/home", homepage) 27 | ``` 28 | 29 | ## Exempt a route from the global limit 30 | 31 | ```python 32 | @app.route("/someroute") 33 | @limiter.exempt 34 | def t(request: Request): 35 | return PlainTextResponse("I'm unlimited") 36 | ``` 37 | 38 | ## Disable the limiter entirely 39 | 40 | You might want to disable the limiter, for instance for testing, etc... 41 | Note that this disables it entirely, for all users. It is essentially as if the limiter was not there. 42 | Simply pass `enabled=False` to the constructor. 43 | 44 | ```python 45 | limiter = Limiter(key_func=get_remote_address, enabled=False) 46 | 47 | @app.route("/someroute") 48 | @limiter.exempt 49 | def t(request: Request): 50 | return PlainTextResponse("I'm unlimited") 51 | ``` 52 | 53 | You can always switch this during the lifetime of the limiter: 54 | 55 | ```python 56 | limiter.enabled = False 57 | ``` 58 | 59 | ## Use redis as backend for the limiter 60 | 61 | ```python 62 | limiter = Limiter(key_func=get_remote_address, storage_uri="redis://:/n") 63 | ``` 64 | 65 | where the /n in the redis url is the database number. To use the default one, just drop the /n from the url. 66 | 67 | There are more examples in the [limits docs](https://limits.readthedocs.io/en/stable/storage.html) which is the library slowapi uses to manage storage. 68 | 69 | ## Set a custom cost per hit 70 | 71 | Setting a custom cost per hit is useful to throttle requests based on something else than the request count. 72 | 73 | Define a function which takes a request as parameter and returns a cost and pass it to the `limit` decorator: 74 | 75 | ```python 76 | def get_hit_cost(request: Request) -> int: 77 | return len(request) 78 | 79 | @app.route("/someroute") 80 | @limiter.limit("100/minute", cost=get_hit_cost) 81 | def t(request: Request): 82 | return PlainTextResponse("I'm limited by the request size") 83 | ``` 84 | 85 | ## WSGI vs ASGI Middleware 86 | 87 | `SlowAPIMiddleware` inheriting from Starlette's BaseHTTPMiddleware, you can find an alternative ASGI Middleware `SlowAPIASGIMiddleware`. 88 | A few reasons to choose the ASGI middleware over the HTTP one are: 89 | - Starlette [is probably going to deprecate BaseHTTPMiddleware](https://github.com/encode/starlette/issues/1678) 90 | - ASGI middlewares [are more performant than WSGI ones](https://github.com/tiangolo/fastapi/issues/2241) 91 | - built-in support for asynchronous exception handlers 92 | - ... 93 | 94 | 95 | Both middlewares are added to your application the same way: 96 | ```python 97 | app = Starlette() # or FastAPI() 98 | app.add_middleware(SlowAPIMiddleware) 99 | ``` 100 | or 101 | ```python 102 | app = Starlette() # or FastAPI() 103 | app.add_middleware(SlowAPIASGIMiddleware) 104 | ``` 105 | 106 | ## Use view function's name instead of full endpoint as part of the storage key 107 | 108 | Let's use this route as an example: 109 | ```python 110 | @app.route("/some_route/{some_param}") 111 | def my_func(some_param): 112 | ... 113 | ``` 114 | 115 | ```python 116 | limiter = Limiter(key_func=lambda: "mock", default_limits=["1/minute"], key_style="url") 117 | ``` 118 | 119 | When initializing the Limiter object with `key_style="url"`, it will use the full endpoint url as part of the storage key. 120 | 121 | When calling the `/some_route/my_param` endpoint would result with a key shaped like: `LIMITER/mock//some_route/my_param/1/1/minute`. 122 | 123 | > This means, that if the route contains some URL parameter, calling the endpoint with different parameters won't share the limitations. 124 | 125 | ```python 126 | limiter = Limiter(key_func=lambda: "mock", default_limits=["1/minute"], key_style="endpoint") 127 | ``` 128 | 129 | When initializing the Limiter object with `key_style="endpoint"`, it will use the function name as part of the storage key. 130 | 131 | When calling the `/some_route/my_param` endpoint would result with a key shaped like: `LIMITER/mock/{module}.my_func/1/1/minute` 132 | 133 | > This means, that if the route contains some URL parameter, calling the endpoint with different parameters will still share the limitations, since the view function is the same. 134 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # SlowApi 2 | 3 | A rate limiting library for Starlette and FastAPI adapted from [flask-limiter](http://github.com/alisaifee/flask-limiter). 4 | 5 | Note: this is alpha quality code still, the API may change, and things may fall apart while you try it. 6 | 7 | # Quick start 8 | 9 | ## Installation 10 | 11 | `slowapi` is available from [pypi](https://pypi.org/project/slowapi/) so you can install it as usual: 12 | 13 | ``` 14 | $ pip install slowapi 15 | ``` 16 | 17 | ## Starlette 18 | 19 | ```python 20 | from starlette.applications import Starlette 21 | from starlette.responses import PlainTextResponse 22 | from starlette.requests import Request 23 | from slowapi import Limiter, _rate_limit_exceeded_handler 24 | from slowapi.util import get_remote_address 25 | from slowapi.errors import RateLimitExceeded 26 | 27 | limiter = Limiter(key_func=get_remote_address) 28 | app = Starlette() 29 | app.state.limiter = limiter 30 | app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) 31 | 32 | @limiter.limit("5/minute") 33 | async def homepage(request: Request): 34 | return PlainTextResponse("test") 35 | 36 | app.add_route("/home", homepage) 37 | ``` 38 | 39 | 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. 40 | 41 | ## FastAPI 42 | 43 | ```python 44 | from fastapi import FastAPI, Request, Response 45 | from slowapi import Limiter, _rate_limit_exceeded_handler 46 | from slowapi.util import get_remote_address 47 | from slowapi.errors import RateLimitExceeded 48 | 49 | limiter = Limiter(key_func=get_remote_address) 50 | app = FastAPI() 51 | app.state.limiter = limiter 52 | app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) 53 | 54 | # Note: the route decorator must be above the limit decorator, not below it 55 | @app.get("/home") 56 | @limiter.limit("5/minute") 57 | async def home(request: Request): 58 | return Response("test") 59 | 60 | @app.get("/mars") 61 | @limiter.limit("5/minute") 62 | async def mars(request: Request, response: Response): 63 | return {"key": "value"} 64 | ``` 65 | 66 | This will provide the same result, but with a FastAPI app. 67 | 68 | # Features 69 | 70 | Most feature are coming from (will come from) FlaskLimiter and the underlying [limits](https://limits.readthedocs.io/). 71 | 72 | Supported now: 73 | 74 | - Single and multiple `limit` decorator on endpoint functions to apply limits 75 | - Redis, memcached and memory backends to track your limits (memory as a fallback) 76 | - Support for sync and async HTTP endpoints 77 | - Support for shared limits across a set of routes 78 | - Support for default global limit 79 | - Support for a custom cost per hit 80 | 81 | # Limitations and known issues 82 | 83 | ## Request argument 84 | 85 | The `request` argument must be explicitly passed to your endpoint, or `slowapi` won't be able to hook into it. In other words, write: 86 | 87 | ```python 88 | @limiter.limit("5/minute") 89 | async def myendpoint(request: Request) 90 | pass 91 | ``` 92 | 93 | and not: 94 | 95 | ```python 96 | @limiter.limit("5/minute") 97 | async def myendpoint() 98 | pass 99 | ``` 100 | 101 | ## Response type 102 | 103 | Similarly, if the returned response is not an instance of `Response` and 104 | will be built at an upper level in the middleware stack, you'll need to provide 105 | the response object explicitly if you want the `Limiter` to modify the headers 106 | (`headers_enabled=True`): 107 | 108 | ```python 109 | @limiter.limit("5/minute") 110 | async def myendpoint(request: Request, response: Response) 111 | return {"key": "value"} 112 | ``` 113 | 114 | ## Decorators order 115 | 116 | The order of decorators matters. It is not a bug, the `limit` decorator needs the `request` argument in the function it decorates (see above). 117 | This works 118 | ``` 119 | @router.get("/test") 120 | @limiter.limit("2/minute") 121 | async def test( 122 | request: Request 123 | ): 124 | return "hi" 125 | ``` 126 | 127 | but this doesnt 128 | 129 | ``` 130 | @limiter.limit("2/minute") 131 | @router.get("/test") 132 | async def test( 133 | request: Request 134 | ): 135 | return "hi" 136 | ``` 137 | 138 | ## Websocket endpoints 139 | 140 | `websocket` endpoints are not supported yet. 141 | 142 | # Examples of setup 143 | 144 | See [examples](examples.md) 145 | 146 | # Developing and contributing 147 | 148 | PRs are more than welcome! Please include tests for your changes :) 149 | 150 | Please run [black](black.readthedocs.io/) on your code before committing, or your PR will not pass the tests. 151 | 152 | The package uses [poetry](https://python-poetry.org) to manage dependencies. To setup your dev env: 153 | 154 | ```bash 155 | $ poetry install 156 | ``` 157 | 158 | To run the tests: 159 | ```bash 160 | $ pytest 161 | ``` 162 | 163 | ## Releasing a new version 164 | 165 | `slowapi` tries to follow [semantic versioning](https://semver.org/). 166 | 167 | To release a new version: 168 | 169 | - Update CHANGELOG.md 170 | - Bump the version number in `pyproject.toml` 171 | - `poetry build` 172 | - `poetry publish` 173 | 174 | # Credits 175 | 176 | Credits go to [flask-limiter](https://github.com/alisaifee/flask-limiter) of which SlowApi is a (still partial) adaptation to Starlette and FastAPI. 177 | It's also important to mention that the actual rate limiting work is done by [limits](https://github.com/alisaifee/limits/), `slowapi` is just a wrapper around it. 178 | 179 | The documentation is built using [mkDocs](https://www.mkdocs.org/) and the API documentation is generated using [mkautodoc](https://github.com/tomchristie/mkautodoc). 180 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.3 2 | atomicwrites==1.3.0; sys_platform == "win32" 3 | attrs==19.3.0 4 | black==23.3.0 5 | certifi==2022.12.7 6 | chardet==3.0.4 7 | click==7.1.2 8 | colorama==0.4.3; sys_platform == "win32" 9 | dataclasses==0.6; python_version < "3.7" 10 | fastapi==0.65.2 11 | future==0.18.3 12 | hiro==0.5.1 13 | idna==2.9 14 | importlib-metadata==1.5.0; python_version < "3.8" 15 | isort==4.3.21 16 | jinja2==2.11.3 17 | joblib==0.16.0; python_version > "2.7" 18 | limits==1.5.1 19 | livereload==2.6.3 20 | lunr==0.5.8 21 | lxml==4.9.1 22 | markdown==3.2.2 23 | markupsafe==1.1.1 24 | mkautodoc==0.1.0 25 | mkdocs==1.2.3 26 | mock==4.0.1 27 | more-itertools==8.2.0 28 | mypy==0.761 29 | mypy-extensions==0.4.3 30 | nltk==3.6.6; python_version > "2.7" 31 | packaging==20.3 32 | pathspec==0.7.0 33 | pluggy==0.13.1 34 | py==1.10.0 35 | pydantic==1.6.2 36 | pyparsing==2.4.6 37 | pytest==5.3.5 38 | pyyaml==5.4 39 | redis==4.3.6 40 | regex==2020.2.20 41 | requests==2.23.0 42 | six==1.14.0 43 | starlette==0.25.0 44 | toml==0.10.0 45 | tornado==6.0.4 46 | tqdm==4.50.0; python_version > "2.7" 47 | typed-ast==1.4.1 48 | typing-extensions==3.7.4.1 49 | urllib3==1.26.5 50 | wcwidth==0.1.8 51 | zipp==3.1.0; python_version < "3.8" 52 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: SlowApi Documentation 2 | 3 | markdown_extensions: 4 | - admonition 5 | - codehilite 6 | - mkautodoc 7 | 8 | extra_css: 9 | - css/custom.css 10 | 11 | nav: 12 | - SlowApi: index.md 13 | - examples.md 14 | - api.md 15 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "anyio" 5 | version = "3.6.2" 6 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 7 | category = "dev" 8 | optional = false 9 | python-versions = ">=3.6.2" 10 | files = [ 11 | {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, 12 | {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"}, 13 | ] 14 | 15 | [package.dependencies] 16 | idna = ">=2.8" 17 | sniffio = ">=1.1" 18 | typing-extensions = {version = "*", markers = "python_version < \"3.8\""} 19 | 20 | [package.extras] 21 | doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] 22 | test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] 23 | trio = ["trio (>=0.16,<0.22)"] 24 | 25 | [[package]] 26 | name = "atomicwrites" 27 | version = "1.4.1" 28 | description = "Atomic file writes." 29 | category = "dev" 30 | optional = false 31 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 32 | files = [ 33 | {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, 34 | ] 35 | 36 | [[package]] 37 | name = "attrs" 38 | version = "22.2.0" 39 | description = "Classes Without Boilerplate" 40 | category = "dev" 41 | optional = false 42 | python-versions = ">=3.6" 43 | files = [ 44 | {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, 45 | {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, 46 | ] 47 | 48 | [package.extras] 49 | cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] 50 | dev = ["attrs[docs,tests]"] 51 | docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] 52 | tests = ["attrs[tests-no-zope]", "zope.interface"] 53 | tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] 54 | 55 | [[package]] 56 | name = "black" 57 | version = "23.3.0" 58 | description = "The uncompromising code formatter." 59 | category = "dev" 60 | optional = false 61 | python-versions = ">=3.7" 62 | files = [ 63 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"}, 64 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"}, 65 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"}, 66 | {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"}, 67 | {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"}, 68 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"}, 69 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"}, 70 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"}, 71 | {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"}, 72 | {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"}, 73 | {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"}, 74 | {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"}, 75 | {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"}, 76 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"}, 77 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"}, 78 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"}, 79 | {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"}, 80 | {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"}, 81 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"}, 82 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"}, 83 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"}, 84 | {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"}, 85 | {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"}, 86 | {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"}, 87 | {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"}, 88 | ] 89 | 90 | [package.dependencies] 91 | click = ">=8.0.0" 92 | mypy-extensions = ">=0.4.3" 93 | packaging = ">=22.0" 94 | pathspec = ">=0.9.0" 95 | platformdirs = ">=2" 96 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 97 | typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} 98 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 99 | 100 | [package.extras] 101 | colorama = ["colorama (>=0.4.3)"] 102 | d = ["aiohttp (>=3.7.4)"] 103 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 104 | uvloop = ["uvloop (>=0.15.2)"] 105 | 106 | [[package]] 107 | name = "certifi" 108 | version = "2022.12.7" 109 | description = "Python package for providing Mozilla's CA Bundle." 110 | category = "dev" 111 | optional = false 112 | python-versions = ">=3.6" 113 | files = [ 114 | {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, 115 | {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, 116 | ] 117 | 118 | [[package]] 119 | name = "charset-normalizer" 120 | version = "3.1.0" 121 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 122 | category = "dev" 123 | optional = false 124 | python-versions = ">=3.7.0" 125 | files = [ 126 | {file = "charset-normalizer-3.1.0.tar.gz", hash = "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5"}, 127 | {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b"}, 128 | {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60"}, 129 | {file = "charset_normalizer-3.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1"}, 130 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0"}, 131 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f"}, 132 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0"}, 133 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795"}, 134 | {file = "charset_normalizer-3.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c"}, 135 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203"}, 136 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1"}, 137 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137"}, 138 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce"}, 139 | {file = "charset_normalizer-3.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a"}, 140 | {file = "charset_normalizer-3.1.0-cp310-cp310-win32.whl", hash = "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448"}, 141 | {file = "charset_normalizer-3.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8"}, 142 | {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19"}, 143 | {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017"}, 144 | {file = "charset_normalizer-3.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df"}, 145 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a"}, 146 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41"}, 147 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1"}, 148 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62"}, 149 | {file = "charset_normalizer-3.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6"}, 150 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5"}, 151 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be"}, 152 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb"}, 153 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac"}, 154 | {file = "charset_normalizer-3.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324"}, 155 | {file = "charset_normalizer-3.1.0-cp311-cp311-win32.whl", hash = "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909"}, 156 | {file = "charset_normalizer-3.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755"}, 157 | {file = "charset_normalizer-3.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373"}, 158 | {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab"}, 159 | {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9"}, 160 | {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f"}, 161 | {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28"}, 162 | {file = "charset_normalizer-3.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d"}, 163 | {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d"}, 164 | {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d"}, 165 | {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6"}, 166 | {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84"}, 167 | {file = "charset_normalizer-3.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c"}, 168 | {file = "charset_normalizer-3.1.0-cp37-cp37m-win32.whl", hash = "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974"}, 169 | {file = "charset_normalizer-3.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23"}, 170 | {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531"}, 171 | {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c"}, 172 | {file = "charset_normalizer-3.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14"}, 173 | {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb"}, 174 | {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1"}, 175 | {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b"}, 176 | {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0"}, 177 | {file = "charset_normalizer-3.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649"}, 178 | {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326"}, 179 | {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11"}, 180 | {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b"}, 181 | {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd"}, 182 | {file = "charset_normalizer-3.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8"}, 183 | {file = "charset_normalizer-3.1.0-cp38-cp38-win32.whl", hash = "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0"}, 184 | {file = "charset_normalizer-3.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59"}, 185 | {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e"}, 186 | {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31"}, 187 | {file = "charset_normalizer-3.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f"}, 188 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e"}, 189 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f"}, 190 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854"}, 191 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706"}, 192 | {file = "charset_normalizer-3.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e"}, 193 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0"}, 194 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230"}, 195 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7"}, 196 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e"}, 197 | {file = "charset_normalizer-3.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f"}, 198 | {file = "charset_normalizer-3.1.0-cp39-cp39-win32.whl", hash = "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1"}, 199 | {file = "charset_normalizer-3.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b"}, 200 | {file = "charset_normalizer-3.1.0-py3-none-any.whl", hash = "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d"}, 201 | ] 202 | 203 | [[package]] 204 | name = "click" 205 | version = "8.1.3" 206 | description = "Composable command line interface toolkit" 207 | category = "dev" 208 | optional = false 209 | python-versions = ">=3.7" 210 | files = [ 211 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 212 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 213 | ] 214 | 215 | [package.dependencies] 216 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 217 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 218 | 219 | [[package]] 220 | name = "colorama" 221 | version = "0.4.6" 222 | description = "Cross-platform colored terminal text." 223 | category = "dev" 224 | optional = false 225 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 226 | files = [ 227 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 228 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 229 | ] 230 | 231 | [[package]] 232 | name = "coverage" 233 | version = "6.5.0" 234 | description = "Code coverage measurement for Python" 235 | category = "dev" 236 | optional = false 237 | python-versions = ">=3.7" 238 | files = [ 239 | {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, 240 | {file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"}, 241 | {file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"}, 242 | {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"}, 243 | {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"}, 244 | {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"}, 245 | {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"}, 246 | {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"}, 247 | {file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"}, 248 | {file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"}, 249 | {file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"}, 250 | {file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"}, 251 | {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"}, 252 | {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"}, 253 | {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"}, 254 | {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"}, 255 | {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"}, 256 | {file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"}, 257 | {file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"}, 258 | {file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"}, 259 | {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"}, 260 | {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"}, 261 | {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"}, 262 | {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"}, 263 | {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"}, 264 | {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"}, 265 | {file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"}, 266 | {file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"}, 267 | {file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"}, 268 | {file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"}, 269 | {file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"}, 270 | {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"}, 271 | {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"}, 272 | {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"}, 273 | {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"}, 274 | {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"}, 275 | {file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"}, 276 | {file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"}, 277 | {file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"}, 278 | {file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"}, 279 | {file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"}, 280 | {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"}, 281 | {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"}, 282 | {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"}, 283 | {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"}, 284 | {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"}, 285 | {file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"}, 286 | {file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"}, 287 | {file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"}, 288 | {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, 289 | ] 290 | 291 | [package.extras] 292 | toml = ["tomli"] 293 | 294 | [[package]] 295 | name = "deprecated" 296 | version = "1.2.13" 297 | description = "Python @deprecated decorator to deprecate old python classes, functions or methods." 298 | category = "main" 299 | optional = false 300 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 301 | files = [ 302 | {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"}, 303 | {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"}, 304 | ] 305 | 306 | [package.dependencies] 307 | wrapt = ">=1.10,<2" 308 | 309 | [package.extras] 310 | dev = ["PyTest", "PyTest (<5)", "PyTest-Cov", "PyTest-Cov (<2.6)", "bump2version (<1)", "configparser (<5)", "importlib-metadata (<3)", "importlib-resources (<4)", "sphinx (<2)", "sphinxcontrib-websupport (<2)", "tox", "zipp (<2)"] 311 | 312 | [[package]] 313 | name = "fastapi" 314 | version = "0.89.1" 315 | description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" 316 | category = "dev" 317 | optional = false 318 | python-versions = ">=3.7" 319 | files = [ 320 | {file = "fastapi-0.89.1-py3-none-any.whl", hash = "sha256:f9773ea22290635b2f48b4275b2bf69a8fa721fda2e38228bed47139839dc877"}, 321 | {file = "fastapi-0.89.1.tar.gz", hash = "sha256:15d9271ee52b572a015ca2ae5c72e1ce4241dd8532a534ad4f7ec70c376a580f"}, 322 | ] 323 | 324 | [package.dependencies] 325 | pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" 326 | starlette = "0.22.0" 327 | 328 | [package.extras] 329 | all = ["email-validator (>=1.1.1)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] 330 | dev = ["pre-commit (>=2.17.0,<3.0.0)", "ruff (==0.0.138)", "uvicorn[standard] (>=0.12.0,<0.21.0)"] 331 | doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer[all] (>=0.6.1,<0.8.0)"] 332 | test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==22.10.0)", "coverage[toml] (>=6.5.0,<8.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.982)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "pyyaml (>=5.3.1,<7.0.0)", "ruff (==0.0.138)", "sqlalchemy (>=1.3.18,<1.4.43)", "types-orjson (==3.6.2)", "types-ujson (==5.6.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"] 333 | 334 | [[package]] 335 | name = "flake8" 336 | version = "4.0.1" 337 | description = "the modular source code checker: pep8 pyflakes and co" 338 | category = "dev" 339 | optional = false 340 | python-versions = ">=3.6" 341 | files = [ 342 | {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, 343 | {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, 344 | ] 345 | 346 | [package.dependencies] 347 | importlib-metadata = {version = "<4.3", markers = "python_version < \"3.8\""} 348 | mccabe = ">=0.6.0,<0.7.0" 349 | pycodestyle = ">=2.8.0,<2.9.0" 350 | pyflakes = ">=2.4.0,<2.5.0" 351 | 352 | [[package]] 353 | name = "ghp-import" 354 | version = "2.1.0" 355 | description = "Copy your docs directly to the gh-pages branch." 356 | category = "dev" 357 | optional = false 358 | python-versions = "*" 359 | files = [ 360 | {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, 361 | {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, 362 | ] 363 | 364 | [package.dependencies] 365 | python-dateutil = ">=2.8.1" 366 | 367 | [package.extras] 368 | dev = ["flake8", "markdown", "twine", "wheel"] 369 | 370 | [[package]] 371 | name = "h11" 372 | version = "0.14.0" 373 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 374 | category = "dev" 375 | optional = false 376 | python-versions = ">=3.7" 377 | files = [ 378 | {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, 379 | {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, 380 | ] 381 | 382 | [package.dependencies] 383 | typing-extensions = {version = "*", markers = "python_version < \"3.8\""} 384 | 385 | [[package]] 386 | name = "hiro" 387 | version = "0.5.1" 388 | description = "time manipulation utilities for python" 389 | category = "dev" 390 | optional = false 391 | python-versions = "*" 392 | files = [ 393 | {file = "hiro-0.5.1.tar.gz", hash = "sha256:d10e3b7f27b36673b4fa1283cd38d610326ba1ff1291260d0275152f15ae4bc7"}, 394 | ] 395 | 396 | [package.dependencies] 397 | mock = "*" 398 | six = ">=1.4.1" 399 | 400 | [[package]] 401 | name = "httpcore" 402 | version = "0.16.3" 403 | description = "A minimal low-level HTTP client." 404 | category = "dev" 405 | optional = false 406 | python-versions = ">=3.7" 407 | files = [ 408 | {file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"}, 409 | {file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"}, 410 | ] 411 | 412 | [package.dependencies] 413 | anyio = ">=3.0,<5.0" 414 | certifi = "*" 415 | h11 = ">=0.13,<0.15" 416 | sniffio = ">=1.0.0,<2.0.0" 417 | 418 | [package.extras] 419 | http2 = ["h2 (>=3,<5)"] 420 | socks = ["socksio (>=1.0.0,<2.0.0)"] 421 | 422 | [[package]] 423 | name = "httpx" 424 | version = "0.23.3" 425 | description = "The next generation HTTP client." 426 | category = "dev" 427 | optional = false 428 | python-versions = ">=3.7" 429 | files = [ 430 | {file = "httpx-0.23.3-py3-none-any.whl", hash = "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6"}, 431 | {file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"}, 432 | ] 433 | 434 | [package.dependencies] 435 | certifi = "*" 436 | httpcore = ">=0.15.0,<0.17.0" 437 | rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} 438 | sniffio = "*" 439 | 440 | [package.extras] 441 | brotli = ["brotli", "brotlicffi"] 442 | cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"] 443 | http2 = ["h2 (>=3,<5)"] 444 | socks = ["socksio (>=1.0.0,<2.0.0)"] 445 | 446 | [[package]] 447 | name = "idna" 448 | version = "3.4" 449 | description = "Internationalized Domain Names in Applications (IDNA)" 450 | category = "dev" 451 | optional = false 452 | python-versions = ">=3.5" 453 | files = [ 454 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 455 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 456 | ] 457 | 458 | [[package]] 459 | name = "importlib-metadata" 460 | version = "4.2.0" 461 | description = "Read metadata from Python packages" 462 | category = "dev" 463 | optional = false 464 | python-versions = ">=3.6" 465 | files = [ 466 | {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"}, 467 | {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"}, 468 | ] 469 | 470 | [package.dependencies] 471 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 472 | zipp = ">=0.5" 473 | 474 | [package.extras] 475 | docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] 476 | testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pep517", "pyfakefs", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] 477 | 478 | [[package]] 479 | name = "importlib-resources" 480 | version = "5.12.0" 481 | description = "Read resources from Python packages" 482 | category = "main" 483 | optional = false 484 | python-versions = ">=3.7" 485 | files = [ 486 | {file = "importlib_resources-5.12.0-py3-none-any.whl", hash = "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a"}, 487 | {file = "importlib_resources-5.12.0.tar.gz", hash = "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6"}, 488 | ] 489 | 490 | [package.dependencies] 491 | zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} 492 | 493 | [package.extras] 494 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 495 | testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] 496 | 497 | [[package]] 498 | name = "iniconfig" 499 | version = "2.0.0" 500 | description = "brain-dead simple config-ini parsing" 501 | category = "dev" 502 | optional = false 503 | python-versions = ">=3.7" 504 | files = [ 505 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 506 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 507 | ] 508 | 509 | [[package]] 510 | name = "isort" 511 | version = "4.3.21" 512 | description = "A Python utility / library to sort Python imports." 513 | category = "dev" 514 | optional = false 515 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 516 | files = [ 517 | {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, 518 | {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, 519 | ] 520 | 521 | [package.extras] 522 | pipfile = ["pipreqs", "requirementslib"] 523 | pyproject = ["toml"] 524 | requirements = ["pip-api", "pipreqs"] 525 | xdg-home = ["appdirs (>=1.4.0)"] 526 | 527 | [[package]] 528 | name = "jinja2" 529 | version = "3.1.2" 530 | description = "A very fast and expressive template engine." 531 | category = "dev" 532 | optional = false 533 | python-versions = ">=3.7" 534 | files = [ 535 | {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, 536 | {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, 537 | ] 538 | 539 | [package.dependencies] 540 | MarkupSafe = ">=2.0" 541 | 542 | [package.extras] 543 | i18n = ["Babel (>=2.7)"] 544 | 545 | [[package]] 546 | name = "limits" 547 | version = "3.3.1" 548 | description = "Rate limiting utilities" 549 | category = "main" 550 | optional = false 551 | python-versions = ">=3.7" 552 | files = [ 553 | {file = "limits-3.3.1-py3-none-any.whl", hash = "sha256:df8685b1aff349b5199628ecdf41a9f339a35233d8e4fcd9c3e10002e4419b45"}, 554 | {file = "limits-3.3.1.tar.gz", hash = "sha256:dfc59ed5b4847e33a33b88ec16033bed18ce444ce6a76287a4e054db9a683861"}, 555 | ] 556 | 557 | [package.dependencies] 558 | deprecated = ">=1.2" 559 | importlib-resources = ">=1.3" 560 | packaging = ">=21,<24" 561 | setuptools = "*" 562 | typing-extensions = "*" 563 | 564 | [package.extras] 565 | all = ["aetcd", "coredis (>=3.4.0,<5)", "emcache (>=0.6.1)", "emcache (>=1)", "etcd3", "motor (>=3,<4)", "pymemcache (>3,<5.0.0)", "pymongo (>4.1,<5)", "redis (>3,!=4.5.2,!=4.5.3,<5.0.0)", "redis (>=4.2.0,!=4.5.2,!=4.5.3)"] 566 | async-etcd = ["aetcd"] 567 | async-memcached = ["emcache (>=0.6.1)", "emcache (>=1)"] 568 | async-mongodb = ["motor (>=3,<4)"] 569 | async-redis = ["coredis (>=3.4.0,<5)"] 570 | etcd = ["etcd3"] 571 | memcached = ["pymemcache (>3,<5.0.0)"] 572 | mongodb = ["pymongo (>4.1,<5)"] 573 | redis = ["redis (>3,!=4.5.2,!=4.5.3,<5.0.0)"] 574 | rediscluster = ["redis (>=4.2.0,!=4.5.2,!=4.5.3)"] 575 | 576 | [[package]] 577 | name = "lxml" 578 | version = "4.9.2" 579 | description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." 580 | category = "dev" 581 | optional = false 582 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" 583 | files = [ 584 | {file = "lxml-4.9.2-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:76cf573e5a365e790396a5cc2b909812633409306c6531a6877c59061e42c4f2"}, 585 | {file = "lxml-4.9.2-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1f42b6921d0e81b1bcb5e395bc091a70f41c4d4e55ba99c6da2b31626c44892"}, 586 | {file = "lxml-4.9.2-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9f102706d0ca011de571de32c3247c6476b55bb6bc65a20f682f000b07a4852a"}, 587 | {file = "lxml-4.9.2-cp27-cp27m-win32.whl", hash = "sha256:8d0b4612b66ff5d62d03bcaa043bb018f74dfea51184e53f067e6fdcba4bd8de"}, 588 | {file = "lxml-4.9.2-cp27-cp27m-win_amd64.whl", hash = "sha256:4c8f293f14abc8fd3e8e01c5bd86e6ed0b6ef71936ded5bf10fe7a5efefbaca3"}, 589 | {file = "lxml-4.9.2-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2899456259589aa38bfb018c364d6ae7b53c5c22d8e27d0ec7609c2a1ff78b50"}, 590 | {file = "lxml-4.9.2-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6749649eecd6a9871cae297bffa4ee76f90b4504a2a2ab528d9ebe912b101975"}, 591 | {file = "lxml-4.9.2-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:a08cff61517ee26cb56f1e949cca38caabe9ea9fbb4b1e10a805dc39844b7d5c"}, 592 | {file = "lxml-4.9.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:85cabf64adec449132e55616e7ca3e1000ab449d1d0f9d7f83146ed5bdcb6d8a"}, 593 | {file = "lxml-4.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8340225bd5e7a701c0fa98284c849c9b9fc9238abf53a0ebd90900f25d39a4e4"}, 594 | {file = "lxml-4.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:1ab8f1f932e8f82355e75dda5413a57612c6ea448069d4fb2e217e9a4bed13d4"}, 595 | {file = "lxml-4.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:699a9af7dffaf67deeae27b2112aa06b41c370d5e7633e0ee0aea2e0b6c211f7"}, 596 | {file = "lxml-4.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9cc34af337a97d470040f99ba4282f6e6bac88407d021688a5d585e44a23184"}, 597 | {file = "lxml-4.9.2-cp310-cp310-win32.whl", hash = "sha256:d02a5399126a53492415d4906ab0ad0375a5456cc05c3fc0fc4ca11771745cda"}, 598 | {file = "lxml-4.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:a38486985ca49cfa574a507e7a2215c0c780fd1778bb6290c21193b7211702ab"}, 599 | {file = "lxml-4.9.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c83203addf554215463b59f6399835201999b5e48019dc17f182ed5ad87205c9"}, 600 | {file = "lxml-4.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:2a87fa548561d2f4643c99cd13131acb607ddabb70682dcf1dff5f71f781a4bf"}, 601 | {file = "lxml-4.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d6b430a9938a5a5d85fc107d852262ddcd48602c120e3dbb02137c83d212b380"}, 602 | {file = "lxml-4.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3efea981d956a6f7173b4659849f55081867cf897e719f57383698af6f618a92"}, 603 | {file = "lxml-4.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:df0623dcf9668ad0445e0558a21211d4e9a149ea8f5666917c8eeec515f0a6d1"}, 604 | {file = "lxml-4.9.2-cp311-cp311-win32.whl", hash = "sha256:da248f93f0418a9e9d94b0080d7ebc407a9a5e6d0b57bb30db9b5cc28de1ad33"}, 605 | {file = "lxml-4.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:3818b8e2c4b5148567e1b09ce739006acfaa44ce3156f8cbbc11062994b8e8dd"}, 606 | {file = "lxml-4.9.2-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca989b91cf3a3ba28930a9fc1e9aeafc2a395448641df1f387a2d394638943b0"}, 607 | {file = "lxml-4.9.2-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:822068f85e12a6e292803e112ab876bc03ed1f03dddb80154c395f891ca6b31e"}, 608 | {file = "lxml-4.9.2-cp35-cp35m-win32.whl", hash = "sha256:be7292c55101e22f2a3d4d8913944cbea71eea90792bf914add27454a13905df"}, 609 | {file = "lxml-4.9.2-cp35-cp35m-win_amd64.whl", hash = "sha256:998c7c41910666d2976928c38ea96a70d1aa43be6fe502f21a651e17483a43c5"}, 610 | {file = "lxml-4.9.2-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:b26a29f0b7fc6f0897f043ca366142d2b609dc60756ee6e4e90b5f762c6adc53"}, 611 | {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:ab323679b8b3030000f2be63e22cdeea5b47ee0abd2d6a1dc0c8103ddaa56cd7"}, 612 | {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:689bb688a1db722485e4610a503e3e9210dcc20c520b45ac8f7533c837be76fe"}, 613 | {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f49e52d174375a7def9915c9f06ec4e569d235ad428f70751765f48d5926678c"}, 614 | {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:36c3c175d34652a35475a73762b545f4527aec044910a651d2bf50de9c3352b1"}, 615 | {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a35f8b7fa99f90dd2f5dc5a9fa12332642f087a7641289ca6c40d6e1a2637d8e"}, 616 | {file = "lxml-4.9.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:58bfa3aa19ca4c0f28c5dde0ff56c520fbac6f0daf4fac66ed4c8d2fb7f22e74"}, 617 | {file = "lxml-4.9.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc718cd47b765e790eecb74d044cc8d37d58562f6c314ee9484df26276d36a38"}, 618 | {file = "lxml-4.9.2-cp36-cp36m-win32.whl", hash = "sha256:d5bf6545cd27aaa8a13033ce56354ed9e25ab0e4ac3b5392b763d8d04b08e0c5"}, 619 | {file = "lxml-4.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:3ab9fa9d6dc2a7f29d7affdf3edebf6ece6fb28a6d80b14c3b2fb9d39b9322c3"}, 620 | {file = "lxml-4.9.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:05ca3f6abf5cf78fe053da9b1166e062ade3fa5d4f92b4ed688127ea7d7b1d03"}, 621 | {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:a5da296eb617d18e497bcf0a5c528f5d3b18dadb3619fbdadf4ed2356ef8d941"}, 622 | {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:04876580c050a8c5341d706dd464ff04fd597095cc8c023252566a8826505726"}, 623 | {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c9ec3eaf616d67db0764b3bb983962b4f385a1f08304fd30c7283954e6a7869b"}, 624 | {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2a29ba94d065945944016b6b74e538bdb1751a1db6ffb80c9d3c2e40d6fa9894"}, 625 | {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a82d05da00a58b8e4c0008edbc8a4b6ec5a4bc1e2ee0fb6ed157cf634ed7fa45"}, 626 | {file = "lxml-4.9.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:223f4232855ade399bd409331e6ca70fb5578efef22cf4069a6090acc0f53c0e"}, 627 | {file = "lxml-4.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d17bc7c2ccf49c478c5bdd447594e82692c74222698cfc9b5daae7ae7e90743b"}, 628 | {file = "lxml-4.9.2-cp37-cp37m-win32.whl", hash = "sha256:b64d891da92e232c36976c80ed7ebb383e3f148489796d8d31a5b6a677825efe"}, 629 | {file = "lxml-4.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:a0a336d6d3e8b234a3aae3c674873d8f0e720b76bc1d9416866c41cd9500ffb9"}, 630 | {file = "lxml-4.9.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:da4dd7c9c50c059aba52b3524f84d7de956f7fef88f0bafcf4ad7dde94a064e8"}, 631 | {file = "lxml-4.9.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:821b7f59b99551c69c85a6039c65b75f5683bdc63270fec660f75da67469ca24"}, 632 | {file = "lxml-4.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:e5168986b90a8d1f2f9dc1b841467c74221bd752537b99761a93d2d981e04889"}, 633 | {file = "lxml-4.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8e20cb5a47247e383cf4ff523205060991021233ebd6f924bca927fcf25cf86f"}, 634 | {file = "lxml-4.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:13598ecfbd2e86ea7ae45ec28a2a54fb87ee9b9fdb0f6d343297d8e548392c03"}, 635 | {file = "lxml-4.9.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:880bbbcbe2fca64e2f4d8e04db47bcdf504936fa2b33933efd945e1b429bea8c"}, 636 | {file = "lxml-4.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7d2278d59425777cfcb19735018d897ca8303abe67cc735f9f97177ceff8027f"}, 637 | {file = "lxml-4.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5344a43228767f53a9df6e5b253f8cdca7dfc7b7aeae52551958192f56d98457"}, 638 | {file = "lxml-4.9.2-cp38-cp38-win32.whl", hash = "sha256:925073b2fe14ab9b87e73f9a5fde6ce6392da430f3004d8b72cc86f746f5163b"}, 639 | {file = "lxml-4.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:9b22c5c66f67ae00c0199f6055705bc3eb3fcb08d03d2ec4059a2b1b25ed48d7"}, 640 | {file = "lxml-4.9.2-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5f50a1c177e2fa3ee0667a5ab79fdc6b23086bc8b589d90b93b4bd17eb0e64d1"}, 641 | {file = "lxml-4.9.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:090c6543d3696cbe15b4ac6e175e576bcc3f1ccfbba970061b7300b0c15a2140"}, 642 | {file = "lxml-4.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:63da2ccc0857c311d764e7d3d90f429c252e83b52d1f8f1d1fe55be26827d1f4"}, 643 | {file = "lxml-4.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:5b4545b8a40478183ac06c073e81a5ce4cf01bf1734962577cf2bb569a5b3bbf"}, 644 | {file = "lxml-4.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2e430cd2824f05f2d4f687701144556646bae8f249fd60aa1e4c768ba7018947"}, 645 | {file = "lxml-4.9.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6804daeb7ef69e7b36f76caddb85cccd63d0c56dedb47555d2fc969e2af6a1a5"}, 646 | {file = "lxml-4.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a6e441a86553c310258aca15d1c05903aaf4965b23f3bc2d55f200804e005ee5"}, 647 | {file = "lxml-4.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ca34efc80a29351897e18888c71c6aca4a359247c87e0b1c7ada14f0ab0c0fb2"}, 648 | {file = "lxml-4.9.2-cp39-cp39-win32.whl", hash = "sha256:6b418afe5df18233fc6b6093deb82a32895b6bb0b1155c2cdb05203f583053f1"}, 649 | {file = "lxml-4.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:f1496ea22ca2c830cbcbd473de8f114a320da308438ae65abad6bab7867fe38f"}, 650 | {file = "lxml-4.9.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:b264171e3143d842ded311b7dccd46ff9ef34247129ff5bf5066123c55c2431c"}, 651 | {file = "lxml-4.9.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0dc313ef231edf866912e9d8f5a042ddab56c752619e92dfd3a2c277e6a7299a"}, 652 | {file = "lxml-4.9.2-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:16efd54337136e8cd72fb9485c368d91d77a47ee2d42b057564aae201257d419"}, 653 | {file = "lxml-4.9.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0f2b1e0d79180f344ff9f321327b005ca043a50ece8713de61d1cb383fb8ac05"}, 654 | {file = "lxml-4.9.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:7b770ed79542ed52c519119473898198761d78beb24b107acf3ad65deae61f1f"}, 655 | {file = "lxml-4.9.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efa29c2fe6b4fdd32e8ef81c1528506895eca86e1d8c4657fda04c9b3786ddf9"}, 656 | {file = "lxml-4.9.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7e91ee82f4199af8c43d8158024cbdff3d931df350252288f0d4ce656df7f3b5"}, 657 | {file = "lxml-4.9.2-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:b23e19989c355ca854276178a0463951a653309fb8e57ce674497f2d9f208746"}, 658 | {file = "lxml-4.9.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:01d36c05f4afb8f7c20fd9ed5badca32a2029b93b1750f571ccc0b142531caf7"}, 659 | {file = "lxml-4.9.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7b515674acfdcadb0eb5d00d8a709868173acece5cb0be3dd165950cbfdf5409"}, 660 | {file = "lxml-4.9.2.tar.gz", hash = "sha256:2455cfaeb7ac70338b3257f41e21f0724f4b5b0c0e7702da67ee6c3640835b67"}, 661 | ] 662 | 663 | [package.extras] 664 | cssselect = ["cssselect (>=0.7)"] 665 | html5 = ["html5lib"] 666 | htmlsoup = ["BeautifulSoup4"] 667 | source = ["Cython (>=0.29.7)"] 668 | 669 | [[package]] 670 | name = "markdown" 671 | version = "3.3.4" 672 | description = "Python implementation of Markdown." 673 | category = "dev" 674 | optional = false 675 | python-versions = ">=3.6" 676 | files = [ 677 | {file = "Markdown-3.3.4-py3-none-any.whl", hash = "sha256:96c3ba1261de2f7547b46a00ea8463832c921d3f9d6aba3f255a6f71386db20c"}, 678 | {file = "Markdown-3.3.4.tar.gz", hash = "sha256:31b5b491868dcc87d6c24b7e3d19a0d730d59d3e46f4eea6430a321bed387a49"}, 679 | ] 680 | 681 | [package.dependencies] 682 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 683 | 684 | [package.extras] 685 | testing = ["coverage", "pyyaml"] 686 | 687 | [[package]] 688 | name = "markupsafe" 689 | version = "2.1.2" 690 | description = "Safely add untrusted strings to HTML/XML markup." 691 | category = "dev" 692 | optional = false 693 | python-versions = ">=3.7" 694 | files = [ 695 | {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"}, 696 | {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"}, 697 | {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"}, 698 | {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"}, 699 | {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"}, 700 | {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"}, 701 | {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"}, 702 | {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"}, 703 | {file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"}, 704 | {file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"}, 705 | {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"}, 706 | {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"}, 707 | {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"}, 708 | {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"}, 709 | {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"}, 710 | {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"}, 711 | {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"}, 712 | {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"}, 713 | {file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"}, 714 | {file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"}, 715 | {file = "MarkupSafe-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a"}, 716 | {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a"}, 717 | {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a"}, 718 | {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2"}, 719 | {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"}, 720 | {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513"}, 721 | {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460"}, 722 | {file = "MarkupSafe-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859"}, 723 | {file = "MarkupSafe-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666"}, 724 | {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed"}, 725 | {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094"}, 726 | {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54"}, 727 | {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419"}, 728 | {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa"}, 729 | {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"}, 730 | {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba"}, 731 | {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03"}, 732 | {file = "MarkupSafe-2.1.2-cp38-cp38-win32.whl", hash = "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2"}, 733 | {file = "MarkupSafe-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147"}, 734 | {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"}, 735 | {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"}, 736 | {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"}, 737 | {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"}, 738 | {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"}, 739 | {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"}, 740 | {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"}, 741 | {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"}, 742 | {file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"}, 743 | {file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"}, 744 | {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"}, 745 | ] 746 | 747 | [[package]] 748 | name = "mccabe" 749 | version = "0.6.1" 750 | description = "McCabe checker, plugin for flake8" 751 | category = "dev" 752 | optional = false 753 | python-versions = "*" 754 | files = [ 755 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 756 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 757 | ] 758 | 759 | [[package]] 760 | name = "mergedeep" 761 | version = "1.3.4" 762 | description = "A deep merge function for 🐍." 763 | category = "dev" 764 | optional = false 765 | python-versions = ">=3.6" 766 | files = [ 767 | {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, 768 | {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, 769 | ] 770 | 771 | [[package]] 772 | name = "mkautodoc" 773 | version = "0.1.0" 774 | description = "AutoDoc for MarkDown" 775 | category = "dev" 776 | optional = false 777 | python-versions = ">=3.6" 778 | files = [ 779 | {file = "mkautodoc-0.1.0.tar.gz", hash = "sha256:7c2595f40276b356e576ce7e343338f8b4fa1e02ea904edf33fadf82b68ca67c"}, 780 | ] 781 | 782 | [[package]] 783 | name = "mkdocs" 784 | version = "1.2.4" 785 | description = "Project documentation with Markdown." 786 | category = "dev" 787 | optional = false 788 | python-versions = ">=3.6" 789 | files = [ 790 | {file = "mkdocs-1.2.4-py3-none-any.whl", hash = "sha256:f108e7ab5a7ed3e30826dbf82f37638f0d90d11161644616cc4f01a1e2ab3940"}, 791 | {file = "mkdocs-1.2.4.tar.gz", hash = "sha256:8e7970a26183487fe2a1041940c6fd03aa0dbe5549e50c3e7194f565cb3c678a"}, 792 | ] 793 | 794 | [package.dependencies] 795 | click = ">=3.3" 796 | ghp-import = ">=1.0" 797 | importlib-metadata = ">=3.10" 798 | Jinja2 = ">=2.10.1" 799 | Markdown = ">=3.2.1" 800 | mergedeep = ">=1.3.4" 801 | packaging = ">=20.5" 802 | PyYAML = ">=3.10" 803 | pyyaml-env-tag = ">=0.1" 804 | watchdog = ">=2.0" 805 | 806 | [package.extras] 807 | i18n = ["babel (>=2.9.0)"] 808 | 809 | [[package]] 810 | name = "mock" 811 | version = "4.0.3" 812 | description = "Rolling backport of unittest.mock for all Pythons" 813 | category = "dev" 814 | optional = false 815 | python-versions = ">=3.6" 816 | files = [ 817 | {file = "mock-4.0.3-py3-none-any.whl", hash = "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62"}, 818 | {file = "mock-4.0.3.tar.gz", hash = "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc"}, 819 | ] 820 | 821 | [package.extras] 822 | build = ["blurb", "twine", "wheel"] 823 | docs = ["sphinx"] 824 | test = ["pytest (<5.4)", "pytest-cov"] 825 | 826 | [[package]] 827 | name = "mypy" 828 | version = "0.910" 829 | description = "Optional static typing for Python" 830 | category = "dev" 831 | optional = false 832 | python-versions = ">=3.5" 833 | files = [ 834 | {file = "mypy-0.910-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457"}, 835 | {file = "mypy-0.910-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb"}, 836 | {file = "mypy-0.910-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9"}, 837 | {file = "mypy-0.910-cp35-cp35m-win_amd64.whl", hash = "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e"}, 838 | {file = "mypy-0.910-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921"}, 839 | {file = "mypy-0.910-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6"}, 840 | {file = "mypy-0.910-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212"}, 841 | {file = "mypy-0.910-cp36-cp36m-win_amd64.whl", hash = "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885"}, 842 | {file = "mypy-0.910-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0"}, 843 | {file = "mypy-0.910-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de"}, 844 | {file = "mypy-0.910-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703"}, 845 | {file = "mypy-0.910-cp37-cp37m-win_amd64.whl", hash = "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a"}, 846 | {file = "mypy-0.910-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504"}, 847 | {file = "mypy-0.910-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9"}, 848 | {file = "mypy-0.910-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072"}, 849 | {file = "mypy-0.910-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811"}, 850 | {file = "mypy-0.910-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e"}, 851 | {file = "mypy-0.910-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b"}, 852 | {file = "mypy-0.910-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2"}, 853 | {file = "mypy-0.910-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97"}, 854 | {file = "mypy-0.910-cp39-cp39-win_amd64.whl", hash = "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8"}, 855 | {file = "mypy-0.910-py3-none-any.whl", hash = "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"}, 856 | {file = "mypy-0.910.tar.gz", hash = "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150"}, 857 | ] 858 | 859 | [package.dependencies] 860 | mypy-extensions = ">=0.4.3,<0.5.0" 861 | toml = "*" 862 | typed-ast = {version = ">=1.4.0,<1.5.0", markers = "python_version < \"3.8\""} 863 | typing-extensions = ">=3.7.4" 864 | 865 | [package.extras] 866 | dmypy = ["psutil (>=4.0)"] 867 | python2 = ["typed-ast (>=1.4.0,<1.5.0)"] 868 | 869 | [[package]] 870 | name = "mypy-extensions" 871 | version = "0.4.4" 872 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 873 | category = "dev" 874 | optional = false 875 | python-versions = ">=2.7" 876 | files = [ 877 | {file = "mypy_extensions-0.4.4.tar.gz", hash = "sha256:c8b707883a96efe9b4bb3aaf0dcc07e7e217d7d8368eec4db4049ee9e142f4fd"}, 878 | ] 879 | 880 | [[package]] 881 | name = "packaging" 882 | version = "23.0" 883 | description = "Core utilities for Python packages" 884 | category = "main" 885 | optional = false 886 | python-versions = ">=3.7" 887 | files = [ 888 | {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, 889 | {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, 890 | ] 891 | 892 | [[package]] 893 | name = "pathspec" 894 | version = "0.11.1" 895 | description = "Utility library for gitignore style pattern matching of file paths." 896 | category = "dev" 897 | optional = false 898 | python-versions = ">=3.7" 899 | files = [ 900 | {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, 901 | {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, 902 | ] 903 | 904 | [[package]] 905 | name = "platformdirs" 906 | version = "3.2.0" 907 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 908 | category = "dev" 909 | optional = false 910 | python-versions = ">=3.7" 911 | files = [ 912 | {file = "platformdirs-3.2.0-py3-none-any.whl", hash = "sha256:ebe11c0d7a805086e99506aa331612429a72ca7cd52a1f0d277dc4adc20cb10e"}, 913 | {file = "platformdirs-3.2.0.tar.gz", hash = "sha256:d5b638ca397f25f979350ff789db335903d7ea010ab28903f57b27e1b16c2b08"}, 914 | ] 915 | 916 | [package.dependencies] 917 | typing-extensions = {version = ">=4.5", markers = "python_version < \"3.8\""} 918 | 919 | [package.extras] 920 | docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] 921 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] 922 | 923 | [[package]] 924 | name = "pluggy" 925 | version = "1.0.0" 926 | description = "plugin and hook calling mechanisms for python" 927 | category = "dev" 928 | optional = false 929 | python-versions = ">=3.6" 930 | files = [ 931 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 932 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 933 | ] 934 | 935 | [package.dependencies] 936 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 937 | 938 | [package.extras] 939 | dev = ["pre-commit", "tox"] 940 | testing = ["pytest", "pytest-benchmark"] 941 | 942 | [[package]] 943 | name = "py" 944 | version = "1.11.0" 945 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 946 | category = "dev" 947 | optional = false 948 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 949 | files = [ 950 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 951 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 952 | ] 953 | 954 | [[package]] 955 | name = "pycodestyle" 956 | version = "2.8.0" 957 | description = "Python style guide checker" 958 | category = "dev" 959 | optional = false 960 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 961 | files = [ 962 | {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, 963 | {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, 964 | ] 965 | 966 | [[package]] 967 | name = "pydantic" 968 | version = "1.10.7" 969 | description = "Data validation and settings management using python type hints" 970 | category = "dev" 971 | optional = false 972 | python-versions = ">=3.7" 973 | files = [ 974 | {file = "pydantic-1.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e79e999e539872e903767c417c897e729e015872040e56b96e67968c3b918b2d"}, 975 | {file = "pydantic-1.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:01aea3a42c13f2602b7ecbbea484a98169fb568ebd9e247593ea05f01b884b2e"}, 976 | {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:516f1ed9bc2406a0467dd777afc636c7091d71f214d5e413d64fef45174cfc7a"}, 977 | {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae150a63564929c675d7f2303008d88426a0add46efd76c3fc797cd71cb1b46f"}, 978 | {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ecbbc51391248116c0a055899e6c3e7ffbb11fb5e2a4cd6f2d0b93272118a209"}, 979 | {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f4a2b50e2b03d5776e7f21af73e2070e1b5c0d0df255a827e7c632962f8315af"}, 980 | {file = "pydantic-1.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:a7cd2251439988b413cb0a985c4ed82b6c6aac382dbaff53ae03c4b23a70e80a"}, 981 | {file = "pydantic-1.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:68792151e174a4aa9e9fc1b4e653e65a354a2fa0fed169f7b3d09902ad2cb6f1"}, 982 | {file = "pydantic-1.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe2507b8ef209da71b6fb5f4e597b50c5a34b78d7e857c4f8f3115effaef5fe"}, 983 | {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a86d8c8db68086f1e30a530f7d5f83eb0685e632e411dbbcf2d5c0150e8dcd"}, 984 | {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75ae19d2a3dbb146b6f324031c24f8a3f52ff5d6a9f22f0683694b3afcb16fb"}, 985 | {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:464855a7ff7f2cc2cf537ecc421291b9132aa9c79aef44e917ad711b4a93163b"}, 986 | {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:193924c563fae6ddcb71d3f06fa153866423ac1b793a47936656e806b64e24ca"}, 987 | {file = "pydantic-1.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:b4a849d10f211389502059c33332e91327bc154acc1845f375a99eca3afa802d"}, 988 | {file = "pydantic-1.10.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cc1dde4e50a5fc1336ee0581c1612215bc64ed6d28d2c7c6f25d2fe3e7c3e918"}, 989 | {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0cfe895a504c060e5d36b287ee696e2fdad02d89e0d895f83037245218a87fe"}, 990 | {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:670bb4683ad1e48b0ecb06f0cfe2178dcf74ff27921cdf1606e527d2617a81ee"}, 991 | {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:950ce33857841f9a337ce07ddf46bc84e1c4946d2a3bba18f8280297157a3fd1"}, 992 | {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c15582f9055fbc1bfe50266a19771bbbef33dd28c45e78afbe1996fd70966c2a"}, 993 | {file = "pydantic-1.10.7-cp37-cp37m-win_amd64.whl", hash = "sha256:82dffb306dd20bd5268fd6379bc4bfe75242a9c2b79fec58e1041fbbdb1f7914"}, 994 | {file = "pydantic-1.10.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c7f51861d73e8b9ddcb9916ae7ac39fb52761d9ea0df41128e81e2ba42886cd"}, 995 | {file = "pydantic-1.10.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6434b49c0b03a51021ade5c4daa7d70c98f7a79e95b551201fff682fc1661245"}, 996 | {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d34ab766fa056df49013bb6e79921a0265204c071984e75a09cbceacbbdd5d"}, 997 | {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:701daea9ffe9d26f97b52f1d157e0d4121644f0fcf80b443248434958fd03dc3"}, 998 | {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf135c46099ff3f919d2150a948ce94b9ce545598ef2c6c7bf55dca98a304b52"}, 999 | {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0f85904f73161817b80781cc150f8b906d521fa11e3cdabae19a581c3606209"}, 1000 | {file = "pydantic-1.10.7-cp38-cp38-win_amd64.whl", hash = "sha256:9f6f0fd68d73257ad6685419478c5aece46432f4bdd8d32c7345f1986496171e"}, 1001 | {file = "pydantic-1.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c230c0d8a322276d6e7b88c3f7ce885f9ed16e0910354510e0bae84d54991143"}, 1002 | {file = "pydantic-1.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:976cae77ba6a49d80f461fd8bba183ff7ba79f44aa5cfa82f1346b5626542f8e"}, 1003 | {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d45fc99d64af9aaf7e308054a0067fdcd87ffe974f2442312372dfa66e1001d"}, 1004 | {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2a5ebb48958754d386195fe9e9c5106f11275867051bf017a8059410e9abf1f"}, 1005 | {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:abfb7d4a7cd5cc4e1d1887c43503a7c5dd608eadf8bc615413fc498d3e4645cd"}, 1006 | {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:80b1fab4deb08a8292d15e43a6edccdffa5377a36a4597bb545b93e79c5ff0a5"}, 1007 | {file = "pydantic-1.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:d71e69699498b020ea198468e2480a2f1e7433e32a3a99760058c6520e2bea7e"}, 1008 | {file = "pydantic-1.10.7-py3-none-any.whl", hash = "sha256:0cd181f1d0b1d00e2b705f1bf1ac7799a2d938cce3376b8007df62b29be3c2c6"}, 1009 | {file = "pydantic-1.10.7.tar.gz", hash = "sha256:cfc83c0678b6ba51b0532bea66860617c4cd4251ecf76e9846fa5a9f3454e97e"}, 1010 | ] 1011 | 1012 | [package.dependencies] 1013 | typing-extensions = ">=4.2.0" 1014 | 1015 | [package.extras] 1016 | dotenv = ["python-dotenv (>=0.10.4)"] 1017 | email = ["email-validator (>=1.0.3)"] 1018 | 1019 | [[package]] 1020 | name = "pyflakes" 1021 | version = "2.4.0" 1022 | description = "passive checker of Python programs" 1023 | category = "dev" 1024 | optional = false 1025 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 1026 | files = [ 1027 | {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, 1028 | {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, 1029 | ] 1030 | 1031 | [[package]] 1032 | name = "pytest" 1033 | version = "6.2.5" 1034 | description = "pytest: simple powerful testing with Python" 1035 | category = "dev" 1036 | optional = false 1037 | python-versions = ">=3.6" 1038 | files = [ 1039 | {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, 1040 | {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, 1041 | ] 1042 | 1043 | [package.dependencies] 1044 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 1045 | attrs = ">=19.2.0" 1046 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 1047 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 1048 | iniconfig = "*" 1049 | packaging = "*" 1050 | pluggy = ">=0.12,<2.0" 1051 | py = ">=1.8.2" 1052 | toml = "*" 1053 | 1054 | [package.extras] 1055 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 1056 | 1057 | [[package]] 1058 | name = "python-dateutil" 1059 | version = "2.8.2" 1060 | description = "Extensions to the standard Python datetime module" 1061 | category = "dev" 1062 | optional = false 1063 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 1064 | files = [ 1065 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 1066 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 1067 | ] 1068 | 1069 | [package.dependencies] 1070 | six = ">=1.5" 1071 | 1072 | [[package]] 1073 | name = "pyyaml" 1074 | version = "6.0" 1075 | description = "YAML parser and emitter for Python" 1076 | category = "dev" 1077 | optional = false 1078 | python-versions = ">=3.6" 1079 | files = [ 1080 | {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, 1081 | {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, 1082 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, 1083 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, 1084 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, 1085 | {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, 1086 | {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, 1087 | {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, 1088 | {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, 1089 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, 1090 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, 1091 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, 1092 | {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, 1093 | {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, 1094 | {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, 1095 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, 1096 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, 1097 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, 1098 | {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, 1099 | {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, 1100 | {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, 1101 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, 1102 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, 1103 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, 1104 | {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, 1105 | {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, 1106 | {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, 1107 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, 1108 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, 1109 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, 1110 | {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, 1111 | {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, 1112 | {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, 1113 | {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, 1114 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, 1115 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, 1116 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, 1117 | {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, 1118 | {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, 1119 | {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, 1120 | ] 1121 | 1122 | [[package]] 1123 | name = "pyyaml-env-tag" 1124 | version = "0.1" 1125 | description = "A custom YAML tag for referencing environment variables in YAML files. " 1126 | category = "dev" 1127 | optional = false 1128 | python-versions = ">=3.6" 1129 | files = [ 1130 | {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, 1131 | {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, 1132 | ] 1133 | 1134 | [package.dependencies] 1135 | pyyaml = "*" 1136 | 1137 | [[package]] 1138 | name = "redis" 1139 | version = "3.5.3" 1140 | description = "Python client for Redis key-value store" 1141 | category = "main" 1142 | optional = true 1143 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 1144 | files = [ 1145 | {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"}, 1146 | {file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"}, 1147 | ] 1148 | 1149 | [package.extras] 1150 | hiredis = ["hiredis (>=0.1.3)"] 1151 | 1152 | [[package]] 1153 | name = "requests" 1154 | version = "2.28.2" 1155 | description = "Python HTTP for Humans." 1156 | category = "dev" 1157 | optional = false 1158 | python-versions = ">=3.7, <4" 1159 | files = [ 1160 | {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"}, 1161 | {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"}, 1162 | ] 1163 | 1164 | [package.dependencies] 1165 | certifi = ">=2017.4.17" 1166 | charset-normalizer = ">=2,<4" 1167 | idna = ">=2.5,<4" 1168 | urllib3 = ">=1.21.1,<1.27" 1169 | 1170 | [package.extras] 1171 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 1172 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 1173 | 1174 | [[package]] 1175 | name = "rfc3986" 1176 | version = "1.5.0" 1177 | description = "Validating URI References per RFC 3986" 1178 | category = "dev" 1179 | optional = false 1180 | python-versions = "*" 1181 | files = [ 1182 | {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, 1183 | {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, 1184 | ] 1185 | 1186 | [package.dependencies] 1187 | idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} 1188 | 1189 | [package.extras] 1190 | idna2008 = ["idna"] 1191 | 1192 | [[package]] 1193 | name = "setuptools" 1194 | version = "65.7.0" 1195 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 1196 | category = "main" 1197 | optional = false 1198 | python-versions = ">=3.7" 1199 | files = [ 1200 | {file = "setuptools-65.7.0-py3-none-any.whl", hash = "sha256:8ab4f1dbf2b4a65f7eec5ad0c620e84c34111a68d3349833494b9088212214dd"}, 1201 | {file = "setuptools-65.7.0.tar.gz", hash = "sha256:4d3c92fac8f1118bb77a22181355e29c239cabfe2b9effdaa665c66b711136d7"}, 1202 | ] 1203 | 1204 | [package.extras] 1205 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 1206 | testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 1207 | testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] 1208 | 1209 | [[package]] 1210 | name = "six" 1211 | version = "1.16.0" 1212 | description = "Python 2 and 3 compatibility utilities" 1213 | category = "dev" 1214 | optional = false 1215 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 1216 | files = [ 1217 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 1218 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 1219 | ] 1220 | 1221 | [[package]] 1222 | name = "sniffio" 1223 | version = "1.3.0" 1224 | description = "Sniff out which async library your code is running under" 1225 | category = "dev" 1226 | optional = false 1227 | python-versions = ">=3.7" 1228 | files = [ 1229 | {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, 1230 | {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, 1231 | ] 1232 | 1233 | [[package]] 1234 | name = "starlette" 1235 | version = "0.22.0" 1236 | description = "The little ASGI library that shines." 1237 | category = "dev" 1238 | optional = false 1239 | python-versions = ">=3.7" 1240 | files = [ 1241 | {file = "starlette-0.22.0-py3-none-any.whl", hash = "sha256:b5eda991ad5f0ee5d8ce4c4540202a573bb6691ecd0c712262d0bc85cf8f2c50"}, 1242 | {file = "starlette-0.22.0.tar.gz", hash = "sha256:b092cbc365bea34dd6840b42861bdabb2f507f8671e642e8272d2442e08ea4ff"}, 1243 | ] 1244 | 1245 | [package.dependencies] 1246 | anyio = ">=3.4.0,<5" 1247 | typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} 1248 | 1249 | [package.extras] 1250 | full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] 1251 | 1252 | [[package]] 1253 | name = "toml" 1254 | version = "0.10.2" 1255 | description = "Python Library for Tom's Obvious, Minimal Language" 1256 | category = "dev" 1257 | optional = false 1258 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 1259 | files = [ 1260 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 1261 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 1262 | ] 1263 | 1264 | [[package]] 1265 | name = "tomli" 1266 | version = "2.0.1" 1267 | description = "A lil' TOML parser" 1268 | category = "dev" 1269 | optional = false 1270 | python-versions = ">=3.7" 1271 | files = [ 1272 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 1273 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 1274 | ] 1275 | 1276 | [[package]] 1277 | name = "typed-ast" 1278 | version = "1.4.3" 1279 | description = "a fork of Python 2 and 3 ast modules with type comment support" 1280 | category = "dev" 1281 | optional = false 1282 | python-versions = "*" 1283 | files = [ 1284 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, 1285 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, 1286 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, 1287 | {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, 1288 | {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, 1289 | {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, 1290 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, 1291 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, 1292 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, 1293 | {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, 1294 | {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, 1295 | {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, 1296 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, 1297 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, 1298 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, 1299 | {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, 1300 | {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, 1301 | {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, 1302 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, 1303 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, 1304 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, 1305 | {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, 1306 | {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, 1307 | {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, 1308 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, 1309 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, 1310 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, 1311 | {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, 1312 | {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, 1313 | {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, 1314 | ] 1315 | 1316 | [[package]] 1317 | name = "types-redis" 1318 | version = "3.5.18" 1319 | description = "Typing stubs for redis" 1320 | category = "dev" 1321 | optional = false 1322 | python-versions = "*" 1323 | files = [ 1324 | {file = "types-redis-3.5.18.tar.gz", hash = "sha256:15482304e8848c63b383b938ffaba7ebe0b7f8f33381ecc450ee03935213e166"}, 1325 | {file = "types_redis-3.5.18-py3-none-any.whl", hash = "sha256:5c55c4b9e8ebdc6d57d4e47900b77d99f19ca0a563264af3f701246ed0926335"}, 1326 | ] 1327 | 1328 | [[package]] 1329 | name = "typing-extensions" 1330 | version = "4.5.0" 1331 | description = "Backported and Experimental Type Hints for Python 3.7+" 1332 | category = "main" 1333 | optional = false 1334 | python-versions = ">=3.7" 1335 | files = [ 1336 | {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, 1337 | {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, 1338 | ] 1339 | 1340 | [[package]] 1341 | name = "urllib3" 1342 | version = "1.26.15" 1343 | description = "HTTP library with thread-safe connection pooling, file post, and more." 1344 | category = "dev" 1345 | optional = false 1346 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 1347 | files = [ 1348 | {file = "urllib3-1.26.15-py2.py3-none-any.whl", hash = "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42"}, 1349 | {file = "urllib3-1.26.15.tar.gz", hash = "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305"}, 1350 | ] 1351 | 1352 | [package.extras] 1353 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] 1354 | secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] 1355 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 1356 | 1357 | [[package]] 1358 | name = "watchdog" 1359 | version = "3.0.0" 1360 | description = "Filesystem events monitoring" 1361 | category = "dev" 1362 | optional = false 1363 | python-versions = ">=3.7" 1364 | files = [ 1365 | {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, 1366 | {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, 1367 | {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"}, 1368 | {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"}, 1369 | {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"}, 1370 | {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"}, 1371 | {file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"}, 1372 | {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"}, 1373 | {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"}, 1374 | {file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"}, 1375 | {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"}, 1376 | {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"}, 1377 | {file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"}, 1378 | {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"}, 1379 | {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"}, 1380 | {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"}, 1381 | {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"}, 1382 | {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"}, 1383 | {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"}, 1384 | {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"}, 1385 | {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"}, 1386 | {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"}, 1387 | {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"}, 1388 | {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"}, 1389 | {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"}, 1390 | {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"}, 1391 | {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"}, 1392 | ] 1393 | 1394 | [package.extras] 1395 | watchmedo = ["PyYAML (>=3.10)"] 1396 | 1397 | [[package]] 1398 | name = "wrapt" 1399 | version = "1.15.0" 1400 | description = "Module for decorators, wrappers and monkey patching." 1401 | category = "main" 1402 | optional = false 1403 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 1404 | files = [ 1405 | {file = "wrapt-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ca1cccf838cd28d5a0883b342474c630ac48cac5df0ee6eacc9c7290f76b11c1"}, 1406 | {file = "wrapt-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e826aadda3cae59295b95343db8f3d965fb31059da7de01ee8d1c40a60398b29"}, 1407 | {file = "wrapt-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5fc8e02f5984a55d2c653f5fea93531e9836abbd84342c1d1e17abc4a15084c2"}, 1408 | {file = "wrapt-1.15.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:96e25c8603a155559231c19c0349245eeb4ac0096fe3c1d0be5c47e075bd4f46"}, 1409 | {file = "wrapt-1.15.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:40737a081d7497efea35ab9304b829b857f21558acfc7b3272f908d33b0d9d4c"}, 1410 | {file = "wrapt-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:f87ec75864c37c4c6cb908d282e1969e79763e0d9becdfe9fe5473b7bb1e5f09"}, 1411 | {file = "wrapt-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:1286eb30261894e4c70d124d44b7fd07825340869945c79d05bda53a40caa079"}, 1412 | {file = "wrapt-1.15.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:493d389a2b63c88ad56cdc35d0fa5752daac56ca755805b1b0c530f785767d5e"}, 1413 | {file = "wrapt-1.15.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:58d7a75d731e8c63614222bcb21dd992b4ab01a399f1f09dd82af17bbfc2368a"}, 1414 | {file = "wrapt-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:21f6d9a0d5b3a207cdf7acf8e58d7d13d463e639f0c7e01d82cdb671e6cb7923"}, 1415 | {file = "wrapt-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce42618f67741d4697684e501ef02f29e758a123aa2d669e2d964ff734ee00ee"}, 1416 | {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41d07d029dd4157ae27beab04d22b8e261eddfc6ecd64ff7000b10dc8b3a5727"}, 1417 | {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54accd4b8bc202966bafafd16e69da9d5640ff92389d33d28555c5fd4f25ccb7"}, 1418 | {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fbfbca668dd15b744418265a9607baa970c347eefd0db6a518aaf0cfbd153c0"}, 1419 | {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:76e9c727a874b4856d11a32fb0b389afc61ce8aaf281ada613713ddeadd1cfec"}, 1420 | {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e20076a211cd6f9b44a6be58f7eeafa7ab5720eb796975d0c03f05b47d89eb90"}, 1421 | {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a74d56552ddbde46c246b5b89199cb3fd182f9c346c784e1a93e4dc3f5ec9975"}, 1422 | {file = "wrapt-1.15.0-cp310-cp310-win32.whl", hash = "sha256:26458da5653aa5b3d8dc8b24192f574a58984c749401f98fff994d41d3f08da1"}, 1423 | {file = "wrapt-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:75760a47c06b5974aa5e01949bf7e66d2af4d08cb8c1d6516af5e39595397f5e"}, 1424 | {file = "wrapt-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ba1711cda2d30634a7e452fc79eabcadaffedf241ff206db2ee93dd2c89a60e7"}, 1425 | {file = "wrapt-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56374914b132c702aa9aa9959c550004b8847148f95e1b824772d453ac204a72"}, 1426 | {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a89ce3fd220ff144bd9d54da333ec0de0399b52c9ac3d2ce34b569cf1a5748fb"}, 1427 | {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bbe623731d03b186b3d6b0d6f51865bf598587c38d6f7b0be2e27414f7f214e"}, 1428 | {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3abbe948c3cbde2689370a262a8d04e32ec2dd4f27103669a45c6929bcdbfe7c"}, 1429 | {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b67b819628e3b748fd3c2192c15fb951f549d0f47c0449af0764d7647302fda3"}, 1430 | {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7eebcdbe3677e58dd4c0e03b4f2cfa346ed4049687d839adad68cc38bb559c92"}, 1431 | {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:74934ebd71950e3db69960a7da29204f89624dde411afbfb3b4858c1409b1e98"}, 1432 | {file = "wrapt-1.15.0-cp311-cp311-win32.whl", hash = "sha256:bd84395aab8e4d36263cd1b9308cd504f6cf713b7d6d3ce25ea55670baec5416"}, 1433 | {file = "wrapt-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:a487f72a25904e2b4bbc0817ce7a8de94363bd7e79890510174da9d901c38705"}, 1434 | {file = "wrapt-1.15.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:4ff0d20f2e670800d3ed2b220d40984162089a6e2c9646fdb09b85e6f9a8fc29"}, 1435 | {file = "wrapt-1.15.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9ed6aa0726b9b60911f4aed8ec5b8dd7bf3491476015819f56473ffaef8959bd"}, 1436 | {file = "wrapt-1.15.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:896689fddba4f23ef7c718279e42f8834041a21342d95e56922e1c10c0cc7afb"}, 1437 | {file = "wrapt-1.15.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:75669d77bb2c071333417617a235324a1618dba66f82a750362eccbe5b61d248"}, 1438 | {file = "wrapt-1.15.0-cp35-cp35m-win32.whl", hash = "sha256:fbec11614dba0424ca72f4e8ba3c420dba07b4a7c206c8c8e4e73f2e98f4c559"}, 1439 | {file = "wrapt-1.15.0-cp35-cp35m-win_amd64.whl", hash = "sha256:fd69666217b62fa5d7c6aa88e507493a34dec4fa20c5bd925e4bc12fce586639"}, 1440 | {file = "wrapt-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b0724f05c396b0a4c36a3226c31648385deb6a65d8992644c12a4963c70326ba"}, 1441 | {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbeccb1aa40ab88cd29e6c7d8585582c99548f55f9b2581dfc5ba68c59a85752"}, 1442 | {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38adf7198f8f154502883242f9fe7333ab05a5b02de7d83aa2d88ea621f13364"}, 1443 | {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:578383d740457fa790fdf85e6d346fda1416a40549fe8db08e5e9bd281c6a475"}, 1444 | {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a4cbb9ff5795cd66f0066bdf5947f170f5d63a9274f99bdbca02fd973adcf2a8"}, 1445 | {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:af5bd9ccb188f6a5fdda9f1f09d9f4c86cc8a539bd48a0bfdc97723970348418"}, 1446 | {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b56d5519e470d3f2fe4aa7585f0632b060d532d0696c5bdfb5e8319e1d0f69a2"}, 1447 | {file = "wrapt-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:77d4c1b881076c3ba173484dfa53d3582c1c8ff1f914c6461ab70c8428b796c1"}, 1448 | {file = "wrapt-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:077ff0d1f9d9e4ce6476c1a924a3332452c1406e59d90a2cf24aeb29eeac9420"}, 1449 | {file = "wrapt-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5c5aa28df055697d7c37d2099a7bc09f559d5053c3349b1ad0c39000e611d317"}, 1450 | {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a8564f283394634a7a7054b7983e47dbf39c07712d7b177b37e03f2467a024e"}, 1451 | {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780c82a41dc493b62fc5884fb1d3a3b81106642c5c5c78d6a0d4cbe96d62ba7e"}, 1452 | {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e169e957c33576f47e21864cf3fc9ff47c223a4ebca8960079b8bd36cb014fd0"}, 1453 | {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b02f21c1e2074943312d03d243ac4388319f2456576b2c6023041c4d57cd7019"}, 1454 | {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f2e69b3ed24544b0d3dbe2c5c0ba5153ce50dcebb576fdc4696d52aa22db6034"}, 1455 | {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d787272ed958a05b2c86311d3a4135d3c2aeea4fc655705f074130aa57d71653"}, 1456 | {file = "wrapt-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:02fce1852f755f44f95af51f69d22e45080102e9d00258053b79367d07af39c0"}, 1457 | {file = "wrapt-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:abd52a09d03adf9c763d706df707c343293d5d106aea53483e0ec8d9e310ad5e"}, 1458 | {file = "wrapt-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdb4f085756c96a3af04e6eca7f08b1345e94b53af8921b25c72f096e704e145"}, 1459 | {file = "wrapt-1.15.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:230ae493696a371f1dbffaad3dafbb742a4d27a0afd2b1aecebe52b740167e7f"}, 1460 | {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63424c681923b9f3bfbc5e3205aafe790904053d42ddcc08542181a30a7a51bd"}, 1461 | {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6bcbfc99f55655c3d93feb7ef3800bd5bbe963a755687cbf1f490a71fb7794b"}, 1462 | {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c99f4309f5145b93eca6e35ac1a988f0dc0a7ccf9ccdcd78d3c0adf57224e62f"}, 1463 | {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b130fe77361d6771ecf5a219d8e0817d61b236b7d8b37cc045172e574ed219e6"}, 1464 | {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:96177eb5645b1c6985f5c11d03fc2dbda9ad24ec0f3a46dcce91445747e15094"}, 1465 | {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5fe3e099cf07d0fb5a1e23d399e5d4d1ca3e6dfcbe5c8570ccff3e9208274f7"}, 1466 | {file = "wrapt-1.15.0-cp38-cp38-win32.whl", hash = "sha256:abd8f36c99512755b8456047b7be10372fca271bf1467a1caa88db991e7c421b"}, 1467 | {file = "wrapt-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:b06fa97478a5f478fb05e1980980a7cdf2712015493b44d0c87606c1513ed5b1"}, 1468 | {file = "wrapt-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2e51de54d4fb8fb50d6ee8327f9828306a959ae394d3e01a1ba8b2f937747d86"}, 1469 | {file = "wrapt-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0970ddb69bba00670e58955f8019bec4a42d1785db3faa043c33d81de2bf843c"}, 1470 | {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76407ab327158c510f44ded207e2f76b657303e17cb7a572ffe2f5a8a48aa04d"}, 1471 | {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd525e0e52a5ff16653a3fc9e3dd827981917d34996600bbc34c05d048ca35cc"}, 1472 | {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d37ac69edc5614b90516807de32d08cb8e7b12260a285ee330955604ed9dd29"}, 1473 | {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:078e2a1a86544e644a68422f881c48b84fef6d18f8c7a957ffd3f2e0a74a0d4a"}, 1474 | {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2cf56d0e237280baed46f0b5316661da892565ff58309d4d2ed7dba763d984b8"}, 1475 | {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7dc0713bf81287a00516ef43137273b23ee414fe41a3c14be10dd95ed98a2df9"}, 1476 | {file = "wrapt-1.15.0-cp39-cp39-win32.whl", hash = "sha256:46ed616d5fb42f98630ed70c3529541408166c22cdfd4540b88d5f21006b0eff"}, 1477 | {file = "wrapt-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:eef4d64c650f33347c1f9266fa5ae001440b232ad9b98f1f43dfe7a79435c0a6"}, 1478 | {file = "wrapt-1.15.0-py3-none-any.whl", hash = "sha256:64b1df0f83706b4ef4cfb4fb0e4c2669100fd7ecacfb59e091fad300d4e04640"}, 1479 | {file = "wrapt-1.15.0.tar.gz", hash = "sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a"}, 1480 | ] 1481 | 1482 | [[package]] 1483 | name = "zipp" 1484 | version = "3.15.0" 1485 | description = "Backport of pathlib-compatible object wrapper for zip files" 1486 | category = "main" 1487 | optional = false 1488 | python-versions = ">=3.7" 1489 | files = [ 1490 | {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, 1491 | {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, 1492 | ] 1493 | 1494 | [package.extras] 1495 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 1496 | testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] 1497 | 1498 | [extras] 1499 | redis = ["redis"] 1500 | 1501 | [metadata] 1502 | lock-version = "2.0" 1503 | python-versions = ">=3.7,<4.0" 1504 | content-hash = "b0fbb75051b47ba71537e15c61e7bbbccd8edf91d806dc2ce83be350ed711297" 1505 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "slowapi" 3 | version = "0.1.9" 4 | description = "A rate limiting extension for Starlette and Fastapi" 5 | authors = ["Laurent Savaete "] 6 | license = "MIT" 7 | 8 | readme = "README.md" 9 | 10 | repository = "https://github.com/laurents/slowapi" 11 | homepage = "https://github.com/laurents/slowapi" 12 | documentation = "https://slowapi.readthedocs.io/en/latest/" 13 | 14 | include = ["slowapi/py.typed"] 15 | 16 | [tool.poetry.dependencies] 17 | python = ">=3.7,<4.0" 18 | limits = ">=2.3" 19 | redis = {version = "^3.4.1", optional = true} 20 | 21 | [tool.poetry.dev-dependencies] 22 | isort = "^4.3.21" 23 | mypy = "^0.910" 24 | black = "^23.0.0" 25 | fastapi = "^0.89.0" 26 | lxml = "^4.9.1" 27 | starlette = "^0.22.0" 28 | mock = "^4.0.1" 29 | hiro = "^0.5.1" 30 | requests = "^2.22.0" 31 | pytest = "~=6.2.5" 32 | mkdocs = "^1.2.3" 33 | mkautodoc = "^0.1.0" 34 | types-redis = "^3.5.6" 35 | coverage = "^6.3" 36 | flake8 = "^4.0.1" 37 | setuptools = "^65.5.0" 38 | httpx = "^0.23.3" 39 | 40 | [tool.black] 41 | line-length = 88 42 | 43 | [tool.isort] 44 | multi_line_output = 3 45 | include_trailing_comma = true 46 | force_grid_wrap = 0 47 | use_parentheses = true 48 | ensure_newline_before_comments = true 49 | line_length = 88 50 | 51 | [build-system] 52 | requires = ["poetry-core>=1.0.0"] 53 | build-backend = "poetry.core.masonry.api" 54 | 55 | [tool.poetry.extras] 56 | redis = ["redis"] 57 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /slowapi/__init__.py: -------------------------------------------------------------------------------- 1 | from .extension import Limiter, _rate_limit_exceeded_handler 2 | 3 | __all__ = ["Limiter", "_rate_limit_exceeded_handler"] 4 | -------------------------------------------------------------------------------- /slowapi/errors.py: -------------------------------------------------------------------------------- 1 | """ 2 | errors and exceptions 3 | """ 4 | 5 | from starlette.exceptions import HTTPException 6 | 7 | from .wrappers import Limit 8 | 9 | 10 | class RateLimitExceeded(HTTPException): 11 | """ 12 | exception raised when a rate limit is hit. 13 | """ 14 | 15 | limit = None 16 | 17 | def __init__(self, limit: Limit) -> None: 18 | self.limit = limit 19 | if limit.error_message: 20 | description: str = ( 21 | limit.error_message 22 | if not callable(limit.error_message) 23 | else limit.error_message() 24 | ) 25 | else: 26 | description = str(limit.limit) 27 | super(RateLimitExceeded, self).__init__(status_code=429, detail=description) 28 | -------------------------------------------------------------------------------- /slowapi/extension.py: -------------------------------------------------------------------------------- 1 | """ 2 | The starlette extension to rate-limit requests 3 | """ 4 | 5 | import asyncio 6 | import functools 7 | import inspect 8 | import itertools 9 | import logging 10 | import os 11 | import time 12 | from datetime import datetime 13 | from email.utils import formatdate, parsedate_to_datetime 14 | from functools import wraps 15 | from typing import ( 16 | Any, 17 | Callable, 18 | Dict, 19 | List, 20 | Optional, 21 | Set, 22 | Tuple, 23 | TypeVar, 24 | Union, 25 | ) 26 | 27 | from limits import RateLimitItem # type: ignore 28 | from limits.errors import ConfigurationError # type: ignore 29 | from limits.storage import MemoryStorage, storage_from_string # type: ignore 30 | from limits.strategies import STRATEGIES, RateLimiter # type: ignore 31 | from starlette.config import Config 32 | from starlette.datastructures import MutableHeaders 33 | from starlette.requests import Request 34 | from starlette.responses import JSONResponse, Response 35 | from typing_extensions import Literal 36 | 37 | from .errors import RateLimitExceeded 38 | from .wrappers import Limit, LimitGroup 39 | 40 | # used to annotate get_app_config method 41 | T = TypeVar("T") 42 | # Define an alias for the most commonly used type 43 | StrOrCallableStr = Union[str, Callable[..., str]] 44 | 45 | 46 | class C: 47 | ENABLED = "RATELIMIT_ENABLED" 48 | HEADERS_ENABLED = "RATELIMIT_HEADERS_ENABLED" 49 | STORAGE_URL = "RATELIMIT_STORAGE_URL" 50 | STORAGE_OPTIONS = "RATELIMIT_STORAGE_OPTIONS" 51 | STRATEGY = "RATELIMIT_STRATEGY" 52 | GLOBAL_LIMITS = "RATELIMIT_GLOBAL" 53 | DEFAULT_LIMITS = "RATELIMIT_DEFAULT" 54 | APPLICATION_LIMITS = "RATELIMIT_APPLICATION" 55 | HEADER_LIMIT = "RATELIMIT_HEADER_LIMIT" 56 | HEADER_REMAINING = "RATELIMIT_HEADER_REMAINING" 57 | HEADER_RESET = "RATELIMIT_HEADER_RESET" 58 | SWALLOW_ERRORS = "RATELIMIT_SWALLOW_ERRORS" 59 | IN_MEMORY_FALLBACK = "RATELIMIT_IN_MEMORY_FALLBACK" 60 | IN_MEMORY_FALLBACK_ENABLED = "RATELIMIT_IN_MEMORY_FALLBACK_ENABLED" 61 | HEADER_RETRY_AFTER = "RATELIMIT_HEADER_RETRY_AFTER" 62 | HEADER_RETRY_AFTER_VALUE = "RATELIMIT_HEADER_RETRY_AFTER_VALUE" 63 | KEY_PREFIX = "RATELIMIT_KEY_PREFIX" 64 | 65 | 66 | class HEADERS: 67 | RESET = 1 68 | REMAINING = 2 69 | LIMIT = 3 70 | RETRY_AFTER = 4 71 | 72 | 73 | MAX_BACKEND_CHECKS = 5 74 | 75 | 76 | def _rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded) -> Response: 77 | """ 78 | Build a simple JSON response that includes the details of the rate limit 79 | that was hit. If no limit is hit, the countdown is added to headers. 80 | """ 81 | response = JSONResponse( 82 | {"error": f"Rate limit exceeded: {exc.detail}"}, status_code=429 83 | ) 84 | response = request.app.state.limiter._inject_headers( 85 | response, request.state.view_rate_limit 86 | ) 87 | return response 88 | 89 | 90 | class Limiter: 91 | """ 92 | Initializes the slowapi rate limiter. 93 | 94 | ** parameter ** 95 | 96 | * **app**: `Starlette/FastAPI` instance to initialize the extension 97 | with. 98 | 99 | * **default_limits**: a variable list of strings or callables returning strings denoting global 100 | limits to apply to all routes. `ratelimit-string` for more details. 101 | 102 | * **application_limits**: a variable list of strings or callables returning strings for limits that 103 | are applied to the entire application (i.e a shared limit for all routes) 104 | 105 | * **key_func**: a callable that returns the domain to rate limit by. 106 | 107 | * **headers_enabled**: whether ``X-RateLimit`` response headers are written. 108 | 109 | * **strategy:** the strategy to use. refer to `ratelimit-strategy` 110 | 111 | * **storage_uri**: the storage location. refer to `ratelimit-conf` 112 | 113 | * **storage_options**: kwargs to pass to the storage implementation upon 114 | instantiation. 115 | * **auto_check**: whether to automatically check the rate limit in the before_request 116 | chain of the application. default ``True`` 117 | * **swallow_errors**: whether to swallow errors when hitting a rate limit. 118 | An exception will still be logged. default ``False`` 119 | * **in_memory_fallback**: a variable list of strings or callables returning strings denoting fallback 120 | limits to apply when the storage is down. 121 | * **in_memory_fallback_enabled**: simply falls back to in memory storage 122 | when the main storage is down and inherits the original limits. 123 | * **key_prefix**: prefix prepended to rate limiter keys. 124 | * **enabled**: set to False to deactivate the limiter (default: True) 125 | * **config_filename**: name of the config file for Starlette from which to load settings 126 | for the rate limiter. Defaults to ".env". 127 | * **key_style**: set to "url" to use the url, "endpoint" to use the view_func 128 | """ 129 | 130 | def __init__( 131 | self, 132 | # app: Starlette = None, 133 | key_func: Callable[..., str], 134 | default_limits: List[StrOrCallableStr] = [], 135 | application_limits: List[StrOrCallableStr] = [], 136 | headers_enabled: bool = False, 137 | strategy: Optional[str] = None, 138 | storage_uri: Optional[str] = None, 139 | storage_options: Dict[str, str] = {}, 140 | auto_check: bool = True, 141 | swallow_errors: bool = False, 142 | in_memory_fallback: List[StrOrCallableStr] = [], 143 | in_memory_fallback_enabled: bool = False, 144 | retry_after: Optional[str] = None, 145 | key_prefix: str = "", 146 | enabled: bool = True, 147 | config_filename: Optional[str] = None, 148 | key_style: Literal["endpoint", "url"] = "url", 149 | ) -> None: 150 | """ 151 | Configure the rate limiter at app level 152 | """ 153 | # assert app is not None, "Passing the app instance to the limiter is required" 154 | # self.app = app 155 | # app.state.limiter = self 156 | 157 | self.logger = logging.getLogger("slowapi") 158 | 159 | dotenv_file_exists = os.path.isfile(".env") 160 | self.app_config = Config( 161 | ".env" 162 | if dotenv_file_exists and config_filename is None 163 | else config_filename 164 | ) 165 | 166 | self.enabled = enabled 167 | self._default_limits = [] 168 | self._application_limits = [] 169 | self._in_memory_fallback: List[LimitGroup] = [] 170 | self._in_memory_fallback_enabled = ( 171 | in_memory_fallback_enabled or len(in_memory_fallback) > 0 172 | ) 173 | self._exempt_routes: Set[str] = set() 174 | self._request_filters: List[Callable[..., bool]] = [] 175 | self._headers_enabled = headers_enabled 176 | self._header_mapping: Dict[int, str] = {} 177 | self._retry_after: Optional[str] = retry_after 178 | self._strategy = strategy 179 | self._storage_uri = storage_uri 180 | self._storage_options = storage_options 181 | self._auto_check = auto_check 182 | self._swallow_errors = swallow_errors 183 | 184 | self._key_func = key_func 185 | self._key_prefix = key_prefix 186 | self._key_style = key_style 187 | 188 | for limit in set(default_limits): 189 | self._default_limits.extend( 190 | [ 191 | LimitGroup( 192 | limit, self._key_func, None, False, None, None, None, 1, False 193 | ) 194 | ] 195 | ) 196 | for limit in application_limits: 197 | self._application_limits.extend( 198 | [ 199 | LimitGroup( 200 | limit, 201 | self._key_func, 202 | "global", 203 | False, 204 | None, 205 | None, 206 | None, 207 | 1, 208 | False, 209 | ) 210 | ] 211 | ) 212 | for limit in in_memory_fallback: 213 | self._in_memory_fallback.extend( 214 | [ 215 | LimitGroup( 216 | limit, self._key_func, None, False, None, None, None, 1, False 217 | ) 218 | ] 219 | ) 220 | self._route_limits: Dict[str, List[Limit]] = {} 221 | self._dynamic_route_limits: Dict[str, List[LimitGroup]] = {} 222 | # a flag to note if the storage backend is dead (not available) 223 | self._storage_dead: bool = False 224 | self._fallback_limiter = None 225 | self.__check_backend_count = 0 226 | self.__last_check_backend = time.time() 227 | self.__marked_for_limiting: Dict[str, List[Callable]] = {} 228 | 229 | class BlackHoleHandler(logging.StreamHandler): 230 | def emit(*_): 231 | return 232 | 233 | self.logger.addHandler(BlackHoleHandler()) 234 | 235 | self.enabled = self.get_app_config(C.ENABLED, self.enabled) 236 | self._swallow_errors = self.get_app_config( 237 | C.SWALLOW_ERRORS, self._swallow_errors 238 | ) 239 | self._headers_enabled = self._headers_enabled or self.get_app_config( 240 | C.HEADERS_ENABLED, False 241 | ) 242 | self._storage_options.update(self.get_app_config(C.STORAGE_OPTIONS, {})) 243 | self._storage = storage_from_string( 244 | self._storage_uri or self.get_app_config(C.STORAGE_URL, "memory://"), 245 | **self._storage_options, 246 | ) 247 | strategy = self._strategy or self.get_app_config(C.STRATEGY, "fixed-window") 248 | if strategy not in STRATEGIES: 249 | raise ConfigurationError("Invalid rate limiting strategy %s" % strategy) 250 | self._limiter: RateLimiter = STRATEGIES[strategy](self._storage) 251 | self._header_mapping.update( 252 | { 253 | HEADERS.RESET: self._header_mapping.get( 254 | HEADERS.RESET, 255 | self.get_app_config(C.HEADER_RESET, "X-RateLimit-Reset"), 256 | ), 257 | HEADERS.REMAINING: self._header_mapping.get( 258 | HEADERS.REMAINING, 259 | self.get_app_config(C.HEADER_REMAINING, "X-RateLimit-Remaining"), 260 | ), 261 | HEADERS.LIMIT: self._header_mapping.get( 262 | HEADERS.LIMIT, 263 | self.get_app_config(C.HEADER_LIMIT, "X-RateLimit-Limit"), 264 | ), 265 | HEADERS.RETRY_AFTER: self._header_mapping.get( 266 | HEADERS.RETRY_AFTER, 267 | self.get_app_config(C.HEADER_RETRY_AFTER, "Retry-After"), 268 | ), 269 | } 270 | ) 271 | self._retry_after = self._retry_after or self.get_app_config( 272 | C.HEADER_RETRY_AFTER_VALUE 273 | ) 274 | self._key_prefix = self._key_prefix or self.get_app_config(C.KEY_PREFIX) 275 | app_limits: Optional[StrOrCallableStr] = self.get_app_config( 276 | C.APPLICATION_LIMITS, None 277 | ) 278 | if not self._application_limits and app_limits: 279 | self._application_limits = [ 280 | LimitGroup( 281 | app_limits, 282 | self._key_func, 283 | "global", 284 | False, 285 | None, 286 | None, 287 | None, 288 | 1, 289 | False, 290 | ) 291 | ] 292 | 293 | conf_limits: Optional[StrOrCallableStr] = self.get_app_config( 294 | C.DEFAULT_LIMITS, None 295 | ) 296 | if not self._default_limits and conf_limits: 297 | self._default_limits = [ 298 | LimitGroup( 299 | conf_limits, self._key_func, None, False, None, None, None, 1, False 300 | ) 301 | ] 302 | fallback_enabled = self.get_app_config(C.IN_MEMORY_FALLBACK_ENABLED, False) 303 | fallback_limits: Optional[StrOrCallableStr] = self.get_app_config( 304 | C.IN_MEMORY_FALLBACK, None 305 | ) 306 | if not self._in_memory_fallback and fallback_limits: 307 | self._in_memory_fallback = [ 308 | LimitGroup( 309 | fallback_limits, 310 | self._key_func, 311 | None, 312 | False, 313 | None, 314 | None, 315 | None, 316 | 1, 317 | False, 318 | ) 319 | ] 320 | if not self._in_memory_fallback_enabled: 321 | self._in_memory_fallback_enabled = ( 322 | fallback_enabled or len(self._in_memory_fallback) > 0 323 | ) 324 | 325 | if self._in_memory_fallback_enabled: 326 | self._fallback_storage = MemoryStorage() 327 | self._fallback_limiter = STRATEGIES[strategy](self._fallback_storage) 328 | 329 | def slowapi_startup(self) -> None: 330 | """ 331 | Starlette startup event handler that links the app with the Limiter instance. 332 | """ 333 | app.state.limiter = self # type: ignore 334 | app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore 335 | 336 | def get_app_config(self, key: str, default_value: T = None) -> T: 337 | """ 338 | Place holder until we find a better way to load config from app 339 | """ 340 | return ( 341 | self.app_config(key, default=default_value, cast=type(default_value)) 342 | if default_value 343 | else self.app_config(key, default=default_value) 344 | ) 345 | 346 | def __should_check_backend(self) -> bool: 347 | if self.__check_backend_count > MAX_BACKEND_CHECKS: 348 | self.__check_backend_count = 0 349 | if time.time() - self.__last_check_backend > pow(2, self.__check_backend_count): 350 | self.__last_check_backend = time.time() 351 | self.__check_backend_count += 1 352 | return True 353 | return False 354 | 355 | def reset(self) -> None: 356 | """ 357 | resets the storage if it supports being reset 358 | """ 359 | try: 360 | self._storage.reset() 361 | self.logger.info("Storage has been reset and all limits cleared") 362 | except NotImplementedError: 363 | self.logger.warning("This storage type does not support being reset") 364 | 365 | @property 366 | def limiter(self) -> RateLimiter: 367 | """ 368 | The backend that keeps track of consumption of endpoints vs limits 369 | """ 370 | if self._storage_dead and self._in_memory_fallback_enabled: 371 | assert ( 372 | self._fallback_limiter 373 | ), "Fallback limiter is needed when in memory fallback is enabled" 374 | return self._fallback_limiter 375 | else: 376 | return self._limiter 377 | 378 | def _inject_headers( 379 | self, response: Response, current_limit: Tuple[RateLimitItem, List[str]] 380 | ) -> Response: 381 | if self.enabled and self._headers_enabled and current_limit is not None: 382 | if not isinstance(response, Response): 383 | raise Exception( 384 | "parameter `response` must be an instance of starlette.responses.Response" 385 | ) 386 | try: 387 | window_stats: Tuple[int, int] = self.limiter.get_window_stats( 388 | current_limit[0], *current_limit[1] 389 | ) 390 | reset_in = 1 + window_stats[0] 391 | response.headers.append( 392 | self._header_mapping[HEADERS.LIMIT], str(current_limit[0].amount) 393 | ) 394 | response.headers.append( 395 | self._header_mapping[HEADERS.REMAINING], str(window_stats[1]) 396 | ) 397 | response.headers.append( 398 | self._header_mapping[HEADERS.RESET], str(reset_in) 399 | ) 400 | 401 | # response may have an existing retry after 402 | existing_retry_after_header = response.headers.get("Retry-After") 403 | 404 | if existing_retry_after_header is not None: 405 | reset_in = max( 406 | self._determine_retry_time(existing_retry_after_header), 407 | reset_in, 408 | ) 409 | 410 | response.headers[self._header_mapping[HEADERS.RETRY_AFTER]] = ( 411 | formatdate(reset_in) 412 | if self._retry_after == "http-date" 413 | else str(int(reset_in - time.time())) 414 | ) 415 | except: 416 | if self._in_memory_fallback and not self._storage_dead: 417 | self.logger.warning( 418 | "Rate limit storage unreachable - falling back to" 419 | " in-memory storage" 420 | ) 421 | self._storage_dead = True 422 | response = self._inject_headers(response, current_limit) 423 | if self._swallow_errors: 424 | self.logger.exception( 425 | "Failed to update rate limit headers. Swallowing error" 426 | ) 427 | else: 428 | raise 429 | return response 430 | 431 | def _inject_asgi_headers( 432 | self, headers: MutableHeaders, current_limit: Tuple[RateLimitItem, List[str]] 433 | ) -> MutableHeaders: 434 | """ 435 | Injects 'X-RateLimit-Reset', 'X-RateLimit-Remaining', 'X-RateLimit-Limit' 436 | and 'Retry-After' headers into :headers parameter if needed. 437 | 438 | Basically the same as _inject_headers, but without access to the Response object. 439 | -> supports ASGI Middlewares. 440 | """ 441 | if self.enabled and self._headers_enabled and current_limit is not None: 442 | try: 443 | window_stats: Tuple[int, int] = self.limiter.get_window_stats( 444 | current_limit[0], *current_limit[1] 445 | ) 446 | reset_in = 1 + window_stats[0] 447 | headers[self._header_mapping[HEADERS.LIMIT]] = str( 448 | current_limit[0].amount 449 | ) 450 | headers[self._header_mapping[HEADERS.REMAINING]] = str(window_stats[1]) 451 | headers[self._header_mapping[HEADERS.RESET]] = str(reset_in) 452 | 453 | # response may have an existing retry after 454 | existing_retry_after_header = headers.get("Retry-After") 455 | 456 | if existing_retry_after_header is not None: 457 | reset_in = max( 458 | self._determine_retry_time(existing_retry_after_header), 459 | reset_in, 460 | ) 461 | 462 | headers[self._header_mapping[HEADERS.RETRY_AFTER]] = ( 463 | formatdate(reset_in) 464 | if self._retry_after == "http-date" 465 | else str(int(reset_in - time.time())) 466 | ) 467 | except Exception: 468 | if self._in_memory_fallback and not self._storage_dead: 469 | self.logger.warning( 470 | "Rate limit storage unreachable - falling back to" 471 | " in-memory storage" 472 | ) 473 | self._storage_dead = True 474 | headers = self._inject_asgi_headers(headers, current_limit) 475 | if self._swallow_errors: 476 | self.logger.exception( 477 | "Failed to update rate limit headers. Swallowing error" 478 | ) 479 | else: 480 | raise 481 | return headers 482 | 483 | def __evaluate_limits( 484 | self, request: Request, endpoint: str, limits: List[Limit] 485 | ) -> None: 486 | failed_limit = None 487 | limit_for_header = None 488 | for lim in limits: 489 | limit_scope = lim.scope or endpoint 490 | if lim.is_exempt(request): 491 | continue 492 | if lim.methods is not None and request.method.lower() not in lim.methods: 493 | continue 494 | if lim.per_method: 495 | limit_scope += ":%s" % request.method 496 | 497 | if "request" in inspect.signature(lim.key_func).parameters.keys(): 498 | limit_key = lim.key_func(request) 499 | else: 500 | limit_key = lim.key_func() 501 | 502 | args = [limit_key, limit_scope] 503 | if all(args): 504 | if self._key_prefix: 505 | args = [self._key_prefix] + args 506 | if not limit_for_header or lim.limit < limit_for_header[0]: 507 | limit_for_header = (lim.limit, args) 508 | 509 | cost = lim.cost(request) if callable(lim.cost) else lim.cost 510 | if not self.limiter.hit(lim.limit, *args, cost=cost): 511 | self.logger.warning( 512 | "ratelimit %s (%s) exceeded at endpoint: %s", 513 | lim.limit, 514 | limit_key, 515 | limit_scope, 516 | ) 517 | failed_limit = lim 518 | limit_for_header = (lim.limit, args) 519 | break 520 | else: 521 | self.logger.error( 522 | "Skipping limit: %s. Empty value found in parameters.", lim.limit 523 | ) 524 | continue 525 | # keep track of which limit was hit, to be picked up for the response header 526 | request.state.view_rate_limit = limit_for_header 527 | 528 | if failed_limit: 529 | raise RateLimitExceeded(failed_limit) 530 | 531 | def _determine_retry_time(self, retry_header_value) -> int: 532 | try: 533 | retry_after_date: Optional[datetime] = parsedate_to_datetime( 534 | retry_header_value 535 | ) 536 | except (TypeError, ValueError): 537 | retry_after_date = None 538 | 539 | if retry_after_date is not None: 540 | return int(time.mktime(retry_after_date.timetuple())) 541 | 542 | try: 543 | retry_after_int: int = int(retry_header_value) 544 | except TypeError: 545 | raise ValueError( 546 | "Retry-After Header does not meet RFC2616 - value is not of http-date or int type." 547 | ) 548 | 549 | return int(time.time() + retry_after_int) 550 | 551 | def _check_request_limit( 552 | self, 553 | request: Request, 554 | endpoint_func: Optional[Callable[..., Any]], 555 | in_middleware: bool = True, 556 | ) -> None: 557 | """ 558 | Determine if the request is within limits 559 | """ 560 | endpoint_url = request["path"] or "" 561 | view_func = endpoint_func 562 | 563 | endpoint_func_name = ( 564 | f"{view_func.__module__}.{view_func.__name__}" if view_func else "" 565 | ) 566 | _endpoint_key = endpoint_url if self._key_style == "url" else endpoint_func_name 567 | # cases where we don't need to check the limits 568 | if ( 569 | not _endpoint_key 570 | or not self.enabled 571 | # or we are sending a static file 572 | # or view_func == current_app.send_static_file 573 | or endpoint_func_name in self._exempt_routes 574 | or any(fn() for fn in self._request_filters) 575 | ): 576 | return 577 | limits: List[Limit] = [] 578 | dynamic_limits: List[Limit] = [] 579 | 580 | if not in_middleware: 581 | limits = ( 582 | self._route_limits[endpoint_func_name] 583 | if endpoint_func_name in self._route_limits 584 | else [] 585 | ) 586 | dynamic_limits = [] 587 | if endpoint_func_name in self._dynamic_route_limits: 588 | for lim in self._dynamic_route_limits[endpoint_func_name]: 589 | try: 590 | dynamic_limits.extend(list(lim.with_request(request))) 591 | except ValueError as e: 592 | self.logger.error( 593 | "failed to load ratelimit for view function %s (%s)", 594 | endpoint_func_name, 595 | e, 596 | ) 597 | 598 | try: 599 | all_limits: List[Limit] = [] 600 | if self._storage_dead and self._fallback_limiter: 601 | if in_middleware and endpoint_func_name in self.__marked_for_limiting: 602 | pass 603 | else: 604 | if self.__should_check_backend() and self._storage.check(): 605 | self.logger.info("Rate limit storage recovered") 606 | self._storage_dead = False 607 | self.__check_backend_count = 0 608 | else: 609 | all_limits = list(itertools.chain(*self._in_memory_fallback)) 610 | if not all_limits: 611 | route_limits: List[Limit] = limits + dynamic_limits 612 | all_limits = ( 613 | list(itertools.chain(*self._application_limits)) 614 | if in_middleware 615 | else [] 616 | ) 617 | all_limits += route_limits 618 | combined_defaults = all( 619 | not limit.override_defaults for limit in route_limits 620 | ) 621 | if ( 622 | not route_limits 623 | and not ( 624 | in_middleware 625 | and endpoint_func_name in self.__marked_for_limiting 626 | ) 627 | or combined_defaults 628 | ): 629 | all_limits += list(itertools.chain(*self._default_limits)) 630 | # actually check the limits, so far we've only computed the list of limits to check 631 | self.__evaluate_limits(request, _endpoint_key, all_limits) 632 | except Exception as e: # no qa 633 | if isinstance(e, RateLimitExceeded): 634 | raise 635 | if self._in_memory_fallback_enabled and not self._storage_dead: 636 | self.logger.warning( 637 | "Rate limit storage unreachable - falling back to" 638 | " in-memory storage" 639 | ) 640 | self._storage_dead = True 641 | self._check_request_limit(request, endpoint_func, in_middleware) 642 | else: 643 | if self._swallow_errors: 644 | self.logger.exception("Failed to rate limit. Swallowing error") 645 | else: 646 | raise 647 | 648 | def __limit_decorator( 649 | self, 650 | limit_value: StrOrCallableStr, 651 | key_func: Optional[Callable[..., str]] = None, 652 | shared: bool = False, 653 | scope: Optional[StrOrCallableStr] = None, 654 | per_method: bool = False, 655 | methods: Optional[List[str]] = None, 656 | error_message: Optional[str] = None, 657 | exempt_when: Optional[Callable[..., bool]] = None, 658 | cost: Union[int, Callable[..., int]] = 1, 659 | override_defaults: bool = True, 660 | ) -> Callable[..., Any]: 661 | _scope = scope if shared else None 662 | 663 | def decorator(func: Callable[..., Response]): 664 | keyfunc = key_func or self._key_func 665 | name = f"{func.__module__}.{func.__name__}" 666 | dynamic_limit = None 667 | static_limits: List[Limit] = [] 668 | if callable(limit_value): 669 | dynamic_limit = LimitGroup( 670 | limit_value, 671 | keyfunc, 672 | _scope, 673 | per_method, 674 | methods, 675 | error_message, 676 | exempt_when, 677 | cost, 678 | override_defaults, 679 | ) 680 | else: 681 | try: 682 | static_limits = list( 683 | LimitGroup( 684 | limit_value, 685 | keyfunc, 686 | _scope, 687 | per_method, 688 | methods, 689 | error_message, 690 | exempt_when, 691 | cost, 692 | override_defaults, 693 | ) 694 | ) 695 | except ValueError as e: 696 | self.logger.error( 697 | "Failed to configure throttling for %s (%s)", 698 | name, 699 | e, 700 | ) 701 | self.__marked_for_limiting.setdefault(name, []).append(func) 702 | if dynamic_limit: 703 | self._dynamic_route_limits.setdefault(name, []).append(dynamic_limit) 704 | else: 705 | self._route_limits.setdefault(name, []).extend(static_limits) 706 | 707 | sig = inspect.signature(func) 708 | for idx, parameter in enumerate(sig.parameters.values()): 709 | if parameter.name == "request" or parameter.name == "websocket": 710 | break 711 | else: 712 | raise Exception( 713 | f'No "request" or "websocket" argument on function "{func}"' 714 | ) 715 | 716 | if asyncio.iscoroutinefunction(func): 717 | # Handle async request/response functions. 718 | @functools.wraps(func) 719 | async def async_wrapper(*args: Any, **kwargs: Any) -> Response: 720 | # get the request object from the decorated endpoint function 721 | if self.enabled: 722 | request = kwargs.get("request", args[idx] if args else None) 723 | if not isinstance(request, Request): 724 | raise Exception( 725 | "parameter `request` must be an instance of starlette.requests.Request" 726 | ) 727 | 728 | if self._auto_check and not getattr( 729 | request.state, "_rate_limiting_complete", False 730 | ): 731 | self._check_request_limit(request, func, False) 732 | request.state._rate_limiting_complete = True 733 | response = await func(*args, **kwargs) # type: ignore 734 | if self.enabled: 735 | if not isinstance(response, Response): 736 | # get the response object from the decorated endpoint function 737 | self._inject_headers( 738 | kwargs.get("response"), # type: ignore 739 | request.state.view_rate_limit, 740 | ) 741 | else: 742 | self._inject_headers( 743 | response, request.state.view_rate_limit 744 | ) 745 | return response 746 | 747 | return async_wrapper 748 | 749 | else: 750 | # Handle sync request/response functions. 751 | @functools.wraps(func) 752 | def sync_wrapper(*args: Any, **kwargs: Any) -> Response: 753 | # get the request object from the decorated endpoint function 754 | if self.enabled: 755 | request = kwargs.get("request", args[idx] if args else None) 756 | if not isinstance(request, Request): 757 | raise Exception( 758 | "parameter `request` must be an instance of starlette.requests.Request" 759 | ) 760 | 761 | if self._auto_check and not getattr( 762 | request.state, "_rate_limiting_complete", False 763 | ): 764 | self._check_request_limit(request, func, False) 765 | request.state._rate_limiting_complete = True 766 | response = func(*args, **kwargs) 767 | if self.enabled: 768 | if not isinstance(response, Response): 769 | # get the response object from the decorated endpoint function 770 | self._inject_headers( 771 | kwargs.get("response"), 772 | request.state.view_rate_limit, # type: ignore 773 | ) 774 | else: 775 | self._inject_headers( 776 | response, request.state.view_rate_limit 777 | ) 778 | return response 779 | 780 | return sync_wrapper 781 | 782 | return decorator 783 | 784 | def limit( 785 | self, 786 | limit_value: StrOrCallableStr, 787 | key_func: Optional[Callable[..., str]] = None, 788 | per_method: bool = False, 789 | methods: Optional[List[str]] = None, 790 | error_message: Optional[str] = None, 791 | exempt_when: Optional[Callable[..., bool]] = None, 792 | cost: Union[int, Callable[..., int]] = 1, 793 | override_defaults: bool = True, 794 | ) -> Callable: 795 | """ 796 | Decorator to be used for rate limiting individual routes. 797 | 798 | * **limit_value**: rate limit string or a callable that returns a string. 799 | :ref:`ratelimit-string` for more details. 800 | * **key_func**: function/lambda to extract the unique identifier for 801 | the rate limit. defaults to remote address of the request. 802 | * **per_method**: whether the limit is sub categorized into the http 803 | method of the request. 804 | * **methods**: if specified, only the methods in this list will be rate 805 | limited (default: None). 806 | * **error_message**: string (or callable that returns one) to override the 807 | error message used in the response. 808 | * **exempt_when**: function returning a boolean indicating whether to exempt 809 | the route from the limit. This function can optionally use a Request object. 810 | * **cost**: integer (or callable that returns one) which is the cost of a hit 811 | * **override_defaults**: whether to override the default limits (default: True) 812 | """ 813 | return self.__limit_decorator( 814 | limit_value, 815 | key_func, 816 | per_method=per_method, 817 | methods=methods, 818 | error_message=error_message, 819 | exempt_when=exempt_when, 820 | cost=cost, 821 | override_defaults=override_defaults, 822 | ) 823 | 824 | def shared_limit( 825 | self, 826 | limit_value: StrOrCallableStr, 827 | scope: StrOrCallableStr, 828 | key_func: Optional[Callable[..., str]] = None, 829 | error_message: Optional[str] = None, 830 | exempt_when: Optional[Callable[..., bool]] = None, 831 | cost: Union[int, Callable[..., int]] = 1, 832 | override_defaults: bool = True, 833 | ) -> Callable: 834 | """ 835 | Decorator to be applied to multiple routes sharing the same rate limit. 836 | 837 | * **limit_value**: rate limit string or a callable that returns a string. 838 | :ref:`ratelimit-string` for more details. 839 | * **scope**: a string or callable that returns a string 840 | for defining the rate limiting scope. 841 | * **key_func**: function/lambda to extract the unique identifier for 842 | the rate limit. defaults to remote address of the request. 843 | * **per_method**: whether the limit is sub categorized into the http 844 | method of the request. 845 | * **methods**: if specified, only the methods in this list will be rate 846 | limited (default: None). 847 | * **error_message**: string (or callable that returns one) to override the 848 | error message used in the response. 849 | * **exempt_when**: function returning a boolean indicating whether to exempt 850 | the route from the limit 851 | * **cost**: integer (or callable that returns one) which is the cost of a hit 852 | * **override_defaults**: whether to override the default limits (default: True) 853 | """ 854 | return self.__limit_decorator( 855 | limit_value, 856 | key_func, 857 | True, 858 | scope, 859 | error_message=error_message, 860 | exempt_when=exempt_when, 861 | cost=cost, 862 | override_defaults=override_defaults, 863 | ) 864 | 865 | def exempt(self, obj): 866 | """ 867 | Decorator to mark a view as exempt from rate limits. 868 | """ 869 | name = "%s.%s" % (obj.__module__, obj.__name__) 870 | 871 | self._exempt_routes.add(name) 872 | 873 | if asyncio.iscoroutinefunction(obj): 874 | 875 | @wraps(obj) 876 | async def __async_inner(*a, **k): 877 | return await obj(*a, **k) 878 | 879 | return __async_inner 880 | else: 881 | 882 | @wraps(obj) 883 | def __inner(*a, **k): 884 | return obj(*a, **k) 885 | 886 | return __inner 887 | -------------------------------------------------------------------------------- /slowapi/middleware.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Callable, Iterable, Optional, Tuple 3 | 4 | from starlette.applications import Starlette 5 | from starlette.datastructures import MutableHeaders 6 | from starlette.middleware.base import ( 7 | BaseHTTPMiddleware, 8 | RequestResponseEndpoint, 9 | ) 10 | from starlette.requests import Request 11 | from starlette.responses import Response 12 | from starlette.routing import BaseRoute, Match 13 | from starlette.types import ASGIApp, Message, Scope, Receive, Send 14 | 15 | from slowapi import Limiter, _rate_limit_exceeded_handler 16 | 17 | 18 | def _find_route_handler( 19 | routes: Iterable[BaseRoute], scope: Scope 20 | ) -> Optional[Callable]: 21 | handler = None 22 | for route in routes: 23 | match, _ = route.matches(scope) 24 | if match == Match.FULL and hasattr(route, "endpoint"): 25 | handler = route.endpoint # type: ignore 26 | return handler 27 | 28 | 29 | def _get_route_name(handler: Callable): 30 | return f"{handler.__module__}.{handler.__name__}" 31 | 32 | 33 | def _check_limits( 34 | limiter: Limiter, request: Request, handler: Optional[Callable], app: Starlette 35 | ) -> Tuple[Optional[Callable], bool, Optional[Exception]]: 36 | """ 37 | Utils to check (if needed) current requests limit. 38 | It returns a tuple of size 3: 39 | 1. The exception handler to run, if needed 40 | 2. a bool, True if we need to inject some headers, False otherwise 41 | 3. the exception that happened, if any 42 | """ 43 | if limiter._auto_check and not getattr( 44 | request.state, "_rate_limiting_complete", False 45 | ): 46 | try: 47 | limiter._check_request_limit(request, handler, True) 48 | except Exception as e: 49 | # handle the exception since the global exception handler won't pick it up if we call_next 50 | exception_handler = app.exception_handlers.get( 51 | type(e), _rate_limit_exceeded_handler 52 | ) 53 | return exception_handler, False, e 54 | 55 | return None, True, None 56 | return None, False, None 57 | 58 | 59 | def sync_check_limits( 60 | limiter: Limiter, request: Request, handler: Optional[Callable], app: Starlette 61 | ) -> Tuple[Optional[Response], bool]: 62 | """ 63 | Returns a `Response` object if an error occurred, as well as a boolean to know 64 | whether we should inject headers or not. 65 | Used in our WSGI middleware, it only supports synchronous exception_handler. 66 | This will fallback on _rate_limit_exceeded_handler otherwise. 67 | """ 68 | exception_handler, _bool, exc = _check_limits(limiter, request, handler, app) 69 | if not exception_handler or not exc: 70 | return None, _bool 71 | 72 | # cannot execute asynchronous code in a synchronous middleware, 73 | # -> fallback on default exception handler 74 | if inspect.iscoroutinefunction(exception_handler): 75 | exception_handler = _rate_limit_exceeded_handler 76 | 77 | return exception_handler(request, exc), _bool # type: ignore 78 | 79 | 80 | async def async_check_limits( 81 | limiter: Limiter, request: Request, handler: Optional[Callable], app: Starlette 82 | ) -> Tuple[Optional[Response], bool]: 83 | """ 84 | Returns a `Response` object if an error occurred, as well as a boolean to know 85 | whether we should inject headers or not. 86 | Used in our ASGI middleware, this support both synchronous or asynchronous exception handlers. 87 | """ 88 | exception_handler, _bool, exc = _check_limits(limiter, request, handler, app) 89 | if not exception_handler: 90 | return None, _bool 91 | 92 | if inspect.iscoroutinefunction(exception_handler): 93 | return await exception_handler(request, exc), _bool 94 | else: 95 | return exception_handler(request, exc), _bool 96 | 97 | 98 | def _should_exempt(limiter: Limiter, handler: Optional[Callable]) -> bool: 99 | # if we can't find the route handler 100 | if handler is None: 101 | return True 102 | 103 | name = _get_route_name(handler) 104 | 105 | # if exempt no need to check 106 | if name in limiter._exempt_routes: 107 | return True 108 | 109 | # there is a decorator for this route we let the decorator handle it 110 | if name in limiter._route_limits: 111 | return True 112 | 113 | return False 114 | 115 | 116 | class SlowAPIMiddleware(BaseHTTPMiddleware): 117 | async def dispatch( 118 | self, request: Request, call_next: RequestResponseEndpoint 119 | ) -> Response: 120 | app: Starlette = request.app 121 | limiter: Limiter = app.state.limiter 122 | 123 | if not limiter.enabled: 124 | return await call_next(request) 125 | 126 | handler = _find_route_handler(app.routes, request.scope) 127 | if _should_exempt(limiter, handler): 128 | return await call_next(request) 129 | 130 | error_response, should_inject_headers = sync_check_limits( 131 | limiter, request, handler, app 132 | ) 133 | if error_response is not None: 134 | return error_response 135 | 136 | response = await call_next(request) 137 | if should_inject_headers: 138 | response = limiter._inject_headers(response, request.state.view_rate_limit) 139 | return response 140 | 141 | 142 | class SlowAPIASGIMiddleware: 143 | def __init__(self, app: ASGIApp) -> None: 144 | self.app = app 145 | 146 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 147 | if scope["type"] != "http": 148 | return await self.app(scope, receive, send) 149 | 150 | await _ASGIMiddlewareResponder(self.app)(scope, receive, send) 151 | 152 | 153 | class _ASGIMiddlewareResponder: 154 | def __init__(self, app: ASGIApp) -> None: 155 | self.app = app 156 | self.error_response: Optional[Response] = None 157 | self.initial_message: Message = {} 158 | self.inject_headers = False 159 | 160 | async def send_wrapper(self, message: Message) -> None: 161 | if message["type"] == "http.response.start": 162 | # do not send the http.response.start message now, so that we can edit the headers 163 | # before sending it, based on what happens in the http.response.body message. 164 | self.initial_message = message 165 | 166 | elif message["type"] == "http.response.body": 167 | if self.error_response: 168 | self.initial_message["status"] = self.error_response.status_code 169 | 170 | if self.inject_headers: 171 | headers = MutableHeaders(raw=self.initial_message["headers"]) 172 | headers = self.limiter._inject_asgi_headers( 173 | headers, self.request.state.view_rate_limit 174 | ) 175 | 176 | # send the http.response.start message just before the http.response.body one, 177 | # now that the headers are updated 178 | await self.send(self.initial_message) 179 | await self.send(message) 180 | 181 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 182 | self.send = send 183 | 184 | _app: Starlette = scope["app"] 185 | limiter: Limiter = _app.state.limiter 186 | 187 | if not limiter.enabled: 188 | return await self.app(scope, receive, self.send) 189 | 190 | handler = _find_route_handler(_app.routes, scope) 191 | request = Request(scope, receive=receive, send=self.send) 192 | if _should_exempt(limiter, handler): 193 | return await self.app(scope, receive, self.send) 194 | 195 | error_response, should_inject_headers = await async_check_limits( 196 | limiter, request, handler, _app 197 | ) 198 | if error_response is not None: 199 | return await error_response(scope, receive, self.send_wrapper) 200 | 201 | if should_inject_headers: 202 | self.inject_headers = True 203 | self.limiter = limiter 204 | self.request = request 205 | 206 | return await self.app(scope, receive, self.send_wrapper) 207 | -------------------------------------------------------------------------------- /slowapi/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurentS/slowapi/a72bcc66597f620f04bf5be3676e40ed308d3a6a/slowapi/py.typed -------------------------------------------------------------------------------- /slowapi/util.py: -------------------------------------------------------------------------------- 1 | from starlette.requests import Request 2 | 3 | 4 | def get_ipaddr(request: Request) -> str: 5 | """ 6 | Returns the ip address for the current request (or 127.0.0.1 if none found) 7 | based on the X-Forwarded-For headers. 8 | Note that a more robust method for determining IP address of the client is 9 | provided by uvicorn's ProxyHeadersMiddleware. 10 | """ 11 | if "X_FORWARDED_FOR" in request.headers: 12 | return request.headers["X_FORWARDED_FOR"] 13 | else: 14 | if not request.client or not request.client.host: 15 | return "127.0.0.1" 16 | 17 | return request.client.host 18 | 19 | 20 | def get_remote_address(request: Request) -> str: 21 | """ 22 | Returns the ip address for the current request (or 127.0.0.1 if none found) 23 | """ 24 | if not request.client or not request.client.host: 25 | return "127.0.0.1" 26 | 27 | return request.client.host 28 | -------------------------------------------------------------------------------- /slowapi/wrappers.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Callable, Iterator, List, Optional, Union 3 | 4 | from limits import RateLimitItem, parse_many # type: ignore 5 | from starlette.requests import Request 6 | 7 | 8 | class Limit(object): 9 | """ 10 | simple wrapper to encapsulate limits and their context 11 | """ 12 | 13 | def __init__( 14 | self, 15 | limit: RateLimitItem, 16 | key_func: Callable[..., str], 17 | scope: Optional[Union[str, Callable[..., str]]], 18 | per_method: bool, 19 | methods: Optional[List[str]], 20 | error_message: Optional[Union[str, Callable[..., str]]], 21 | exempt_when: Optional[Callable[..., bool]], 22 | cost: Union[int, Callable[..., int]], 23 | override_defaults: bool, 24 | ) -> None: 25 | self.limit = limit 26 | self.key_func = key_func 27 | self.__scope = scope 28 | self.per_method = per_method 29 | self.methods = methods 30 | self.error_message = error_message 31 | self.exempt_when = exempt_when 32 | self._exempt_when_takes_request = ( 33 | self.exempt_when 34 | and len(inspect.signature(self.exempt_when).parameters) == 1 35 | ) 36 | self.cost = cost 37 | self.override_defaults = override_defaults 38 | 39 | def is_exempt(self, request: Optional[Request] = None) -> bool: 40 | """ 41 | Check if the limit is exempt. 42 | 43 | ** parameter ** 44 | * **request**: the request object 45 | 46 | Return True to exempt the route from the limit. 47 | """ 48 | if self.exempt_when is None: 49 | return False 50 | if self._exempt_when_takes_request and request: 51 | return self.exempt_when(request) 52 | return self.exempt_when() 53 | 54 | @property 55 | def scope(self) -> str: 56 | # flack.request.endpoint is the name of the function for the endpoint 57 | # FIXME: how to get the request here? 58 | if self.__scope is None: 59 | return "" 60 | else: 61 | return ( 62 | self.__scope(request.endpoint) # type: ignore 63 | if callable(self.__scope) 64 | else self.__scope 65 | ) 66 | 67 | 68 | class LimitGroup(object): 69 | """ 70 | represents a group of related limits either from a string or a callable that returns one 71 | """ 72 | 73 | def __init__( 74 | self, 75 | limit_provider: Union[str, Callable[..., str]], 76 | key_function: Callable[..., str], 77 | scope: Optional[Union[str, Callable[..., str]]], 78 | per_method: bool, 79 | methods: Optional[List[str]], 80 | error_message: Optional[Union[str, Callable[..., str]]], 81 | exempt_when: Optional[Callable[..., bool]], 82 | cost: Union[int, Callable[..., int]], 83 | override_defaults: bool, 84 | ): 85 | self.__limit_provider = limit_provider 86 | self.__scope = scope 87 | self.key_function = key_function 88 | self.per_method = per_method 89 | self.methods = methods and [m.lower() for m in methods] or methods 90 | self.error_message = error_message 91 | self.exempt_when = exempt_when 92 | self.cost = cost 93 | self.override_defaults = override_defaults 94 | self.request = None 95 | 96 | def __iter__(self) -> Iterator[Limit]: 97 | if callable(self.__limit_provider): 98 | if "key" in inspect.signature(self.__limit_provider).parameters.keys(): 99 | assert ( 100 | "request" in inspect.signature(self.key_function).parameters.keys() 101 | ), f"Limit provider function {self.key_function.__name__} needs a `request` argument" 102 | if self.request is None: 103 | raise Exception("`request` object can't be None") 104 | limit_raw = self.__limit_provider(self.key_function(self.request)) 105 | else: 106 | limit_raw = self.__limit_provider() 107 | else: 108 | limit_raw = self.__limit_provider 109 | limit_items: List[RateLimitItem] = parse_many(limit_raw) 110 | for limit in limit_items: 111 | yield Limit( 112 | limit, 113 | self.key_function, 114 | self.__scope, 115 | self.per_method, 116 | self.methods, 117 | self.error_message, 118 | self.exempt_when, 119 | self.cost, 120 | self.override_defaults, 121 | ) 122 | 123 | def with_request(self, request): 124 | self.request = request 125 | return self 126 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import pytest 5 | from fastapi import FastAPI 6 | from mock import mock # type: ignore 7 | from starlette.applications import Starlette 8 | from starlette.requests import Request 9 | 10 | from slowapi.errors import RateLimitExceeded 11 | from slowapi.extension import Limiter, _rate_limit_exceeded_handler 12 | from slowapi.middleware import SlowAPIMiddleware, SlowAPIASGIMiddleware 13 | from slowapi.util import get_remote_address 14 | 15 | 16 | async def _async_rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded): 17 | await asyncio.sleep(0) 18 | return _rate_limit_exceeded_handler(request, exc) 19 | 20 | 21 | class TestSlowapi: 22 | @pytest.fixture( 23 | params=[ 24 | (SlowAPIMiddleware, _rate_limit_exceeded_handler), 25 | (SlowAPIASGIMiddleware, _rate_limit_exceeded_handler), 26 | (SlowAPIASGIMiddleware, _async_rate_limit_exceeded_handler), 27 | ] 28 | ) 29 | def build_starlette_app(self, request): 30 | def _factory(config={}, **limiter_args): 31 | middleware, exception_handler = request.param 32 | 33 | limiter_args.setdefault("key_func", get_remote_address) 34 | limiter = Limiter(**limiter_args) 35 | app = Starlette(debug=True) 36 | app.state.limiter = limiter 37 | app.add_exception_handler(RateLimitExceeded, exception_handler) 38 | app.add_middleware(middleware) 39 | 40 | mock_handler = mock.Mock() 41 | mock_handler.level = logging.INFO 42 | limiter.logger.addHandler(mock_handler) 43 | return app, limiter 44 | 45 | return _factory 46 | 47 | @pytest.fixture( 48 | params=[ 49 | (SlowAPIMiddleware, _rate_limit_exceeded_handler), 50 | (SlowAPIASGIMiddleware, _rate_limit_exceeded_handler), 51 | (SlowAPIASGIMiddleware, _async_rate_limit_exceeded_handler), 52 | ] 53 | ) 54 | def build_fastapi_app(self, request): 55 | def _factory(config={}, **limiter_args): 56 | middleware, exception_handler = request.param 57 | limiter_args.setdefault("key_func", get_remote_address) 58 | limiter = Limiter(**limiter_args) 59 | app = FastAPI() 60 | app.state.limiter = limiter 61 | app.add_exception_handler(RateLimitExceeded, exception_handler) 62 | app.add_middleware(middleware) 63 | 64 | mock_handler = mock.Mock() 65 | mock_handler.level = logging.INFO 66 | limiter.logger.addHandler(mock_handler) 67 | return app, limiter 68 | 69 | return _factory 70 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | def test_import(): 2 | import slowapi # noqa: F401 3 | -------------------------------------------------------------------------------- /tests/test_fastapi_extension.py: -------------------------------------------------------------------------------- 1 | import hiro # type: ignore 2 | import pytest # type: ignore 3 | from starlette.requests import Request 4 | from starlette.responses import PlainTextResponse, Response 5 | from starlette.testclient import TestClient 6 | 7 | from slowapi.util import get_ipaddr 8 | from tests import TestSlowapi 9 | 10 | 11 | class TestDecorators(TestSlowapi): 12 | def test_single_decorator(self, build_fastapi_app): 13 | app, limiter = build_fastapi_app(key_func=get_ipaddr) 14 | 15 | @app.get("/t1") 16 | @limiter.limit("5/minute") 17 | async def t1(request: Request): 18 | return PlainTextResponse("test") 19 | 20 | client = TestClient(app) 21 | for i in range(0, 10): 22 | response = client.get("/t1") 23 | assert response.status_code == 200 if i < 5 else 429 24 | 25 | def test_single_decorator_with_headers(self, build_fastapi_app): 26 | app, limiter = build_fastapi_app(key_func=get_ipaddr, headers_enabled=True) 27 | 28 | @app.get("/t1") 29 | @limiter.limit("5/minute") 30 | async def t1(request: Request): 31 | return PlainTextResponse("test") 32 | 33 | client = TestClient(app) 34 | for i in range(0, 10): 35 | response = client.get("/t1") 36 | assert response.status_code == 200 if i < 5 else 429 37 | assert ( 38 | response.headers.get("X-RateLimit-Limit") is not None if i < 5 else True 39 | ) 40 | assert response.headers.get("Retry-After") is not None if i < 5 else True 41 | 42 | def test_single_decorator_not_response(self, build_fastapi_app): 43 | app, limiter = build_fastapi_app(key_func=get_ipaddr) 44 | 45 | @app.get("/t1") 46 | @limiter.limit("5/minute") 47 | async def t1(request: Request, response: Response): 48 | return {"key": "value"} 49 | 50 | client = TestClient(app) 51 | for i in range(0, 10): 52 | response = client.get("/t1") 53 | assert response.status_code == 200 if i < 5 else 429 54 | 55 | def test_single_decorator_not_response_with_headers(self, build_fastapi_app): 56 | app, limiter = build_fastapi_app(key_func=get_ipaddr, headers_enabled=True) 57 | 58 | @app.get("/t1") 59 | @limiter.limit("5/minute") 60 | async def t1(request: Request, response: Response): 61 | return {"key": "value"} 62 | 63 | client = TestClient(app) 64 | for i in range(0, 10): 65 | response = client.get("/t1") 66 | assert response.status_code == 200 if i < 5 else 429 67 | assert ( 68 | response.headers.get("X-RateLimit-Limit") is not None if i < 5 else True 69 | ) 70 | assert response.headers.get("Retry-After") is not None if i < 5 else True 71 | 72 | def test_multiple_decorators(self, build_fastapi_app): 73 | app, limiter = build_fastapi_app(key_func=get_ipaddr) 74 | 75 | @app.get("/t1") 76 | @limiter.limit( 77 | "100 per minute", lambda: "test" 78 | ) # effectively becomes a limit for all users 79 | @limiter.limit("50/minute") # per ip as per default key_func 80 | async def t1(request: Request): 81 | return PlainTextResponse("test") 82 | 83 | with hiro.Timeline().freeze() as timeline: 84 | cli = TestClient(app) 85 | for i in range(0, 100): 86 | response = cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.2"}) 87 | assert response.status_code == 200 if i < 50 else 429 88 | for i in range(50): 89 | assert cli.get("/t1").status_code == 200 90 | 91 | assert cli.get("/t1").status_code == 429 92 | assert ( 93 | cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.3"}).status_code 94 | == 429 95 | ) 96 | 97 | def test_multiple_decorators_not_response(self, build_fastapi_app): 98 | app, limiter = build_fastapi_app(key_func=get_ipaddr) 99 | 100 | @app.get("/t1") 101 | @limiter.limit( 102 | "100 per minute", lambda: "test" 103 | ) # effectively becomes a limit for all users 104 | @limiter.limit("50/minute") # per ip as per default key_func 105 | async def t1(request: Request, response: Response): 106 | return {"key": "value"} 107 | 108 | with hiro.Timeline().freeze() as timeline: 109 | cli = TestClient(app) 110 | for i in range(0, 100): 111 | response = cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.2"}) 112 | assert response.status_code == 200 if i < 50 else 429 113 | for i in range(50): 114 | assert cli.get("/t1").status_code == 200 115 | 116 | assert cli.get("/t1").status_code == 429 117 | assert ( 118 | cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.3"}).status_code 119 | == 429 120 | ) 121 | 122 | def test_multiple_decorators_not_response_with_headers(self, build_fastapi_app): 123 | app, limiter = build_fastapi_app(key_func=get_ipaddr, headers_enabled=True) 124 | 125 | @app.get("/t1") 126 | @limiter.limit( 127 | "100 per minute", lambda: "test" 128 | ) # effectively becomes a limit for all users 129 | @limiter.limit("50/minute") # per ip as per default key_func 130 | async def t1(request: Request, response: Response): 131 | return {"key": "value"} 132 | 133 | with hiro.Timeline().freeze() as timeline: 134 | cli = TestClient(app) 135 | for i in range(0, 100): 136 | response = cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.2"}) 137 | assert response.status_code == 200 if i < 50 else 429 138 | for i in range(50): 139 | assert cli.get("/t1").status_code == 200 140 | 141 | assert cli.get("/t1").status_code == 429 142 | assert ( 143 | cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.3"}).status_code 144 | == 429 145 | ) 146 | 147 | def test_endpoint_missing_request_param(self, build_fastapi_app): 148 | app, limiter = build_fastapi_app(key_func=get_ipaddr) 149 | 150 | with pytest.raises(Exception) as exc_info: 151 | 152 | @app.get("/t3") 153 | @limiter.limit("5/minute") 154 | async def t3(): 155 | return PlainTextResponse("test") 156 | 157 | assert exc_info.match( 158 | r"""^No "request" or "websocket" argument on function .*""" 159 | ) 160 | 161 | def test_endpoint_missing_request_param_sync(self, build_fastapi_app): 162 | app, limiter = build_fastapi_app(key_func=get_ipaddr) 163 | 164 | with pytest.raises(Exception) as exc_info: 165 | 166 | @app.get("/t3_sync") 167 | @limiter.limit("5/minute") 168 | def t3(): 169 | return PlainTextResponse("test") 170 | 171 | assert exc_info.match( 172 | r"""^No "request" or "websocket" argument on function .*""" 173 | ) 174 | 175 | def test_endpoint_request_param_invalid(self, build_fastapi_app): 176 | app, limiter = build_fastapi_app(key_func=get_ipaddr) 177 | 178 | @app.get("/t4") 179 | @limiter.limit("5/minute") 180 | async def t4(request: str = None): 181 | return PlainTextResponse("test") 182 | 183 | with pytest.raises(Exception) as exc_info: 184 | client = TestClient(app) 185 | client.get("/t4") 186 | assert exc_info.match( 187 | r"""parameter `request` must be an instance of starlette.requests.Request""" 188 | ) 189 | 190 | def test_endpoint_response_param_invalid(self, build_fastapi_app): 191 | app, limiter = build_fastapi_app(key_func=get_ipaddr, headers_enabled=True) 192 | 193 | @app.get("/t4") 194 | @limiter.limit("5/minute") 195 | async def t4(request: Request, response: str = None): 196 | return {"key": "value"} 197 | 198 | with pytest.raises(Exception) as exc_info: 199 | client = TestClient(app) 200 | client.get("/t4") 201 | assert exc_info.match( 202 | r"""parameter `response` must be an instance of starlette.responses.Response""" 203 | ) 204 | 205 | def test_endpoint_request_param_invalid_sync(self, build_fastapi_app): 206 | app, limiter = build_fastapi_app(key_func=get_ipaddr) 207 | 208 | @app.get("/t5") 209 | @limiter.limit("5/minute") 210 | def t5(request: str = None): 211 | return PlainTextResponse("test") 212 | 213 | with pytest.raises(Exception) as exc_info: 214 | client = TestClient(app) 215 | client.get("/t5") 216 | assert exc_info.match( 217 | r"""parameter `request` must be an instance of starlette.requests.Request""" 218 | ) 219 | 220 | def test_endpoint_response_param_invalid_sync(self, build_fastapi_app): 221 | app, limiter = build_fastapi_app(key_func=get_ipaddr, headers_enabled=True) 222 | 223 | @app.get("/t5") 224 | @limiter.limit("5/minute") 225 | def t5(request: Request, response: str = None): 226 | return {"key": "value"} 227 | 228 | with pytest.raises(Exception) as exc_info: 229 | client = TestClient(app) 230 | client.get("/t5") 231 | assert exc_info.match( 232 | r"""parameter `response` must be an instance of starlette.responses.Response""" 233 | ) 234 | 235 | def test_dynamic_limit_provider_depending_on_key(self, build_fastapi_app): 236 | def custom_key_func(request: Request): 237 | if request.headers.get("TOKEN") == "secret": 238 | return "admin" 239 | return "user" 240 | 241 | def dynamic_limit_provider(key: str): 242 | if key == "admin": 243 | return "10/minute" 244 | return "5/minute" 245 | 246 | app, limiter = build_fastapi_app(key_func=custom_key_func) 247 | 248 | @app.get("/t1") 249 | @limiter.limit(dynamic_limit_provider) 250 | async def t1(request: Request, response: Response): 251 | return {"key": "value"} 252 | 253 | client = TestClient(app) 254 | for i in range(0, 10): 255 | response = client.get("/t1") 256 | assert response.status_code == 200 if i < 5 else 429 257 | 258 | for i in range(0, 20): 259 | response = client.get("/t1", headers={"TOKEN": "secret"}) 260 | assert response.status_code == 200 if i < 10 else 429 261 | 262 | def test_disabled_limiter(self, build_fastapi_app): 263 | """ 264 | Check that the limiter does nothing if disabled (both sync and async) 265 | """ 266 | app, limiter = build_fastapi_app(key_func=get_ipaddr, enabled=False) 267 | 268 | @app.get("/t1") 269 | @limiter.limit("5/minute") 270 | async def t1(request: Request): 271 | return PlainTextResponse("test") 272 | 273 | @app.get("/t2") 274 | @limiter.limit("5/minute") 275 | def t2(request: Request): 276 | return PlainTextResponse("test") 277 | 278 | @app.get("/t3") 279 | def t3(request: Request): 280 | return PlainTextResponse("also a test") 281 | 282 | client = TestClient(app) 283 | for i in range(0, 10): 284 | response = client.get("/t1") 285 | assert response.status_code == 200 286 | 287 | for i in range(0, 10): 288 | response = client.get("/t2") 289 | assert response.status_code == 200 290 | 291 | for i in range(0, 10): 292 | response = client.get("/t3") 293 | assert response.status_code == 200 294 | 295 | def test_cost(self, build_fastapi_app): 296 | app, limiter = build_fastapi_app(key_func=get_ipaddr) 297 | 298 | @app.get("/t1") 299 | @limiter.limit("50/minute", cost=10) 300 | async def t1(request: Request): 301 | return PlainTextResponse("test") 302 | 303 | @app.get("/t2") 304 | @limiter.limit("50/minute", cost=15) 305 | async def t2(request: Request): 306 | return PlainTextResponse("test") 307 | 308 | client = TestClient(app) 309 | for i in range(0, 10): 310 | response = client.get("/t1") 311 | assert response.status_code == 200 if i < 5 else 429 312 | 313 | response = client.get("/t2") 314 | assert response.status_code == 200 if i < 3 else 429 315 | 316 | def test_callable_cost(self, build_fastapi_app): 317 | app, limiter = build_fastapi_app(key_func=get_ipaddr) 318 | 319 | @app.get("/t1") 320 | @limiter.limit("50/minute", cost=lambda request: int(request.headers["foo"])) 321 | async def t1(request: Request): 322 | return PlainTextResponse("test") 323 | 324 | @app.get("/t2") 325 | @limiter.limit( 326 | "50/minute", cost=lambda request: int(request.headers["foo"]) * 1.5 327 | ) 328 | async def t2(request: Request): 329 | return PlainTextResponse("test") 330 | 331 | client = TestClient(app) 332 | for i in range(0, 10): 333 | response = client.get("/t1", headers={"foo": "10"}) 334 | assert response.status_code == 200 if i < 5 else 429 335 | 336 | response = client.get("/t2", headers={"foo": "5"}) 337 | assert response.status_code == 200 if i < 6 else 429 338 | 339 | @pytest.mark.parametrize( 340 | "key_style", 341 | ["url", "endpoint"], 342 | ) 343 | def test_key_style(self, build_fastapi_app, key_style): 344 | app, limiter = build_fastapi_app(key_func=lambda: "mock", key_style=key_style) 345 | 346 | @app.get("/t1/{my_param}") 347 | @limiter.limit("1/minute") 348 | async def t1_func(my_param: str, request: Request): 349 | return PlainTextResponse("test") 350 | 351 | client = TestClient(app) 352 | client.get("/t1/param_one") 353 | second_call = client.get("/t1/param_two") 354 | # with the "url" key_style, since the `my_param` value changed, the storage key is different 355 | # meaning it should not raise any RateLimitExceeded error. 356 | if key_style == "url": 357 | assert second_call.status_code == 200 358 | assert limiter._storage.get("LIMITER/mock//t1/param_one/1/1/minute") == 1 359 | assert limiter._storage.get("LIMITER/mock//t1/param_two/1/1/minute") == 1 360 | # However, with the `endpoint` key_style, it will use the function name (e.g: "t1_func") 361 | # meaning it will raise a RateLimitExceeded error, because no matter the parameter value 362 | # it will share the limitations. 363 | elif key_style == "endpoint": 364 | assert second_call.status_code == 429 365 | # check that we counted 2 requests, even though we had a different value for "my_param" 366 | assert ( 367 | limiter._storage.get( 368 | "LIMITER/mock/tests.test_fastapi_extension.t1_func/1/1/minute" 369 | ) 370 | == 2 371 | ) 372 | -------------------------------------------------------------------------------- /tests/test_starlette_extension.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import hiro # type: ignore 4 | import pytest # type: ignore 5 | from starlette.requests import Request 6 | from starlette.responses import PlainTextResponse 7 | from starlette.testclient import TestClient 8 | 9 | from slowapi.util import get_ipaddr, get_remote_address 10 | from tests import TestSlowapi 11 | 12 | 13 | class TestDecorators(TestSlowapi): 14 | def test_single_decorator_async(self, build_starlette_app): 15 | app, limiter = build_starlette_app(key_func=get_ipaddr) 16 | 17 | @limiter.limit("5/minute") 18 | async def t1(request: Request): 19 | return PlainTextResponse("test") 20 | 21 | app.add_route("/t1", t1) 22 | 23 | client = TestClient(app) 24 | for i in range(0, 10): 25 | response = client.get("/t1") 26 | assert response.status_code == 200 if i < 5 else 429 27 | if i < 5: 28 | assert response.text == "test" 29 | 30 | def test_single_decorator_sync(self, build_starlette_app): 31 | app, limiter = build_starlette_app(key_func=get_ipaddr) 32 | 33 | @limiter.limit("5/minute") 34 | def t1(request: Request): 35 | return PlainTextResponse("test") 36 | 37 | app.add_route("/t1", t1) 38 | 39 | client = TestClient(app) 40 | for i in range(0, 10): 41 | response = client.get("/t1") 42 | assert response.status_code == 200 if i < 5 else 429 43 | if i < 5: 44 | assert response.text == "test" 45 | 46 | def test_exempt_when_argument(self, build_starlette_app): 47 | app, limiter = build_starlette_app(key_func=get_ipaddr) 48 | 49 | def return_true(): 50 | return True 51 | 52 | def return_false(): 53 | return False 54 | 55 | def dynamic(request: Request): 56 | user_agent = request.headers.get("User-Agent") 57 | if user_agent is None: 58 | return False 59 | return user_agent == "exempt" 60 | 61 | @limiter.limit("1/minute", exempt_when=return_true) 62 | def always_true(request: Request): 63 | return PlainTextResponse("test") 64 | 65 | @limiter.limit("1/minute", exempt_when=return_false) 66 | def always_false(request: Request): 67 | return PlainTextResponse("test") 68 | 69 | @limiter.limit("1/minute", exempt_when=dynamic) 70 | def always_dynamic(request: Request): 71 | return PlainTextResponse("test") 72 | 73 | app.add_route("/true", always_true) 74 | app.add_route("/false", always_false) 75 | app.add_route("/dynamic", always_dynamic) 76 | 77 | client = TestClient(app) 78 | # Test always true always exempting 79 | for i in range(0, 2): 80 | response = client.get("/true") 81 | assert response.status_code == 200 82 | assert response.text == "test" 83 | # Test always false hitting the limit after one hit 84 | for i in range(0, 2): 85 | response = client.get("/false") 86 | assert response.status_code == 200 if i < 1 else 429 87 | if i < 1: 88 | assert response.text == "test" 89 | # Test dynamic not exempting with the correct header 90 | for i in range(0, 2): 91 | response = client.get("/dynamic", headers={"User-Agent": "exempt"}) 92 | assert response.status_code == 200 93 | assert response.text == "test" 94 | # Test dynamic exempting with the incorrect header 95 | for i in range(0, 2): 96 | response = client.get("/dynamic") 97 | assert response.status_code == 200 if i < 1 else 429 98 | if i < 1: 99 | assert response.text == "test" 100 | 101 | def test_shared_decorator(self, build_starlette_app): 102 | app, limiter = build_starlette_app(key_func=get_ipaddr) 103 | 104 | shared_lim = limiter.shared_limit("5/minute", "somescope") 105 | 106 | @shared_lim 107 | def t1(request: Request): 108 | return PlainTextResponse("test") 109 | 110 | @shared_lim 111 | def t2(request: Request): 112 | return PlainTextResponse("test") 113 | 114 | app.add_route("/t1", t1) 115 | app.add_route("/t2", t2) 116 | 117 | client = TestClient(app) 118 | for i in range(0, 10): 119 | response = client.get("/t1") 120 | assert response.status_code == 200 if i < 5 else 429 121 | # the shared limit has already been hit via t1 122 | assert client.get("/t2").status_code == 429 123 | 124 | def test_multiple_decorators(self, build_starlette_app): 125 | app, limiter = build_starlette_app(key_func=get_ipaddr) 126 | 127 | @limiter.limit("10 per minute", lambda: "test") 128 | @limiter.limit("5/minute") # per ip as per default key_func 129 | async def t1(request: Request): 130 | return PlainTextResponse("test") 131 | 132 | app.add_route("/t1", t1) 133 | 134 | with hiro.Timeline().freeze() as timeline: 135 | cli = TestClient(app) 136 | for i in range(0, 10): 137 | response = cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.2"}) 138 | assert response.status_code == 200 if i < 5 else 429 139 | for i in range(5): 140 | assert cli.get("/t1").status_code == 200 141 | 142 | assert cli.get("/t1").status_code == 429 143 | assert ( 144 | cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.3"}).status_code 145 | == 429 146 | ) 147 | 148 | def test_multiple_decorators_with_headers(self, build_starlette_app): 149 | app, limiter = build_starlette_app(key_func=get_ipaddr, headers_enabled=True) 150 | 151 | @limiter.limit("10 per minute", lambda: "test") 152 | @limiter.limit("5/minute") # per ip as per default key_func 153 | async def t1(request: Request): 154 | return PlainTextResponse("test") 155 | 156 | app.add_route("/t1", t1) 157 | 158 | with hiro.Timeline().freeze() as timeline: 159 | cli = TestClient(app) 160 | for i in range(0, 10): 161 | response = cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.2"}) 162 | assert response.status_code == 200 if i < 5 else 429 163 | assert response.headers.get("Retry-After") if i < 5 else True 164 | for i in range(5): 165 | assert cli.get("/t1").status_code == 200 166 | 167 | assert cli.get("/t1").status_code == 429 168 | assert ( 169 | cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.3"}).status_code 170 | == 429 171 | ) 172 | 173 | def test_headers_no_breach(self, build_starlette_app): 174 | app, limiter = build_starlette_app( 175 | headers_enabled=True, key_func=get_remote_address 176 | ) 177 | 178 | @app.route("/t1") 179 | @limiter.limit("10/minute") 180 | def t1(request: Request): 181 | return PlainTextResponse("test") 182 | 183 | @app.route("/t2") 184 | @limiter.limit("2/second; 5 per minute; 10/hour") 185 | def t2(request: Request): 186 | return PlainTextResponse("test") 187 | 188 | with hiro.Timeline().freeze(): 189 | with TestClient(app) as cli: 190 | resp = cli.get("/t1") 191 | assert resp.headers.get("X-RateLimit-Limit") == "10" 192 | assert resp.headers.get("X-RateLimit-Remaining") == "9" 193 | assert resp.headers.get("X-RateLimit-Reset") == str( 194 | int(time.time() + 61) 195 | ) 196 | assert resp.headers.get("Retry-After") == str(60) 197 | resp = cli.get("/t2") 198 | assert resp.headers.get("X-RateLimit-Limit") == "2" 199 | assert resp.headers.get("X-RateLimit-Remaining") == "1" 200 | assert resp.headers.get("X-RateLimit-Reset") == str( 201 | int(time.time() + 2) 202 | ) 203 | 204 | assert resp.headers.get("Retry-After") == str(1) 205 | 206 | def test_headers_breach(self, build_starlette_app): 207 | app, limiter = build_starlette_app( 208 | headers_enabled=True, key_func=get_remote_address 209 | ) 210 | 211 | @app.route("/t1") 212 | @limiter.limit("2/second; 10 per minute; 20/hour") 213 | def t(request: Request): 214 | return PlainTextResponse("test") 215 | 216 | with hiro.Timeline().freeze() as timeline: 217 | with TestClient(app) as cli: 218 | for i in range(11): 219 | resp = cli.get("/t1") 220 | timeline.forward(1) 221 | 222 | assert resp.headers.get("X-RateLimit-Limit") == "10" 223 | assert resp.headers.get("X-RateLimit-Remaining") == "0" 224 | assert resp.headers.get("X-RateLimit-Reset") == str( 225 | int(time.time() + 50) 226 | ) 227 | assert resp.headers.get("Retry-After") == str(int(50)) 228 | 229 | def test_retry_after(self, build_starlette_app): 230 | # FIXME: this test is not actually running! 231 | 232 | app, limiter = build_starlette_app( 233 | headers_enabled=True, key_func=get_remote_address 234 | ) 235 | 236 | @app.route("/t1") 237 | @limiter.limit("1/minute") 238 | def t(request: Request): 239 | return PlainTextResponse("test") 240 | 241 | with hiro.Timeline().freeze() as timeline: 242 | with TestClient(app) as cli: 243 | resp = cli.get("/t1") 244 | retry_after = int(resp.headers.get("Retry-After")) 245 | assert retry_after > 0 246 | timeline.forward(retry_after) 247 | resp = cli.get("/t1") 248 | assert resp.status_code == 200 249 | 250 | def test_exempt_decorator(self, build_starlette_app): 251 | app, limiter = build_starlette_app( 252 | headers_enabled=True, 253 | key_func=get_remote_address, 254 | default_limits=["1/minute"], 255 | ) 256 | 257 | @app.route("/t1") 258 | def t1(request: Request): 259 | return PlainTextResponse("test") 260 | 261 | with TestClient(app) as cli: 262 | resp = cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.10"}) 263 | assert resp.status_code == 200 264 | resp2 = cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.10"}) 265 | assert resp2.status_code == 429 266 | 267 | @app.route("/t2") 268 | @limiter.exempt 269 | def t2(request: Request): 270 | """Exempt a sync route""" 271 | return PlainTextResponse("test") 272 | 273 | with TestClient(app) as cli: 274 | resp = cli.get("/t2", headers={"X_FORWARDED_FOR": "127.0.0.10"}) 275 | assert resp.status_code == 200 276 | resp2 = cli.get("/t2", headers={"X_FORWARDED_FOR": "127.0.0.10"}) 277 | assert resp2.status_code == 200 278 | 279 | @app.route("/t3") 280 | @limiter.exempt 281 | async def t3(request: Request): 282 | """Exempt an async route""" 283 | return PlainTextResponse("test") 284 | 285 | with TestClient(app) as cli: 286 | resp = cli.get("/t3", headers={"X_FORWARDED_FOR": "127.0.0.10"}) 287 | assert resp.status_code == 200 288 | resp2 = cli.get("/t3", headers={"X_FORWARDED_FOR": "127.0.0.10"}) 289 | assert resp2.status_code == 200 290 | 291 | # todo: more tests - see https://github.com/alisaifee/flask-limiter/blob/55df08f14143a7e918fc033067a494248ab6b0c5/tests/test_decorators.py#L187 292 | def test_default_and_decorator_limit_merging(self, build_starlette_app): 293 | app, limiter = build_starlette_app( 294 | key_func=lambda: "test", default_limits=["10/minute"] 295 | ) 296 | 297 | @limiter.limit("5 per minute", key_func=get_ipaddr, override_defaults=False) 298 | async def t1(request: Request): 299 | return PlainTextResponse("test") 300 | 301 | app.add_route("/t1", t1) 302 | 303 | with hiro.Timeline().freeze() as timeline: 304 | cli = TestClient(app) 305 | for i in range(0, 10): 306 | response = cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.2"}) 307 | assert response.status_code == 200 if i < 5 else 429 308 | for i in range(5): 309 | assert cli.get("/t1").status_code == 200 310 | 311 | assert cli.get("/t1").status_code == 429 312 | assert ( 313 | cli.get("/t1", headers={"X_FORWARDED_FOR": "127.0.0.3"}).status_code 314 | == 429 315 | ) 316 | 317 | def test_cost(self, build_starlette_app): 318 | app, limiter = build_starlette_app(key_func=get_ipaddr) 319 | 320 | @limiter.limit("50/minute", cost=10) 321 | async def t1(request: Request): 322 | return PlainTextResponse("test") 323 | 324 | app.add_route("/t1", t1) 325 | 326 | @limiter.limit("50/minute", cost=15) 327 | async def t2(request: Request): 328 | return PlainTextResponse("test") 329 | 330 | app.add_route("/t2", t2) 331 | 332 | client = TestClient(app) 333 | for i in range(0, 10): 334 | response = client.get("/t1") 335 | assert response.status_code == 200 if i < 5 else 429 336 | if i < 5: 337 | assert response.text == "test" 338 | else: 339 | assert "error" in response.json() 340 | 341 | response = client.get("/t2") 342 | assert response.status_code == 200 if i < 3 else 429 343 | if i < 3: 344 | assert response.text == "test" 345 | else: 346 | assert "error" in response.json() 347 | 348 | def test_callable_cost(self, build_starlette_app): 349 | app, limiter = build_starlette_app(key_func=get_ipaddr) 350 | 351 | @limiter.limit("50/minute", cost=lambda request: int(request.headers["foo"])) 352 | async def t1(request: Request): 353 | return PlainTextResponse("test") 354 | 355 | app.add_route("/t1", t1) 356 | 357 | @limiter.limit( 358 | "50/minute", cost=lambda request: int(request.headers["foo"]) * 1.5 359 | ) 360 | async def t2(request: Request): 361 | return PlainTextResponse("test") 362 | 363 | app.add_route("/t2", t2) 364 | 365 | client = TestClient(app) 366 | for i in range(0, 10): 367 | response = client.get("/t1", headers={"foo": "10"}) 368 | assert response.status_code == 200 if i < 5 else 429 369 | if i < 5: 370 | assert response.text == "test" 371 | else: 372 | assert "error" in response.json() 373 | 374 | response = client.get("/t2", headers={"foo": "5"}) 375 | assert response.status_code == 200 if i < 6 else 429 376 | if i < 6: 377 | assert response.text == "test" 378 | else: 379 | assert "error" in response.json() 380 | 381 | @pytest.mark.parametrize( 382 | "key_style", 383 | ["url", "endpoint"], 384 | ) 385 | def test_key_style(self, build_starlette_app, key_style): 386 | app, limiter = build_starlette_app(key_func=lambda: "mock", key_style=key_style) 387 | 388 | @limiter.limit("1/minute") 389 | async def t1_func(request: Request): 390 | return PlainTextResponse("test") 391 | 392 | app.add_route("/t1/{my_param}", t1_func) 393 | 394 | client = TestClient(app) 395 | client.get("/t1/param_one") 396 | second_call = client.get("/t1/param_two") 397 | # with the "url" key_style, since the `my_param` value changed, the storage key is different 398 | # meaning it should not raise any RateLimitExceeded error. 399 | if key_style == "url": 400 | assert second_call.status_code == 200 401 | assert limiter._storage.get("LIMITER/mock//t1/param_one/1/1/minute") == 1 402 | assert limiter._storage.get("LIMITER/mock//t1/param_two/1/1/minute") == 1 403 | # However, with the `endpoint` key_style, it will use the function name (e.g: "t1_func") 404 | # meaning it will raise a RateLimitExceeded error, because no matter the parameter value 405 | # it will share the limitations. 406 | elif key_style == "endpoint": 407 | assert second_call.status_code == 429 408 | # check that we counted 2 requests, even though we had a different value for "my_param" 409 | assert ( 410 | limiter._storage.get( 411 | "LIMITER/mock/tests.test_starlette_extension.t1_func/1/1/minute" 412 | ) 413 | == 2 414 | ) 415 | --------------------------------------------------------------------------------