├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── idempotency_header_middleware ├── __init__.py ├── backends │ ├── __init__.py │ ├── base.py │ ├── memory.py │ └── redis.py ├── middleware.py └── py.typed ├── poetry.lock ├── pyproject.toml ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── static └── image.jpeg ├── test_backends.py └── test_middleware.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: daily 8 | reviewers: 9 | - sondrelg 10 | 11 | - package-ecosystem: pip 12 | directory: / 13 | versioning-strategy: lockfile-only 14 | open-pull-requests-limit: 4 15 | target-branch: master 16 | schedule: 17 | interval: daily 18 | reviewers: 19 | - sondrelg 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish package 2 | 3 | on: 4 | release: 5 | types: [published, edited] 6 | 7 | jobs: 8 | build-and-publish-test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: snok/.github/workflows/publish@main 12 | with: 13 | overwrite-repository: true 14 | repository-url: https://test.pypi.org/legacy/ 15 | token: ${{ secrets.TEST_PYPI_TOKEN }} 16 | build-and-publish: 17 | needs: build-and-publish-test 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: snok/.github/workflows/publish@main 21 | with: 22 | token: ${{ secrets.PYPI_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | linting: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.10" 17 | - uses: actions/cache@v3 18 | id: cache-venv 19 | with: 20 | path: .venv 21 | key: venv-0 22 | - run: | 23 | python -m venv .venv --upgrade-deps 24 | source .venv/bin/activate 25 | pip install pre-commit 26 | if: steps.cache-venv.outputs.cache-hit != 'true' 27 | - uses: actions/cache@v3 28 | id: pre-commit-cache 29 | with: 30 | path: ~/.cache/pre-commit 31 | key: key-0 32 | - run: | 33 | source .venv/bin/activate 34 | pre-commit run --all-files 35 | 36 | test: 37 | runs-on: ubuntu-latest 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | python-version: [ "3.8.13", "3.9.13", "3.10.6", "3.11.0-rc.1" ] 42 | steps: 43 | - uses: actions/checkout@v3 44 | - uses: actions/setup-python@v5 45 | with: 46 | python-version: "${{ matrix.python-version }}" 47 | - uses: actions/cache@v3 48 | id: poetry-cache 49 | with: 50 | path: ~/.local 51 | key: key-1 52 | - uses: snok/install-poetry@v1 53 | with: 54 | virtualenvs-create: false 55 | version: latest 56 | - uses: actions/cache@v3 57 | id: cache-venv 58 | with: 59 | path: .venv 60 | key: ${{ hashFiles('**/poetry.lock') }}-0 61 | - run: | 62 | python -m venv .venv 63 | source .venv/bin/activate 64 | pip install -U pip 65 | poetry install --no-interaction --no-root --extras all 66 | if: steps.cache-venv.outputs.cache-hit != 'true' 67 | - run: source .venv/bin/activate && pip install coverage[toml] 68 | if: matrix.python-version == '3.10' 69 | - name: Run tests 70 | run: | 71 | source .venv/bin/activate 72 | pytest --cov=asgi_correlation_id tests/ --cov-report=xml 73 | coverage report 74 | - uses: codecov/codecov-action@v4 75 | with: 76 | file: ./coverage.xml 77 | fail_ci_if_error: true 78 | if: matrix.python-version == '3.10' 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # Pycharm 141 | .idea 142 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 22.12.0 4 | hooks: 5 | - id: black 6 | 7 | - repo: https://github.com/pycqa/isort 8 | rev: 5.11.4 9 | hooks: 10 | - id: isort 11 | 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v4.4.0 14 | hooks: 15 | - id: check-ast 16 | - id: check-merge-conflict 17 | - id: check-case-conflict 18 | - id: check-docstring-first 19 | - id: check-json 20 | - id: check-yaml 21 | - id: end-of-file-fixer 22 | - id: trailing-whitespace 23 | - id: mixed-line-ending 24 | - id: trailing-whitespace 25 | - id: double-quote-string-fixer 26 | 27 | - repo: https://github.com/pycqa/flake8 28 | rev: 6.0.0 29 | hooks: 30 | - id: flake8 31 | additional_dependencies: [ 32 | 'flake8-bugbear', 33 | 'flake8-comprehensions', 34 | 'flake8-print', 35 | 'flake8-mutable', 36 | 'flake8-simplify', 37 | 'flake8-pytest-style', 38 | 'flake8-printf-formatting', 39 | ] 40 | 41 | - repo: https://github.com/sirosen/check-jsonschema 42 | rev: 0.19.2 43 | hooks: 44 | - id: check-github-actions 45 | - id: check-github-workflows 46 | 47 | - repo: https://github.com/asottile/pyupgrade 48 | rev: v3.3.1 49 | hooks: 50 | - id: pyupgrade 51 | args: [ "--py36-plus", "--py37-plus" ] 52 | 53 | - repo: https://github.com/pre-commit/mirrors-mypy 54 | rev: v0.991 55 | hooks: 56 | - id: mypy 57 | additional_dependencies: 58 | - types-redis 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 4-Clause License 2 | 3 | Copyright (c) 2021, Sondre Lillebø Gundersen 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. All advertising materials mentioning features or use of this software must 17 | display the following acknowledgement: 18 | This product includes software developed by [project]. 19 | 20 | 4. Neither the name of the copyright holder nor the names of its 21 | contributors may be used to endorse or promote products derived from 22 | this software without specific prior written permission. 23 | 24 | THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDER "AS IS" AND ANY EXPRESS OR 25 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 26 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO 27 | EVENT SHALL COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 28 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 29 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 30 | OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 31 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 32 | OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 33 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![tests](https://github.com/sondrelg/asgi-idempotency-header/actions/workflows/test.yml/badge.svg)](https://github.com/sondrelg/asgi-idempotency-header/actions/workflows/test.yml) 2 | [![pypi](https://img.shields.io/pypi/v/asgi-idempotency-header.svg)](https://pypi.org/project/drf-openapi-tester/) 3 | [![python-versions](https://img.shields.io/badge/python-3.8%2B-blue)](https://pypi.org/project/asgi-idempotency-header) 4 | [![codecov](https://codecov.io/gh/sondrelg/asgi-idempotency-header/branch/main/graph/badge.svg?token=UOJTCSY8H7)](https://codecov.io/gh/sondrelg/asgi-idempotency-header) 5 | 6 | # Idempotency Header ASGI Middleware 7 | 8 | A middleware for making endpoints idempotent. 9 | 10 | The purpose of the middleware is to guarantee that execution of mutating endpoints happens exactly once, 11 | regardless of the number of requests. 12 | We achieve this by caching responses, and returning already-saved responses to the user on repeated requests. 13 | Responses are only cached when an idempotency-key HTTP header is present, so clients must opt-into this behaviour. 14 | Additionally, only configured HTTP methods (by default, `POST` and `PATCH`) that return JSON payloads are cached and replayed. 15 | 16 | This is largely modelled after [stripe' implementation](https://stripe.com/docs/api/idempotent_requests). 17 | 18 | The middleware is compatible with both [Starlette](https://github.com/encode/starlette) 19 | and [FastAPI](https://github.com/tiangolo/fastapi) apps. 20 | 21 | ## Installation 22 | 23 | ``` 24 | pip install asgi-idempotency-header 25 | ``` 26 | 27 | ## Setup 28 | 29 | Add the middleware to your app like this: 30 | 31 | ```python 32 | from fastapi import FastAPI 33 | 34 | from idempotency_header_middleware import IdempotencyHeaderMiddleware 35 | from idempotency_header_middleware.backends import RedisBackend 36 | 37 | 38 | backend = RedisBackend(redis=redis) 39 | 40 | app = FastAPI() 41 | app.add_middleware(IdempotencyHeaderMiddleware(backend=backend)) 42 | ``` 43 | 44 | or like this: 45 | 46 | ```python 47 | from fastapi import FastAPI 48 | from fastapi.middleware import Middleware 49 | 50 | from idempotency_header_middleware import IdempotencyHeaderMiddleware 51 | from idempotency_header_middleware.backends import RedisBackend 52 | 53 | 54 | backend = RedisBackend(redis=redis) 55 | 56 | app = FastAPI( 57 | middleware=[ 58 | Middleware( 59 | IdempotencyHeaderMiddleware, 60 | backend=backend, 61 | ) 62 | ] 63 | ) 64 | ``` 65 | 66 | If you're using `Starlette`, just substitute `FastAPI` for `Starlette` and it should work the same. 67 | 68 | ## Configuration 69 | 70 | The middleware takes a few arguments. A full example looks like this: 71 | 72 | ```python 73 | from aioredis import from_url 74 | 75 | from idempotency_header_middleware import IdempotencyHeaderMiddleware 76 | from idempotency_header_middleware.backends import RedisBackend 77 | 78 | 79 | redis = from_url(redis_url) 80 | backend = RedisBackend(redis=redis) 81 | 82 | IdempotencyHeaderMiddleware( 83 | backend, 84 | idempotency_header_key='Idempotency-Key', 85 | replay_header_key='Idempotent-Replayed', 86 | enforce_uuid4_formatting=False, 87 | expiry=60 * 60 * 24, 88 | applicable_methods=['POST', 'PATCH'] 89 | ) 90 | ``` 91 | 92 | The following section describes each argument: 93 | 94 | ### Backend 95 | 96 | ```python 97 | from idempotency_header_middleware.backends import RedisBackend, MemoryBackend 98 | 99 | backend: Union[RedisBackend, MemoryBackend] 100 | ``` 101 | 102 | The backend is the only required argument, as it defines **how** and **where** to store a response. 103 | 104 | The package comes with an [aioredis](https://github.com/aio-libs/aioredis-py) backend implementation, and a 105 | memory-backend for testing. 106 | 107 | Contributions for more backends are welcomed, and configuring a custom backend is pretty simple - just take a look at 108 | the existing ones. 109 | 110 | ### Idempotency header key 111 | 112 | ```python 113 | idempotency_header_key: str = 'Idempotency-Key' 114 | ``` 115 | 116 | The idempotency header key is the header value to check for. When present, the middleware will be used if the HTTP 117 | method is in the [applicable methods](#applicable-methods). 118 | 119 | The default value is `"Idempotency-Key"`, but it can be defined as any string. 120 | 121 | ### Replay header key 122 | 123 | ```python 124 | replay_header_key: str = 'Idempotent-Replayed' 125 | ``` 126 | 127 | The replay header is added to replayed responses. It provides a way for the client to tell whether the action was 128 | performed for the first time or not. 129 | 130 | ### Enforce UUID formatting 131 | 132 | ```python 133 | enforce_uuid4_formatting: bool = False 134 | ``` 135 | 136 | Convenience option for stricter header value validation. 137 | 138 | Clients can technically set any value they want in their header, 139 | but the shorter the key value is, the higher the risk of value-collisions is from other users. 140 | If two users accidentally send in the same header value for what's meant to be two separate requests, 141 | the middleware will interpret them as the same. 142 | 143 | By enabling this option, you can force users to use UUIDs as header values, and pretty much eliminate this risk. 144 | 145 | When validation fails, a 422 response is returned from the middleware, informing the user that the header value is malformed. 146 | 147 | ### Expiry 148 | 149 | ```python 150 | expiry: int = 60 * 60 * 24 151 | ``` 152 | 153 | How long to cache responses for, measured in seconds. Set to 24 hours by default. 154 | 155 | ### Applicable Methods 156 | 157 | ```python 158 | applicable_methods=['POST', 'PATCH'] 159 | ``` 160 | 161 | What HTTP methods to consider for idempotency. If the request method is one of the methods in this list, and the 162 | [idempotency header](#idempotency-header-key) is sent, the middleware will be used. By default, only `POST` 163 | and `PATCH` methods are cached and replayed. 164 | 165 | ## Quick summary of behaviours 166 | 167 | Briefly summarized, this is how the middleware functions: 168 | 169 | - The first request is processed, and consequent requests are replayed, until the response expires. 170 | `expiry` *can* be set to `None` to skip expiry, but most likely you will want to expire responses 171 | after a while. 172 | - If two requests comes in at the same time - i.e., if a second request hits the middlware *before* 173 | the first request has finished, the middleware will return a 409, informing the user that a request 174 | is being processed, and that we cannot handle the second request. 175 | - The middleware only handles HTTP requests. 176 | - By default, the middleware only handles requests with `POST` and `PATCH` methods. Other HTTP methods skip this middleware. 177 | - Only valid JSON responses with `content-type` == `application/json` are cached. 178 | -------------------------------------------------------------------------------- /idempotency_header_middleware/__init__.py: -------------------------------------------------------------------------------- 1 | from idempotency_header_middleware.middleware import IdempotencyHeaderMiddleware 2 | 3 | __all__ = ('IdempotencyHeaderMiddleware',) 4 | -------------------------------------------------------------------------------- /idempotency_header_middleware/backends/__init__.py: -------------------------------------------------------------------------------- 1 | from idempotency_header_middleware.backends.memory import MemoryBackend 2 | from idempotency_header_middleware.backends.redis import RedisBackend 3 | 4 | __all__ = ( 5 | 'RedisBackend', 6 | 'MemoryBackend', 7 | ) 8 | -------------------------------------------------------------------------------- /idempotency_header_middleware/backends/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional 3 | 4 | from starlette.responses import Response 5 | 6 | 7 | class Backend(ABC): 8 | expiry: Optional[int] = 60 * 60 * 24 9 | 10 | @abstractmethod 11 | async def get_stored_response(self, idempotency_key: str) -> Optional[Response]: 12 | """ 13 | Return a stored response if it exists, otherwise return None. 14 | """ 15 | ... 16 | 17 | @abstractmethod 18 | async def store_response_data(self, idempotency_key: str, payload: dict, status_code: int) -> None: 19 | """ 20 | Store a response to an appropriate backend (redis, postgres, etc.). 21 | """ 22 | ... 23 | 24 | @abstractmethod 25 | async def store_idempotency_key(self, idempotency_key: str) -> bool: 26 | """ 27 | Store an idempotency key header value in a set, if it doesn't already exist. 28 | 29 | Returns False if we wrote to the backend, True if the key already existed. 30 | 31 | The primary purpose of this method is to make sure we reject repeated requests 32 | (with a 409) when a request has been initiated but is not yet completed. 33 | 34 | All implementations of this most likely will want to implement some locking 35 | mechanism to prevent race conditions and double execution. 36 | """ 37 | ... 38 | 39 | @abstractmethod 40 | async def clear_idempotency_key(self, idempotency_key: str) -> None: 41 | """ 42 | Remove an idempotency header value from the backend. 43 | 44 | Once a request has been completed, we should pop the idempotency 45 | key stored in 'store_idempotency_key'. 46 | """ 47 | ... 48 | -------------------------------------------------------------------------------- /idempotency_header_middleware/backends/memory.py: -------------------------------------------------------------------------------- 1 | import time 2 | from dataclasses import dataclass, field 3 | from typing import Any, Dict, Optional, Set 4 | 5 | from starlette.responses import JSONResponse 6 | 7 | from idempotency_header_middleware.backends.base import Backend 8 | 9 | 10 | @dataclass() 11 | class MemoryBackend(Backend): 12 | """ 13 | In-memory backend. 14 | 15 | This backend should probably not be used in deployed environments where 16 | applications are hosted on several nodes, since memory is not shared state 17 | and the response caching then won't work as intended. 18 | 19 | The backend is mainly here for local development or testing. 20 | """ 21 | 22 | expiry: Optional[int] = 60 * 60 * 24 23 | 24 | response_store: Dict[str, Dict[str, Any]] = field(default_factory=dict) 25 | keys: Set[str] = field(default_factory=set) 26 | 27 | async def get_stored_response(self, idempotency_key: str) -> Optional[JSONResponse]: 28 | """ 29 | Return a stored response if it exists, otherwise return None. 30 | """ 31 | if idempotency_key not in self.response_store: 32 | return None 33 | 34 | if (expiry := self.response_store[idempotency_key]['expiry']) and expiry <= time.time(): 35 | del self.response_store[idempotency_key] 36 | return None 37 | 38 | return JSONResponse( 39 | self.response_store[idempotency_key]['json'], 40 | status_code=self.response_store[idempotency_key]['status_code'], 41 | ) 42 | 43 | async def store_response_data(self, idempotency_key: str, payload: dict, status_code: int) -> None: 44 | """ 45 | Store a response in memory. 46 | """ 47 | self.response_store[idempotency_key] = { 48 | 'expiry': time.time() + self.expiry if self.expiry else None, 49 | 'json': payload, 50 | 'status_code': status_code, 51 | } 52 | 53 | async def store_idempotency_key(self, idempotency_key: str) -> bool: 54 | """ 55 | Store an idempotency key header value in a set. 56 | """ 57 | if idempotency_key in self.keys: 58 | return True 59 | 60 | self.keys.add(idempotency_key) 61 | return False 62 | 63 | async def clear_idempotency_key(self, idempotency_key: str) -> None: 64 | """ 65 | Remove an idempotency header value from the set. 66 | """ 67 | self.keys.remove(idempotency_key) 68 | -------------------------------------------------------------------------------- /idempotency_header_middleware/backends/redis.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass 3 | from typing import Optional, Tuple 4 | 5 | from fastapi.responses import JSONResponse 6 | from redis.asyncio import Redis 7 | 8 | from idempotency_header_middleware.backends.base import Backend 9 | 10 | 11 | @dataclass() 12 | class RedisBackend(Backend): 13 | def __init__( 14 | self, 15 | redis: Redis, 16 | keys_key: str = 'idempotency-key-keys', 17 | response_key: str = 'idempotency-key-responses', 18 | expiry: int = 60 * 60 * 24, 19 | ): 20 | self.redis = redis 21 | self.KEYS_KEY = keys_key 22 | self.RESPONSE_KEY = response_key 23 | self.expiry = expiry 24 | 25 | def _get_keys(self, idempotency_key: str) -> Tuple[str, str]: 26 | payload_key = self.RESPONSE_KEY + idempotency_key 27 | status_code_key = self.RESPONSE_KEY + idempotency_key + 'status-code' 28 | return payload_key, status_code_key 29 | 30 | async def get_stored_response(self, idempotency_key: str) -> Optional[JSONResponse]: 31 | """ 32 | Return a stored response if it exists, otherwise return None. 33 | """ 34 | payload_key, status_code_key = self._get_keys(idempotency_key) 35 | 36 | if not (payload := await self.redis.get(payload_key)): 37 | return None 38 | else: 39 | status_code = await self.redis.get(status_code_key) 40 | 41 | return JSONResponse(json.loads(payload), status_code=int(status_code)) # type: ignore[arg-type] 42 | 43 | async def store_response_data(self, idempotency_key: str, payload: dict, status_code: int) -> None: 44 | """ 45 | Store a response in redis. 46 | """ 47 | payload_key, status_code_key = self._get_keys(idempotency_key) 48 | 49 | await self.redis.set(payload_key, json.dumps(payload)) 50 | await self.redis.set(status_code_key, status_code) 51 | 52 | if self.expiry: 53 | await self.redis.expire(payload_key, self.expiry) 54 | await self.redis.expire(status_code_key, self.expiry) 55 | 56 | async def store_idempotency_key(self, idempotency_key: str) -> bool: 57 | """ 58 | Store an idempotency key header value in a set. 59 | """ 60 | return not bool(await self.redis.sadd(self.KEYS_KEY, idempotency_key)) 61 | 62 | async def clear_idempotency_key(self, idempotency_key: str) -> None: 63 | """ 64 | Remove an idempotency header value from the set. 65 | """ 66 | await self.redis.srem(self.KEYS_KEY, idempotency_key) 67 | -------------------------------------------------------------------------------- /idempotency_header_middleware/middleware.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from collections import namedtuple 4 | from dataclasses import dataclass, field 5 | from json import JSONDecodeError 6 | from typing import Any, List, Union 7 | from uuid import UUID 8 | 9 | from starlette.datastructures import Headers 10 | from starlette.responses import JSONResponse 11 | from starlette.types import ASGIApp, Message, Receive, Scope, Send 12 | 13 | from idempotency_header_middleware.backends.base import Backend 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def is_valid_uuid(uuid_: str) -> bool: 19 | """ 20 | Check whether a string is a valid v4 uuid. 21 | """ 22 | try: 23 | return bool(UUID(uuid_, version=4)) 24 | except ValueError: 25 | return False 26 | 27 | 28 | @dataclass 29 | class IdempotencyHeaderMiddleware: 30 | app: ASGIApp 31 | backend: Backend 32 | idempotency_header_key: str = 'Idempotency-Key' 33 | replay_header_key: str = 'Idempotent-Replayed' 34 | enforce_uuid4_formatting: bool = False 35 | applicable_methods: List[str] = field(default_factory=lambda: ['POST', 'PATCH']) 36 | 37 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> Union[JSONResponse, Any]: 38 | """ 39 | Enable idempotent operations in POST and PATCH endpoints. 40 | """ 41 | if scope['type'] != 'http' or scope['method'] not in self.applicable_methods: 42 | return await self.app(scope, receive, send) 43 | 44 | if not (idempotency_key := Headers(scope=scope).get(self.idempotency_header_key.lower())): 45 | return await self.app(scope, receive, send) 46 | 47 | if self.enforce_uuid4_formatting and not is_valid_uuid(idempotency_key): 48 | payload = {'detail': f"'{self.idempotency_header_key}' header value must be formatted as a v4 UUID"} 49 | response = JSONResponse(payload, 422) 50 | return await response(scope, receive, send) 51 | 52 | if stored_response := await self.backend.get_stored_response(idempotency_key): 53 | stored_response.headers[self.replay_header_key] = 'true' 54 | return await stored_response(scope, receive, send) 55 | 56 | # Check if request is already pending 57 | if await self.backend.store_idempotency_key(idempotency_key): 58 | payload = {'detail': f"Request already pending for idempotency key '{idempotency_key}'"} 59 | response = JSONResponse(payload, 409) 60 | return await response(scope, receive, send) 61 | 62 | # Spin up a request-specific class instance, so we can read and write to it in the `send_wrapper` below 63 | response_state = namedtuple('response_state', ['status_code', 'response_headers']) 64 | 65 | async def send_wrapper(message: Message) -> None: 66 | if message['type'] == 'http.response.start': 67 | response_state.status_code = message['status'] 68 | response_state.response_headers = Headers(scope=message) 69 | 70 | elif message['type'] == 'http.response.body': 71 | if ( 72 | 'content-type' in response_state.response_headers 73 | and response_state.response_headers['content-type'] != 'application/json' 74 | ): 75 | await self.backend.clear_idempotency_key(idempotency_key) 76 | await send(message) 77 | return 78 | 79 | try: 80 | json_payload = json.loads(message['body']) 81 | except JSONDecodeError as e: 82 | logger.info('Failed to save JSON response: %s', e) 83 | await self.backend.clear_idempotency_key(idempotency_key) 84 | await send(message) 85 | return 86 | 87 | await self.backend.store_response_data( 88 | idempotency_key=idempotency_key, 89 | payload=json_payload, 90 | status_code=response_state.status_code, 91 | ) 92 | 93 | await send(message) 94 | 95 | await self.app(scope, receive, send_wrapper) 96 | -------------------------------------------------------------------------------- /idempotency_header_middleware/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/asgi-idempotency-header/8f89a5c5c713881aff89cfb44b524c1792b6429d/idempotency_header_middleware/py.typed -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 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 = "main" 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 | 19 | [package.extras] 20 | doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] 21 | 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)"] 22 | trio = ["trio (>=0.16,<0.22)"] 23 | 24 | [[package]] 25 | name = "async-timeout" 26 | version = "4.0.2" 27 | description = "Timeout context manager for asyncio programs" 28 | category = "main" 29 | optional = false 30 | python-versions = ">=3.6" 31 | files = [ 32 | {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, 33 | {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, 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 = "certifi" 57 | version = "2022.12.7" 58 | description = "Python package for providing Mozilla's CA Bundle." 59 | category = "dev" 60 | optional = false 61 | python-versions = ">=3.6" 62 | files = [ 63 | {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, 64 | {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, 65 | ] 66 | 67 | [[package]] 68 | name = "cfgv" 69 | version = "3.3.1" 70 | description = "Validate configuration and produce human readable error messages." 71 | category = "dev" 72 | optional = false 73 | python-versions = ">=3.6.1" 74 | files = [ 75 | {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, 76 | {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, 77 | ] 78 | 79 | [[package]] 80 | name = "colorama" 81 | version = "0.4.6" 82 | description = "Cross-platform colored terminal text." 83 | category = "dev" 84 | optional = false 85 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 86 | files = [ 87 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 88 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 89 | ] 90 | 91 | [[package]] 92 | name = "coverage" 93 | version = "7.0.2" 94 | description = "Code coverage measurement for Python" 95 | category = "dev" 96 | optional = false 97 | python-versions = ">=3.7" 98 | files = [ 99 | {file = "coverage-7.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1f02d016b9b6b5ad21949a21646714bfa7b32d6041a30f97674f05d6d6996e3"}, 100 | {file = "coverage-7.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2d4f68e4fa286fb6b00d58a1e87c79840e289d3f6e5dcb912ad7b0fbd9629fb"}, 101 | {file = "coverage-7.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:426895ac9f2938bec193aa998e7a77a3e65d3e46903f348e794b4192b9a5b61e"}, 102 | {file = "coverage-7.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcb708ab06f3f4dfc99e9f84821c9120e5f12113b90fad132311a2cb97525379"}, 103 | {file = "coverage-7.0.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8217f73faf08623acb25fb2affd5d20cbcd8185213db308e46a37e6fd6a56a49"}, 104 | {file = "coverage-7.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bcb57d175ff0cb4ff97fc547c74c1cb8d4c9612003f6d267ee78dad8f23d8b30"}, 105 | {file = "coverage-7.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7d47d666e17e57ef65fefc87229fde262bd5c9039ae8424bc53aa2d8f07dc178"}, 106 | {file = "coverage-7.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:321316a7b979892a13c148a9d37852b5a76f26717e4b911b606a649394629532"}, 107 | {file = "coverage-7.0.2-cp310-cp310-win32.whl", hash = "sha256:420f10c852b9a489cf5a764534669a19f49732a0576c76d9489ebf287f81af6d"}, 108 | {file = "coverage-7.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a27a8dca0dc6f0944ed9fd83c556d862e227a5cd4220e62af5d4c750389938f0"}, 109 | {file = "coverage-7.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac1033942851bf01f28c76318155ea92d6648aecb924cab81fa23781d095e9ab"}, 110 | {file = "coverage-7.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36c1a1b6d38ebf8a4335f65226ec36b5d6fd67743fdcbad5c52bdcd46c4f5842"}, 111 | {file = "coverage-7.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5e1874c601128cf54c1d4b471e915658a334fbc56d7b3c324ddc7511597ea82"}, 112 | {file = "coverage-7.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e133ca2f8141b415ff396ba789bdeffdea8ff9a5c7fc9996ccf591d7d40ee93"}, 113 | {file = "coverage-7.0.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e97b21482aa5c21e049e4755c95955ad71fb54c9488969e2f17cf30922aa5f6"}, 114 | {file = "coverage-7.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8bd466135fb07f693dbdd999a5569ffbc0590e9c64df859243162f0ebee950c8"}, 115 | {file = "coverage-7.0.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:38f281bb9bdd4269c451fed9451203512dadefd64676f14ed1e82c77eb5644fc"}, 116 | {file = "coverage-7.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7c669be1b01e4b2bf23aa49e987d9bedde0234a7da374a9b77ca5416d7c57002"}, 117 | {file = "coverage-7.0.2-cp311-cp311-win32.whl", hash = "sha256:80583c536e7e010e301002088919d4ea90566d1789ee02551574fdf3faa275ae"}, 118 | {file = "coverage-7.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:a7b018811a0e1d3869d8d0600849953acd355a3a29c6bee0fbd24d7772bcc0a2"}, 119 | {file = "coverage-7.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e8931af864bd599c6af626575a02103ae626f57b34e3af5537d40b040d33d2ad"}, 120 | {file = "coverage-7.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a613d60be1a02c7a5184ea5c4227f48c08e0635608b9c17ae2b17efef8f2501"}, 121 | {file = "coverage-7.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fbb59f84c8549113dcdce7c6d16c5731fe53651d0b46c0a25a5ebc7bb655869"}, 122 | {file = "coverage-7.0.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d40ad86a348c79c614e2b90566267dd6d45f2e6b4d2bfb794d78ea4a4b60b63"}, 123 | {file = "coverage-7.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7e184aa18f921b612ea08666c25dd92a71241c8ed40917f2824219c92289b8c7"}, 124 | {file = "coverage-7.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:fd22ee7bff4b5c37bb6385efee1c501b75e29ca40286f037cb91c2182d1348ce"}, 125 | {file = "coverage-7.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3d72e3d20b03e63bd27b1c4d6b754cd93eca82ecc5dd77b99262d5f64862ca35"}, 126 | {file = "coverage-7.0.2-cp37-cp37m-win32.whl", hash = "sha256:5f44ba7c07e0aa4a7a2723b426c254e952da82a33d65b4a52afae4bef74a4203"}, 127 | {file = "coverage-7.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:efa9d943189321f67f71070c309aa6f6130fa1ec35c9dfd0da0ed238938ce573"}, 128 | {file = "coverage-7.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:da458bdc9b0bcd9b8ca85bc73148631b18cc8ba03c47f29f4c017809990351aa"}, 129 | {file = "coverage-7.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:87d95eea58fb71f69b4f1c761099a19e0e9cb27d45dc1cc7042523128ee56337"}, 130 | {file = "coverage-7.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfafc350f43fd7dc67df18c940c3b7ed208ebb797abe9fb3047f0c65be8e4c0f"}, 131 | {file = "coverage-7.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46db409fc0c3ee5c859b84c7de9cb507166287d588390889fdf06a1afe452e16"}, 132 | {file = "coverage-7.0.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8805673b1953313adfc487d5323b4c87864e77057944a0888c98dd2f7a6052f"}, 133 | {file = "coverage-7.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:62e5b942378d5f0b87caace567a70dc634ddd4d219a236fa221dc97d2fc412c8"}, 134 | {file = "coverage-7.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a99b2f2dd1236e8d9dc35974a3dc298a408cdfd512b0bb2451798cff1ce63408"}, 135 | {file = "coverage-7.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:04691b8e832a900ed15f5bcccc2008fc2d1c8e7411251fd7d1422a84e1d72841"}, 136 | {file = "coverage-7.0.2-cp38-cp38-win32.whl", hash = "sha256:b6936cd38757dd323fefc157823e46436610328f0feb1419a412316f24b77f36"}, 137 | {file = "coverage-7.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:483e120ea324c7fced6126bb9bf0535c71e9233d29cbc7e2fc4560311a5f8a32"}, 138 | {file = "coverage-7.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4438ba539bef21e288092b30ea2fc30e883d9af5b66ebeaf2fd7c25e2f074e39"}, 139 | {file = "coverage-7.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a2d81c95d3b02638ee6ae647edc79769fd29bf5e9e5b6b0c29040579f33c260"}, 140 | {file = "coverage-7.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c40aaf7930680e0e5f3bd6d3d3dc97a7897f53bdce925545c4b241e0c5c3ca6a"}, 141 | {file = "coverage-7.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b31f7f246dbff339b3b76ee81329e3eca5022ce270c831c65e841dbbb40115f"}, 142 | {file = "coverage-7.0.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6e1c77ff6f10eab496fbbcdaa7dfae84968928a0aadc43ce3c3453cec29bd79"}, 143 | {file = "coverage-7.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8e6c0ca447b557a32642f22d0987be37950eda51c4f19fc788cebc99426284b6"}, 144 | {file = "coverage-7.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9de96025ce25b9f4e744fbe558a003e673004af255da9b1f6ec243720ac5deeb"}, 145 | {file = "coverage-7.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:312fd77258bf1044ef4faa82091f2e88216e4b62dcf1a461d3e917144c8b09b7"}, 146 | {file = "coverage-7.0.2-cp39-cp39-win32.whl", hash = "sha256:4d7be755d7544dac2b9814e98366a065d15a16e13847eb1f5473bb714483391e"}, 147 | {file = "coverage-7.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:b6eab230b18458707b5c501548e997e42934b1c189fb4d1b78bf5aacc1c6a137"}, 148 | {file = "coverage-7.0.2-pp37.pp38.pp39-none-any.whl", hash = "sha256:1d732b5dcafed67d81c5b5a0c404c31a61e13148946a3b910a340f72fdd1ec95"}, 149 | {file = "coverage-7.0.2.tar.gz", hash = "sha256:405d8528a0ea07ca516d9007ecad4e1bd10e2eeef27411c6188d78c4e2dfcddc"}, 150 | ] 151 | 152 | [package.dependencies] 153 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 154 | 155 | [package.extras] 156 | toml = ["tomli"] 157 | 158 | [[package]] 159 | name = "distlib" 160 | version = "0.3.6" 161 | description = "Distribution utilities" 162 | category = "dev" 163 | optional = false 164 | python-versions = "*" 165 | files = [ 166 | {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, 167 | {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, 168 | ] 169 | 170 | [[package]] 171 | name = "exceptiongroup" 172 | version = "1.1.0" 173 | description = "Backport of PEP 654 (exception groups)" 174 | category = "dev" 175 | optional = false 176 | python-versions = ">=3.7" 177 | files = [ 178 | {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, 179 | {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, 180 | ] 181 | 182 | [package.extras] 183 | test = ["pytest (>=6)"] 184 | 185 | [[package]] 186 | name = "fakeredis" 187 | version = "2.2.0" 188 | description = "Fake implementation of redis API for testing purposes." 189 | category = "dev" 190 | optional = false 191 | python-versions = ">=3.7,<4.0" 192 | files = [ 193 | {file = "fakeredis-2.2.0-py3-none-any.whl", hash = "sha256:1ac7adf04dcbf8f9886add5972018e0e42781ee772fbaa0a573350cd4e4c66f7"}, 194 | {file = "fakeredis-2.2.0.tar.gz", hash = "sha256:dacdede58b0e682d5dc6176518858b6933c3d529ec18270f162ad5aaf97f5769"}, 195 | ] 196 | 197 | [package.dependencies] 198 | redis = "<4.5" 199 | sortedcontainers = ">=2.4.0,<3.0.0" 200 | 201 | [package.extras] 202 | json = ["jsonpath-ng (>=1.5,<2.0)"] 203 | lua = ["lupa (>=1.14,<2.0)"] 204 | 205 | [[package]] 206 | name = "fastapi" 207 | version = "0.70.1" 208 | description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" 209 | category = "main" 210 | optional = true 211 | python-versions = ">=3.6.1" 212 | files = [ 213 | {file = "fastapi-0.70.1-py3-none-any.whl", hash = "sha256:5367226c7bcd7bfb2e17edaf225fd9a983095b1372281e9a3eb661336fb93748"}, 214 | {file = "fastapi-0.70.1.tar.gz", hash = "sha256:21d03979b5336375c66fa5d1f3126c6beca650d5d2166fbb78345a30d33c8d06"}, 215 | ] 216 | 217 | [package.dependencies] 218 | 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" 219 | starlette = "0.16.0" 220 | 221 | [package.extras] 222 | all = ["email_validator (>=1.1.1,<2.0.0)", "itsdangerous (>=1.1.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "orjson (>=3.2.1,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "pyyaml (>=5.3.1,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "ujson (>=4.0.1,<5.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] 223 | dev = ["autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] 224 | 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 (>=7.1.9,<8.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "typer-cli (>=0.0.12,<0.0.13)"] 225 | test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==21.9b0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "email_validator (>=1.1.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "orjson (>=3.2.1,<4.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "requests (>=2.24.0,<3.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "types-dataclasses (==0.1.7)", "types-orjson (==3.6.0)", "types-ujson (==0.1.1)", "ujson (>=4.0.1,<5.0.0)"] 226 | 227 | [[package]] 228 | name = "filelock" 229 | version = "3.9.0" 230 | description = "A platform independent file lock." 231 | category = "dev" 232 | optional = false 233 | python-versions = ">=3.7" 234 | files = [ 235 | {file = "filelock-3.9.0-py3-none-any.whl", hash = "sha256:f58d535af89bb9ad5cd4df046f741f8553a418c01a7856bf0d173bbc9f6bd16d"}, 236 | {file = "filelock-3.9.0.tar.gz", hash = "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de"}, 237 | ] 238 | 239 | [package.extras] 240 | docs = ["furo (>=2022.12.7)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] 241 | testing = ["covdefaults (>=2.2.2)", "coverage (>=7.0.1)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"] 242 | 243 | [[package]] 244 | name = "h11" 245 | version = "0.14.0" 246 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 247 | category = "dev" 248 | optional = false 249 | python-versions = ">=3.7" 250 | files = [ 251 | {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, 252 | {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, 253 | ] 254 | 255 | [[package]] 256 | name = "httpcore" 257 | version = "0.16.3" 258 | description = "A minimal low-level HTTP client." 259 | category = "dev" 260 | optional = false 261 | python-versions = ">=3.7" 262 | files = [ 263 | {file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"}, 264 | {file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"}, 265 | ] 266 | 267 | [package.dependencies] 268 | anyio = ">=3.0,<5.0" 269 | certifi = "*" 270 | h11 = ">=0.13,<0.15" 271 | sniffio = ">=1.0.0,<2.0.0" 272 | 273 | [package.extras] 274 | http2 = ["h2 (>=3,<5)"] 275 | socks = ["socksio (>=1.0.0,<2.0.0)"] 276 | 277 | [[package]] 278 | name = "httpx" 279 | version = "0.23.2" 280 | description = "The next generation HTTP client." 281 | category = "dev" 282 | optional = false 283 | python-versions = ">=3.7" 284 | files = [ 285 | {file = "httpx-0.23.2-py3-none-any.whl", hash = "sha256:106cded342a44e443060fab70ef327139248c61939e77d73964560c8d8b57069"}, 286 | {file = "httpx-0.23.2.tar.gz", hash = "sha256:e824a6fa18ffaa6423c6f3a32d5096fc15bd8dff43663a223f06242fc69451a8"}, 287 | ] 288 | 289 | [package.dependencies] 290 | certifi = "*" 291 | httpcore = ">=0.15.0,<0.17.0" 292 | rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} 293 | sniffio = "*" 294 | 295 | [package.extras] 296 | brotli = ["brotli", "brotlicffi"] 297 | cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"] 298 | http2 = ["h2 (>=3,<5)"] 299 | socks = ["socksio (>=1.0.0,<2.0.0)"] 300 | 301 | [[package]] 302 | name = "identify" 303 | version = "2.5.12" 304 | description = "File identification library for Python" 305 | category = "dev" 306 | optional = false 307 | python-versions = ">=3.7" 308 | files = [ 309 | {file = "identify-2.5.12-py2.py3-none-any.whl", hash = "sha256:e8a400c3062d980243d27ce10455a52832205649bbcaf27ffddb3dfaaf477bad"}, 310 | {file = "identify-2.5.12.tar.gz", hash = "sha256:0bc96b09c838310b6fcfcc61f78a981ea07f94836ef6ef553da5bb5d4745d662"}, 311 | ] 312 | 313 | [package.extras] 314 | license = ["ukkonen"] 315 | 316 | [[package]] 317 | name = "idna" 318 | version = "3.4" 319 | description = "Internationalized Domain Names in Applications (IDNA)" 320 | category = "main" 321 | optional = false 322 | python-versions = ">=3.5" 323 | files = [ 324 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 325 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 326 | ] 327 | 328 | [[package]] 329 | name = "iniconfig" 330 | version = "1.1.1" 331 | description = "iniconfig: brain-dead simple config-ini parsing" 332 | category = "dev" 333 | optional = false 334 | python-versions = "*" 335 | files = [ 336 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 337 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 338 | ] 339 | 340 | [[package]] 341 | name = "lupa" 342 | version = "1.14.1" 343 | description = "Python wrapper around Lua and LuaJIT" 344 | category = "main" 345 | optional = true 346 | python-versions = "*" 347 | files = [ 348 | {file = "lupa-1.14.1-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:20b486cda76ff141cfb5f28df9c757224c9ed91e78c5242d402d2e9cb699d464"}, 349 | {file = "lupa-1.14.1-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c685143b18c79a3a1fa25a4cc774a87b5a61c606f249bcf824d125d8accb6b2c"}, 350 | {file = "lupa-1.14.1-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:3865f9dbe9a84bd6a471250e52068aaf1147f206a51905fb6d93e1db9efb00ee"}, 351 | {file = "lupa-1.14.1-cp27-cp27m-win32.whl", hash = "sha256:2dacdddd5e28c6f5fd96a46c868ec5c34b0fad1ec7235b5bbb56f06183a37f20"}, 352 | {file = "lupa-1.14.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e754cbc6cacc9bca6ff2b39025e9659a2098420639d214054b06b466825f4470"}, 353 | {file = "lupa-1.14.1-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e36f3eb70705841bce9c15e12bc6fc3b2f4f68a41ba0e4af303b22fc4d8667c"}, 354 | {file = "lupa-1.14.1-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0aac06098d46729edd2d04e80b55d9d310e902f042f27521308df77cb1ba0191"}, 355 | {file = "lupa-1.14.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:9706a192339efa1a6b7d806389572a669dd9ae2250469ff1ce13f684085af0b4"}, 356 | {file = "lupa-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d688a35f7fe614720ed7b820cbb739b37eff577a764c2003e229c2a752201cea"}, 357 | {file = "lupa-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:36d888bd42589ecad21a5fb957b46bc799640d18eff2fd0c47a79ffb4a1b286c"}, 358 | {file = "lupa-1.14.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:0423acd739cf25dbdbf1e33a0aa8026f35e1edea0573db63d156f14a082d77c8"}, 359 | {file = "lupa-1.14.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7068ae0d6a1a35ea8718ef6e103955c1ee143181bf0684604a76acc67f69de55"}, 360 | {file = "lupa-1.14.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5fef8b755591f0466438ad0a3e92ecb21dd6bb1f05d0215139b6ff8c87b2ce65"}, 361 | {file = "lupa-1.14.1-cp310-cp310-win32.whl", hash = "sha256:4a44e1fd0e9f4a546fbddd2e0fd913c823c9ac58a5f3160fb4f9109f633cb027"}, 362 | {file = "lupa-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:b83100cd7b48a7ca85dda4e9a6a5e7bc3312691e7f94c6a78d1f9a48a86a7fec"}, 363 | {file = "lupa-1.14.1-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:1b8bda50c61c98ff9bb41d1f4934640c323e9f1539021810016a2eae25a66c3d"}, 364 | {file = "lupa-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa1449aa1ab46c557344867496dee324b47ede0c41643df8f392b00262d21b12"}, 365 | {file = "lupa-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a17ebf91b3aa1c5c36661e34c9cf10e04bb4cc00076e8b966f86749647162050"}, 366 | {file = "lupa-1.14.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:b1d9cfa469e7a2ad7e9a00fea7196b0022aa52f43a2043c2e0be92122e7bcfe8"}, 367 | {file = "lupa-1.14.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bc4f5e84aee0d567aa2e116ff6844d06086ef7404d5102807e59af5ce9daf3c0"}, 368 | {file = "lupa-1.14.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:40cf2eb90087dfe8ee002740469f2c4c5230d5e7d10ffb676602066d2f9b1ac9"}, 369 | {file = "lupa-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:63a27c38295aa971730795941270fff2ce65576f68ec63cb3ecb90d7a4526d03"}, 370 | {file = "lupa-1.14.1-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:457330e7a5456c4415fc6d38822036bd4cff214f9d8f7906200f6b588f1b2932"}, 371 | {file = "lupa-1.14.1-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d61fb507a36e18dc68f2d9e9e2ea19e1114b1a5e578a36f18e9be7a17d2931d1"}, 372 | {file = "lupa-1.14.1-cp35-cp35m-win32.whl", hash = "sha256:f26b73d10130ad73e07d45dfe9b7c3833e3a2aa1871a4ecf5ce2dc1abeeae74d"}, 373 | {file = "lupa-1.14.1-cp35-cp35m-win_amd64.whl", hash = "sha256:297d801ba8e4e882b295c25d92f1634dde5e76d07ec6c35b13882401248c485d"}, 374 | {file = "lupa-1.14.1-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:c8bddd22eaeea0ce9d302b390d8bc606f003bf6c51be68e8b007504433b91280"}, 375 | {file = "lupa-1.14.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1661c890861cf0f7002d7a7e00f50c885577954c2d85a7173b218d3228fa3869"}, 376 | {file = "lupa-1.14.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2ee480d31555f00f8bf97dd949c596508bd60264cff1921a3797a03dd369e8cd"}, 377 | {file = "lupa-1.14.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:1ff93560c2546d7627ab2f95b5e88f000705db70a3d6041ac29d050f094f2a35"}, 378 | {file = "lupa-1.14.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:47f1459e2c98480c291ae3b70688d762f82dbb197ef121d529aa2c4e8bab1ba3"}, 379 | {file = "lupa-1.14.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8986dba002346505ee44c78303339c97a346b883015d5cf3aaa0d76d3b952744"}, 380 | {file = "lupa-1.14.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8912459fddf691e70f2add799a128822bae725826cfb86f69720a38bdfa42410"}, 381 | {file = "lupa-1.14.1-cp36-cp36m-win32.whl", hash = "sha256:9b9d1b98391959ae531bbb8df7559ac2c408fcbd33721921b6a05fd6414161e0"}, 382 | {file = "lupa-1.14.1-cp36-cp36m-win_amd64.whl", hash = "sha256:61ff409040fa3a6c358b7274c10e556ba22afeb3470f8d23cd0a6bf418fb30c9"}, 383 | {file = "lupa-1.14.1-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:350ba2218eea800898854b02753dc0c9cfe83db315b30c0dc10ab17493f0321a"}, 384 | {file = "lupa-1.14.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:46dcbc0eae63899468686bb1dfc2fe4ed21fe06f69416113f039d88aab18f5dc"}, 385 | {file = "lupa-1.14.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:7ad96923e2092d8edbf0c1b274f9b522690b932ed47a70d9a0c1c329f169f107"}, 386 | {file = "lupa-1.14.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:364b291bf2b55555c87b4bffb4db5a9619bcdb3c02e58aebde5319c3c59ec9b2"}, 387 | {file = "lupa-1.14.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0ed071efc8ee231fac1fcd6b6fce44dc6da75a352b9b78403af89a48d759743c"}, 388 | {file = "lupa-1.14.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:bce60847bebb4aa9ed3436fab3e84585e9094e15e1cb8d32e16e041c4ef65331"}, 389 | {file = "lupa-1.14.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5fbe7f83b0007cda3b158a93726c80dfd39003a8c5c5d608f6fdf8c60c42117f"}, 390 | {file = "lupa-1.14.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4bd789967cbb5c84470f358c7fa8fcbf7464185adbd872a6c3de9b42d29a6d26"}, 391 | {file = "lupa-1.14.1-cp37-cp37m-win32.whl", hash = "sha256:ca58da94a6495dda0063ba975fe2e6f722c5e84c94f09955671b279c41cfde96"}, 392 | {file = "lupa-1.14.1-cp37-cp37m-win_amd64.whl", hash = "sha256:51d6965663b2be1a593beabfa10803fdbbcf0b293aa4a53ea09a23db89787d0d"}, 393 | {file = "lupa-1.14.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d251ba009996a47231615ea6b78123c88446979ae99b5585269ec46f7a9197aa"}, 394 | {file = "lupa-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:abe3fc103d7bd34e7028d06db557304979f13ebf9050ad0ea6c1cc3a1caea017"}, 395 | {file = "lupa-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:4ea185c394bf7d07e9643d868e50cc94a530bb298d4bdae4915672b3809cc72b"}, 396 | {file = "lupa-1.14.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:6aff7257b5953de620db489899406cddb22093d1124fc5b31f8900e44a9dbc2a"}, 397 | {file = "lupa-1.14.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d6f5bfbd8fc48c27786aef8f30c84fd9197747fa0b53761e69eb968d81156cbf"}, 398 | {file = "lupa-1.14.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:dec7580b86975bc5bdf4cc54638c93daaec10143b4acc4a6c674c0f7e27dd363"}, 399 | {file = "lupa-1.14.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:96a201537930813b34145daf337dcd934ddfaebeba6452caf8a32a418e145e82"}, 400 | {file = "lupa-1.14.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c0efaae8e7276f4feb82cba43c3cd45c82db820c9dab3965a8f2e0cb8b0bc30b"}, 401 | {file = "lupa-1.14.1-cp38-cp38-win32.whl", hash = "sha256:b6953854a343abdfe11aa52a2d021fadf3d77d0cd2b288b650f149b597e0d02d"}, 402 | {file = "lupa-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:c79ced2aaf7577e3d06933cf0d323fa968e6864c498c376b0bd475ded86f01f3"}, 403 | {file = "lupa-1.14.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:72589a21a3776c7dd4b05374780e7ecf1b49c490056077fc91486461935eaaa3"}, 404 | {file = "lupa-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:30d356a433653b53f1fe29477faaf5e547b61953b971b010d2185a561f4ce82a"}, 405 | {file = "lupa-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2116eb467797d5a134b2c997dfc7974b9a84b3aa5776c17ba8578ed4f5f41a9b"}, 406 | {file = "lupa-1.14.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:24d6c3435d38614083d197f3e7bcfe6d3d9eb02ee393d60a4ab9c719bc000162"}, 407 | {file = "lupa-1.14.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9144ecfa5e363f03e4d1c1e678b081cd223438be08f96604fca478591c3e3b53"}, 408 | {file = "lupa-1.14.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:69be1d6c3f3ab9fc988c9a0e5801f23f68e2c8b5900a8fd3ae57d1d0e9c5539c"}, 409 | {file = "lupa-1.14.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:77b587043d0bee9cc738e00c12718095cf808dd269b171f852bd82026c664c69"}, 410 | {file = "lupa-1.14.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:62530cf0a9c749a3cd13ad92b31eaf178939d642b6176b46cfcd98f6c5006383"}, 411 | {file = "lupa-1.14.1-cp39-cp39-win32.whl", hash = "sha256:d891b43b8810191eb4c42a0bc57c32f481098029aac42b176108e09ffe118cdc"}, 412 | {file = "lupa-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:cf643bc48a152e2c572d8be7fc1de1c417a6a9648d337ffedebf00f57016b786"}, 413 | {file = "lupa-1.14.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0ac862c6d2eb542ac70d294a8e960b9ae7f46297559733b4c25f9e3c945e522a"}, 414 | {file = "lupa-1.14.1-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:0a15680f425b91ec220eb84b0ab59d24c4bee69d15b88245a6998a7d38c78ba6"}, 415 | {file = "lupa-1.14.1-pp37-pypy37_pp73-win32.whl", hash = "sha256:8a064d72991ba53aeea9720d95f2055f7f8a1e2f35b32a35d92248b63a94bcd1"}, 416 | {file = "lupa-1.14.1-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6d87d6c51e6c3b6326d18af83e81f4860ba0b287cda1101b1ab8562389d598f5"}, 417 | {file = "lupa-1.14.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:b3efe9d887cfdf459054308ecb716e0eb11acb9a96c3022ee4e677c1f510d244"}, 418 | {file = "lupa-1.14.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:723fff6fcab5e7045e0fa79014729577f98082bd1fd1050f907f83a41e4c9865"}, 419 | {file = "lupa-1.14.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:930092a27157241d07d6d09ff01d5530a9e4c0dd515228211f2902b7e88ec1f0"}, 420 | {file = "lupa-1.14.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:7f6bc9852bdf7b16840c984a1e9f952815f7d4b3764585d20d2e062bd1128074"}, 421 | {file = "lupa-1.14.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:8f65d2007092a04616c215fea5ad05ba8f661bd0f45cde5265d27150f64d3dd8"}, 422 | {file = "lupa-1.14.1.tar.gz", hash = "sha256:d0fd4e60ad149fe25c90530e2a0e032a42a6f0455f29ca0edb8170d6ec751c6e"}, 423 | ] 424 | 425 | [[package]] 426 | name = "nodeenv" 427 | version = "1.7.0" 428 | description = "Node.js virtual environment builder" 429 | category = "dev" 430 | optional = false 431 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 432 | files = [ 433 | {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, 434 | {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, 435 | ] 436 | 437 | [package.dependencies] 438 | setuptools = "*" 439 | 440 | [[package]] 441 | name = "orjson" 442 | version = "3.8.3" 443 | description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" 444 | category = "dev" 445 | optional = false 446 | python-versions = ">=3.7" 447 | files = [ 448 | {file = "orjson-3.8.3-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:6bf425bba42a8cee49d611ddd50b7fea9e87787e77bf90b2cb9742293f319480"}, 449 | {file = "orjson-3.8.3-cp310-cp310-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:068febdc7e10655a68a381d2db714d0a90ce46dc81519a4962521a0af07697fb"}, 450 | {file = "orjson-3.8.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d46241e63df2d39f4b7d44e2ff2becfb6646052b963afb1a99f4ef8c2a31aba0"}, 451 | {file = "orjson-3.8.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:961bc1dcbc3a89b52e8979194b3043e7d28ffc979187e46ad23efa8ada612d04"}, 452 | {file = "orjson-3.8.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65ea3336c2bda31bc938785b84283118dec52eb90a2946b140054873946f60a4"}, 453 | {file = "orjson-3.8.3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:83891e9c3a172841f63cae75ff9ce78f12e4c2c5161baec7af725b1d71d4de21"}, 454 | {file = "orjson-3.8.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4b587ec06ab7dd4fb5acf50af98314487b7d56d6e1a7f05d49d8367e0e0b23bc"}, 455 | {file = "orjson-3.8.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:37196a7f2219508c6d944d7d5ea0000a226818787dadbbed309bfa6174f0402b"}, 456 | {file = "orjson-3.8.3-cp310-none-win_amd64.whl", hash = "sha256:94bd4295fadea984b6284dc55f7d1ea828240057f3b6a1d8ec3fe4d1ea596964"}, 457 | {file = "orjson-3.8.3-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:8fe6188ea2a1165280b4ff5fab92753b2007665804e8214be3d00d0b83b5764e"}, 458 | {file = "orjson-3.8.3-cp311-cp311-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:d30d427a1a731157206ddb1e95620925298e4c7c3f93838f53bd19f6069be244"}, 459 | {file = "orjson-3.8.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3497dde5c99dd616554f0dcb694b955a2dc3eb920fe36b150f88ce53e3be2a46"}, 460 | {file = "orjson-3.8.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc29ff612030f3c2e8d7c0bc6c74d18b76dde3726230d892524735498f29f4b2"}, 461 | {file = "orjson-3.8.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1612e08b8254d359f9b72c4a4099d46cdc0f58b574da48472625a0e80222b6e"}, 462 | {file = "orjson-3.8.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:54f3ef512876199d7dacd348a0fc53392c6be15bdf857b2d67fa1b089d561b98"}, 463 | {file = "orjson-3.8.3-cp311-none-win_amd64.whl", hash = "sha256:a30503ee24fc3c59f768501d7a7ded5119a631c79033929a5035a4c91901eac7"}, 464 | {file = "orjson-3.8.3-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:d746da1260bbe7cb06200813cc40482fb1b0595c4c09c3afffe34cfc408d0a4a"}, 465 | {file = "orjson-3.8.3-cp37-cp37m-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:e570fdfa09b84cc7c42a3a6dd22dbd2177cb5f3798feefc430066b260886acae"}, 466 | {file = "orjson-3.8.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca61e6c5a86efb49b790c8e331ff05db6d5ed773dfc9b58667ea3b260971cfb2"}, 467 | {file = "orjson-3.8.3-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cd0bb7e843ceba759e4d4cc2ca9243d1a878dac42cdcfc2295883fbd5bd2400"}, 468 | {file = "orjson-3.8.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff96c61127550ae25caab325e1f4a4fba2740ca77f8e81640f1b8b575e95f784"}, 469 | {file = "orjson-3.8.3-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:faf44a709f54cf490a27ccb0fb1cb5a99005c36ff7cb127d222306bf84f5493f"}, 470 | {file = "orjson-3.8.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:194aef99db88b450b0005406f259ad07df545e6c9632f2a64c04986a0faf2c68"}, 471 | {file = "orjson-3.8.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:aa57fe8b32750a64c816840444ec4d1e4310630ecd9d1d7b3db4b45d248b5585"}, 472 | {file = "orjson-3.8.3-cp37-none-win_amd64.whl", hash = "sha256:dbd74d2d3d0b7ac8ca968c3be51d4cfbecec65c6d6f55dabe95e975c234d0338"}, 473 | {file = "orjson-3.8.3-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:ef3b4c7931989eb973fbbcc38accf7711d607a2b0ed84817341878ec8effb9c5"}, 474 | {file = "orjson-3.8.3-cp38-cp38-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:cf3dad7dbf65f78fefca0eb385d606844ea58a64fe908883a32768dfaee0b952"}, 475 | {file = "orjson-3.8.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbdfbd49d58cbaabfa88fcdf9e4f09487acca3d17f144648668ea6ae06cc3183"}, 476 | {file = "orjson-3.8.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f06ef273d8d4101948ebc4262a485737bcfd440fb83dd4b125d3e5f4226117bc"}, 477 | {file = "orjson-3.8.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75de90c34db99c42ee7608ff88320442d3ce17c258203139b5a8b0afb4a9b43b"}, 478 | {file = "orjson-3.8.3-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:78d69020fa9cf28b363d2494e5f1f10210e8fecf49bf4a767fcffcce7b9d7f58"}, 479 | {file = "orjson-3.8.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b70782258c73913eb6542c04b6556c841247eb92eeace5db2ee2e1d4cb6ffaa5"}, 480 | {file = "orjson-3.8.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:989bf5980fc8aca43a9d0a50ea0a0eee81257e812aaceb1e9c0dbd0856fc5230"}, 481 | {file = "orjson-3.8.3-cp38-none-win_amd64.whl", hash = "sha256:52540572c349179e2a7b6a7b98d6e9320e0333533af809359a95f7b57a61c506"}, 482 | {file = "orjson-3.8.3-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:7f0ec0ca4e81492569057199e042607090ba48289c4f59f29bbc219282b8dc60"}, 483 | {file = "orjson-3.8.3-cp39-cp39-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:b7018494a7a11bcd04da1173c3a38fa5a866f905c138326504552231824ac9c1"}, 484 | {file = "orjson-3.8.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5870ced447a9fbeb5aeb90f362d9106b80a32f729a57b59c64684dbc9175e92"}, 485 | {file = "orjson-3.8.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0459893746dc80dbfb262a24c08fdba2a737d44d26691e85f27b2223cac8075f"}, 486 | {file = "orjson-3.8.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0379ad4c0246281f136a93ed357e342f24070c7055f00aeff9a69c2352e38d10"}, 487 | {file = "orjson-3.8.3-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:3e9e54ff8c9253d7f01ebc5836a1308d0ebe8e5c2edee620867a49556a158484"}, 488 | {file = "orjson-3.8.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f8ff793a3188c21e646219dc5e2c60a74dde25c26de3075f4c2e33cf25835340"}, 489 | {file = "orjson-3.8.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b0c13e05da5bc1a6b2e1d3b117cc669e2267ce0a131e94845056d506ef041c6"}, 490 | {file = "orjson-3.8.3-cp39-none-win_amd64.whl", hash = "sha256:4fff44ca121329d62e48582850a247a487e968cfccd5527fab20bd5b650b78c3"}, 491 | {file = "orjson-3.8.3.tar.gz", hash = "sha256:eda1534a5289168614f21422861cbfb1abb8a82d66c00a8ba823d863c0797178"}, 492 | ] 493 | 494 | [[package]] 495 | name = "packaging" 496 | version = "22.0" 497 | description = "Core utilities for Python packages" 498 | category = "dev" 499 | optional = false 500 | python-versions = ">=3.7" 501 | files = [ 502 | {file = "packaging-22.0-py3-none-any.whl", hash = "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3"}, 503 | {file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"}, 504 | ] 505 | 506 | [[package]] 507 | name = "platformdirs" 508 | version = "2.6.2" 509 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 510 | category = "dev" 511 | optional = false 512 | python-versions = ">=3.7" 513 | files = [ 514 | {file = "platformdirs-2.6.2-py3-none-any.whl", hash = "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490"}, 515 | {file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"}, 516 | ] 517 | 518 | [package.extras] 519 | docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] 520 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] 521 | 522 | [[package]] 523 | name = "pluggy" 524 | version = "1.0.0" 525 | description = "plugin and hook calling mechanisms for python" 526 | category = "dev" 527 | optional = false 528 | python-versions = ">=3.6" 529 | files = [ 530 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 531 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 532 | ] 533 | 534 | [package.extras] 535 | dev = ["pre-commit", "tox"] 536 | testing = ["pytest", "pytest-benchmark"] 537 | 538 | [[package]] 539 | name = "pre-commit" 540 | version = "2.21.0" 541 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 542 | category = "dev" 543 | optional = false 544 | python-versions = ">=3.7" 545 | files = [ 546 | {file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"}, 547 | {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"}, 548 | ] 549 | 550 | [package.dependencies] 551 | cfgv = ">=2.0.0" 552 | identify = ">=1.0.0" 553 | nodeenv = ">=0.11.1" 554 | pyyaml = ">=5.1" 555 | virtualenv = ">=20.10.0" 556 | 557 | [[package]] 558 | name = "pydantic" 559 | version = "1.10.4" 560 | description = "Data validation and settings management using python type hints" 561 | category = "main" 562 | optional = true 563 | python-versions = ">=3.7" 564 | files = [ 565 | {file = "pydantic-1.10.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5635de53e6686fe7a44b5cf25fcc419a0d5e5c1a1efe73d49d48fe7586db854"}, 566 | {file = "pydantic-1.10.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6dc1cc241440ed7ca9ab59d9929075445da6b7c94ced281b3dd4cfe6c8cff817"}, 567 | {file = "pydantic-1.10.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51bdeb10d2db0f288e71d49c9cefa609bca271720ecd0c58009bd7504a0c464c"}, 568 | {file = "pydantic-1.10.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cec42b95dbb500a1f7120bdf95c401f6abb616bbe8785ef09887306792e66e"}, 569 | {file = "pydantic-1.10.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8775d4ef5e7299a2f4699501077a0defdaac5b6c4321173bcb0f3c496fbadf85"}, 570 | {file = "pydantic-1.10.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:572066051eeac73d23f95ba9a71349c42a3e05999d0ee1572b7860235b850cc6"}, 571 | {file = "pydantic-1.10.4-cp310-cp310-win_amd64.whl", hash = "sha256:7feb6a2d401f4d6863050f58325b8d99c1e56f4512d98b11ac64ad1751dc647d"}, 572 | {file = "pydantic-1.10.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39f4a73e5342b25c2959529f07f026ef58147249f9b7431e1ba8414a36761f53"}, 573 | {file = "pydantic-1.10.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:983e720704431a6573d626b00662eb78a07148c9115129f9b4351091ec95ecc3"}, 574 | {file = "pydantic-1.10.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75d52162fe6b2b55964fbb0af2ee58e99791a3138588c482572bb6087953113a"}, 575 | {file = "pydantic-1.10.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdf8d759ef326962b4678d89e275ffc55b7ce59d917d9f72233762061fd04a2d"}, 576 | {file = "pydantic-1.10.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:05a81b006be15655b2a1bae5faa4280cf7c81d0e09fcb49b342ebf826abe5a72"}, 577 | {file = "pydantic-1.10.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d88c4c0e5c5dfd05092a4b271282ef0588e5f4aaf345778056fc5259ba098857"}, 578 | {file = "pydantic-1.10.4-cp311-cp311-win_amd64.whl", hash = "sha256:6a05a9db1ef5be0fe63e988f9617ca2551013f55000289c671f71ec16f4985e3"}, 579 | {file = "pydantic-1.10.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:887ca463c3bc47103c123bc06919c86720e80e1214aab79e9b779cda0ff92a00"}, 580 | {file = "pydantic-1.10.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdf88ab63c3ee282c76d652fc86518aacb737ff35796023fae56a65ced1a5978"}, 581 | {file = "pydantic-1.10.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a48f1953c4a1d9bd0b5167ac50da9a79f6072c63c4cef4cf2a3736994903583e"}, 582 | {file = "pydantic-1.10.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a9f2de23bec87ff306aef658384b02aa7c32389766af3c5dee9ce33e80222dfa"}, 583 | {file = "pydantic-1.10.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:cd8702c5142afda03dc2b1ee6bc358b62b3735b2cce53fc77b31ca9f728e4bc8"}, 584 | {file = "pydantic-1.10.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6e7124d6855b2780611d9f5e1e145e86667eaa3bd9459192c8dc1a097f5e9903"}, 585 | {file = "pydantic-1.10.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b53e1d41e97063d51a02821b80538053ee4608b9a181c1005441f1673c55423"}, 586 | {file = "pydantic-1.10.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:55b1625899acd33229c4352ce0ae54038529b412bd51c4915349b49ca575258f"}, 587 | {file = "pydantic-1.10.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:301d626a59edbe5dfb48fcae245896379a450d04baeed50ef40d8199f2733b06"}, 588 | {file = "pydantic-1.10.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6f9d649892a6f54a39ed56b8dfd5e08b5f3be5f893da430bed76975f3735d15"}, 589 | {file = "pydantic-1.10.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d7b5a3821225f5c43496c324b0d6875fde910a1c2933d726a743ce328fbb2a8c"}, 590 | {file = "pydantic-1.10.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f2f7eb6273dd12472d7f218e1fef6f7c7c2f00ac2e1ecde4db8824c457300416"}, 591 | {file = "pydantic-1.10.4-cp38-cp38-win_amd64.whl", hash = "sha256:4b05697738e7d2040696b0a66d9f0a10bec0efa1883ca75ee9e55baf511909d6"}, 592 | {file = "pydantic-1.10.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a9a6747cac06c2beb466064dda999a13176b23535e4c496c9d48e6406f92d42d"}, 593 | {file = "pydantic-1.10.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eb992a1ef739cc7b543576337bebfc62c0e6567434e522e97291b251a41dad7f"}, 594 | {file = "pydantic-1.10.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:990406d226dea0e8f25f643b370224771878142155b879784ce89f633541a024"}, 595 | {file = "pydantic-1.10.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e82a6d37a95e0b1b42b82ab340ada3963aea1317fd7f888bb6b9dfbf4fff57c"}, 596 | {file = "pydantic-1.10.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9193d4f4ee8feca58bc56c8306bcb820f5c7905fd919e0750acdeeeef0615b28"}, 597 | {file = "pydantic-1.10.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2b3ce5f16deb45c472dde1a0ee05619298c864a20cded09c4edd820e1454129f"}, 598 | {file = "pydantic-1.10.4-cp39-cp39-win_amd64.whl", hash = "sha256:9cbdc268a62d9a98c56e2452d6c41c0263d64a2009aac69246486f01b4f594c4"}, 599 | {file = "pydantic-1.10.4-py3-none-any.whl", hash = "sha256:4948f264678c703f3877d1c8877c4e3b2e12e549c57795107f08cf70c6ec7774"}, 600 | {file = "pydantic-1.10.4.tar.gz", hash = "sha256:b9a3859f24eb4e097502a3be1fb4b2abb79b6103dd9e2e0edb70613a4459a648"}, 601 | ] 602 | 603 | [package.dependencies] 604 | typing-extensions = ">=4.2.0" 605 | 606 | [package.extras] 607 | dotenv = ["python-dotenv (>=0.10.4)"] 608 | email = ["email-validator (>=1.0.3)"] 609 | 610 | [[package]] 611 | name = "pytest" 612 | version = "7.2.0" 613 | description = "pytest: simple powerful testing with Python" 614 | category = "dev" 615 | optional = false 616 | python-versions = ">=3.7" 617 | files = [ 618 | {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, 619 | {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, 620 | ] 621 | 622 | [package.dependencies] 623 | attrs = ">=19.2.0" 624 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 625 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 626 | iniconfig = "*" 627 | packaging = "*" 628 | pluggy = ">=0.12,<2.0" 629 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 630 | 631 | [package.extras] 632 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 633 | 634 | [[package]] 635 | name = "pytest-asyncio" 636 | version = "0.20.3" 637 | description = "Pytest support for asyncio" 638 | category = "dev" 639 | optional = false 640 | python-versions = ">=3.7" 641 | files = [ 642 | {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"}, 643 | {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"}, 644 | ] 645 | 646 | [package.dependencies] 647 | pytest = ">=6.1.0" 648 | 649 | [package.extras] 650 | docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] 651 | testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] 652 | 653 | [[package]] 654 | name = "pytest-cov" 655 | version = "4.0.0" 656 | description = "Pytest plugin for measuring coverage." 657 | category = "dev" 658 | optional = false 659 | python-versions = ">=3.6" 660 | files = [ 661 | {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, 662 | {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, 663 | ] 664 | 665 | [package.dependencies] 666 | coverage = {version = ">=5.2.1", extras = ["toml"]} 667 | pytest = ">=4.6" 668 | 669 | [package.extras] 670 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 671 | 672 | [[package]] 673 | name = "pyyaml" 674 | version = "6.0" 675 | description = "YAML parser and emitter for Python" 676 | category = "dev" 677 | optional = false 678 | python-versions = ">=3.6" 679 | files = [ 680 | {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, 681 | {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, 682 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, 683 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, 684 | {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"}, 685 | {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, 686 | {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, 687 | {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, 688 | {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, 689 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, 690 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, 691 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, 692 | {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, 693 | {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, 694 | {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, 695 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, 696 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, 697 | {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"}, 698 | {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, 699 | {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, 700 | {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, 701 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, 702 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, 703 | {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"}, 704 | {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, 705 | {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, 706 | {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, 707 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, 708 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, 709 | {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"}, 710 | {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, 711 | {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, 712 | {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, 713 | {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, 714 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, 715 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, 716 | {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"}, 717 | {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, 718 | {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, 719 | {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, 720 | ] 721 | 722 | [[package]] 723 | name = "redis" 724 | version = "4.4.0" 725 | description = "Python client for Redis database and key-value store" 726 | category = "main" 727 | optional = false 728 | python-versions = ">=3.7" 729 | files = [ 730 | {file = "redis-4.4.0-py3-none-any.whl", hash = "sha256:cae3ee5d1f57d8caf534cd8764edf3163c77e073bdd74b6f54a87ffafdc5e7d9"}, 731 | {file = "redis-4.4.0.tar.gz", hash = "sha256:7b8c87d19c45d3f1271b124858d2a5c13160c4e74d4835e28273400fa34d5228"}, 732 | ] 733 | 734 | [package.dependencies] 735 | async-timeout = ">=4.0.2" 736 | 737 | [package.extras] 738 | hiredis = ["hiredis (>=1.0.0)"] 739 | ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] 740 | 741 | [[package]] 742 | name = "rfc3986" 743 | version = "1.5.0" 744 | description = "Validating URI References per RFC 3986" 745 | category = "dev" 746 | optional = false 747 | python-versions = "*" 748 | files = [ 749 | {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, 750 | {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, 751 | ] 752 | 753 | [package.dependencies] 754 | idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} 755 | 756 | [package.extras] 757 | idna2008 = ["idna"] 758 | 759 | [[package]] 760 | name = "setuptools" 761 | version = "65.6.3" 762 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 763 | category = "dev" 764 | optional = false 765 | python-versions = ">=3.7" 766 | files = [ 767 | {file = "setuptools-65.6.3-py3-none-any.whl", hash = "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54"}, 768 | {file = "setuptools-65.6.3.tar.gz", hash = "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75"}, 769 | ] 770 | 771 | [package.extras] 772 | 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-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 773 | 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"] 774 | 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"] 775 | 776 | [[package]] 777 | name = "sniffio" 778 | version = "1.3.0" 779 | description = "Sniff out which async library your code is running under" 780 | category = "main" 781 | optional = false 782 | python-versions = ">=3.7" 783 | files = [ 784 | {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, 785 | {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, 786 | ] 787 | 788 | [[package]] 789 | name = "sortedcontainers" 790 | version = "2.4.0" 791 | description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" 792 | category = "dev" 793 | optional = false 794 | python-versions = "*" 795 | files = [ 796 | {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, 797 | {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, 798 | ] 799 | 800 | [[package]] 801 | name = "starlette" 802 | version = "0.16.0" 803 | description = "The little ASGI library that shines." 804 | category = "main" 805 | optional = true 806 | python-versions = ">=3.6" 807 | files = [ 808 | {file = "starlette-0.16.0-py3-none-any.whl", hash = "sha256:38eb24bf705a2c317e15868e384c1b8a12ca396e5a3c3a003db7e667c43f939f"}, 809 | {file = "starlette-0.16.0.tar.gz", hash = "sha256:e1904b5d0007aee24bdd3c43994be9b3b729f4f58e740200de1d623f8c3a8870"}, 810 | ] 811 | 812 | [package.dependencies] 813 | anyio = ">=3.0.0,<4" 814 | 815 | [package.extras] 816 | full = ["graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] 817 | 818 | [[package]] 819 | name = "tomli" 820 | version = "2.0.1" 821 | description = "A lil' TOML parser" 822 | category = "dev" 823 | optional = false 824 | python-versions = ">=3.7" 825 | files = [ 826 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 827 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 828 | ] 829 | 830 | [[package]] 831 | name = "typing-extensions" 832 | version = "4.4.0" 833 | description = "Backported and Experimental Type Hints for Python 3.7+" 834 | category = "main" 835 | optional = true 836 | python-versions = ">=3.7" 837 | files = [ 838 | {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, 839 | {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, 840 | ] 841 | 842 | [[package]] 843 | name = "ujson" 844 | version = "5.6.0" 845 | description = "Ultra fast JSON encoder and decoder for Python" 846 | category = "dev" 847 | optional = false 848 | python-versions = ">=3.7" 849 | files = [ 850 | {file = "ujson-5.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b74396a655ac8a5299dcb765b4a17ba706e45c0df95818bcc6c13c4645a1c38e"}, 851 | {file = "ujson-5.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f63535d51e039a984b2fb67ff87057ffe4216d4757c3cedf2fc846af88253cb7"}, 852 | {file = "ujson-5.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4420bfff18ca6aa39cfb22fe35d8aba3811fa1190c4f4e1ad816b0aad72f7e3"}, 853 | {file = "ujson-5.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35423460954d0c61602da734697724e8dd5326a8aa7900123e584b935116203e"}, 854 | {file = "ujson-5.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:169b3fbd1188647c6ce00cb690915526aff86997c89a94c1b50432010ad7ae0f"}, 855 | {file = "ujson-5.6.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:91000612a2c30f50c6a009e6459a677e5c1972e51b59ecefd6063543dc47a4e9"}, 856 | {file = "ujson-5.6.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b72d4d948749e9c6afcd3d7af9ecc780fccde84e26d275c97273dd83c68a488b"}, 857 | {file = "ujson-5.6.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:aff708a1b9e2d4979f74375ade0bff978be72c8bd90422a756d24d8a46d78059"}, 858 | {file = "ujson-5.6.0-cp310-cp310-win32.whl", hash = "sha256:6ea9024749a41864bffb12da15aace4a3193c03ea97e77b069557aefa342811f"}, 859 | {file = "ujson-5.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:1217326ba80eab1ff3f644f9eee065bd4fcc4e0c068a2f86f851cafd05737169"}, 860 | {file = "ujson-5.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bfb1fdf61763fafc0f8a20becf9cc4287c14fc41c0e14111d28c0d0dfda9ba56"}, 861 | {file = "ujson-5.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fecf83b2ef3cbce4f5cc573df6f6ded565e5e27c1af84038bae5ade306686d82"}, 862 | {file = "ujson-5.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213e41dc501b4a6d029873039da3e45ba7766b9f9eba97ecc4287c371f5403cc"}, 863 | {file = "ujson-5.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad74eb53ee07e76c82f9ef8e7256c33873b81bd1f97a274fdb65ed87c2801f6"}, 864 | {file = "ujson-5.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a68a204386648ec92ae9b526c1ffca528f38221eca70f98b4709390c3204275"}, 865 | {file = "ujson-5.6.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4be7d865cb5161824e12db71cee83290ab72b3523566371a30d6ba1bd63402a"}, 866 | {file = "ujson-5.6.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:dde59d2f06297fc4e70b2bae6e4a6b3ce89ca89697ab2c41e641abae3be96b0c"}, 867 | {file = "ujson-5.6.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:551408a5c4306839b4a4f91503c96069204dbef2c7ed91a9dab08874ac1ed679"}, 868 | {file = "ujson-5.6.0-cp311-cp311-win32.whl", hash = "sha256:ceee5aef3e234c7e998fdb52e5236c41e50cdedc116360f7f1874a04829f6490"}, 869 | {file = "ujson-5.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:dd5ccc036b0f4721b98e1c03ccc604e7f3e1db53866ccc92b2add40ace1782f7"}, 870 | {file = "ujson-5.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a66c5a75b46545361271b4cf55560d9ad8bad794dd054a14b3fbb031407948e"}, 871 | {file = "ujson-5.6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d0a60c5f065737a81249c819475d001a86da9a41900d888287e34619c9b4851"}, 872 | {file = "ujson-5.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9cf04fcc958bb52a6b6c301b780cb9afab3ec68713b17ca5aa423e1f99c2c1cf"}, 873 | {file = "ujson-5.6.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24d40e01accbf4f0ba5181c4db1bac83749fdc1a5413466da582529f2a096085"}, 874 | {file = "ujson-5.6.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3f8b9e8c0420ce3dcc193ab6dd5628840ba79ad1b76e1816ac7ca6752c6bf035"}, 875 | {file = "ujson-5.6.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:0f0f21157d1a84ad5fb54388f31767cde9c1a48fb29de7ef91d8887fdc2ca92b"}, 876 | {file = "ujson-5.6.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:82bf24ea72a73c7d77402a7adc954931243e7ec4241d5738ae74894b53944458"}, 877 | {file = "ujson-5.6.0-cp37-cp37m-win32.whl", hash = "sha256:3b49a1014d396b962cb1d6c5f867f88b2c9aa9224c3860ee6ff63b2837a2965b"}, 878 | {file = "ujson-5.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:74671d1bde8c03daeb92abdbd972960978347b1a1d432c4c1b3c9284ce4094cf"}, 879 | {file = "ujson-5.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:72fa6e850831280a46704032721c75155fd41b839ddadabb6068ab218c56a37a"}, 880 | {file = "ujson-5.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:57904e5b49ffe93189349229dcd83f73862ef9bb8517e8f1e62d0ff73f313847"}, 881 | {file = "ujson-5.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61fdf24f7bddc402ce06b25e4bed7bf5ee4f03e23028a0a09116835c21d54888"}, 882 | {file = "ujson-5.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7174e81c137d480abe2f8036e9fb69157e509f2db0bfdee4488eb61dc3f0ff6b"}, 883 | {file = "ujson-5.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a7e4023c79d9a053c0c6b7c6ec50ea0af78381539ab27412e6af8d9410ae555"}, 884 | {file = "ujson-5.6.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:31288f85db6295ec63e128daff7285bb0bc220935e1b5107bd2d67e2dc687b7e"}, 885 | {file = "ujson-5.6.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f3e651f04b7510fae7d4706a4600cd43457f015df08702ece82a71339fc15c3d"}, 886 | {file = "ujson-5.6.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:52f536712d16a1f4e0f9d084982c28e11b7e70c397a1059069e4d28d53b3f522"}, 887 | {file = "ujson-5.6.0-cp38-cp38-win32.whl", hash = "sha256:23051f062bb257a87f3e55ea5a055ea98d56f08185fd415b34313268fa4d814e"}, 888 | {file = "ujson-5.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:fb1632b27e12c0b0df62f924c362206daf246a42c0080e959dd465810dc3482e"}, 889 | {file = "ujson-5.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3f00dff3bf26bbb96791ceaf51ca95a3f34e2a21985748da855a650c38633b99"}, 890 | {file = "ujson-5.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d1b5e233e42f53bbbc6961caeb492986e9f3aeacd30be811467583203873bad2"}, 891 | {file = "ujson-5.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a51cbe614acb5ea8e2006e4fd80b4e8ea7c51ae51e42c75290012f4925a9d6ab"}, 892 | {file = "ujson-5.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2aece7a92dffc9c78787f5f36e47e24b95495812270c27abc2fa430435a931d"}, 893 | {file = "ujson-5.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20d929a27822cb79e034cc5e0bb62daa0257ab197247cb6f35d5149f2f438983"}, 894 | {file = "ujson-5.6.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7bde16cb18b95a8f68cc48715e4652b394b4fee68cb3f9fee0fd7d26b29a53b6"}, 895 | {file = "ujson-5.6.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bca3c06c3f10ce03fa80b1301dce53765815c2578a24bd141ce4e5769bb7b709"}, 896 | {file = "ujson-5.6.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e5715b0e2767b1987ceed0066980fc0a53421dd2f197b4f88460d474d6aef4c"}, 897 | {file = "ujson-5.6.0-cp39-cp39-win32.whl", hash = "sha256:a8795de7ceadf84bcef88f947f91900d647eda234a2c6cc89912c25048cc0490"}, 898 | {file = "ujson-5.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b9e9d26600020cf635a4e58763959f5a59f8c70f75d72ebf26ceae94c2efac74"}, 899 | {file = "ujson-5.6.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:798116b88158f13ed687417526100ef353ba4692e0aef8afbc622bd4bf7e9057"}, 900 | {file = "ujson-5.6.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c169e12642f0edf1dde607fb264721b88787b55a6da5fb3824302a9cac6f9405"}, 901 | {file = "ujson-5.6.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d70b7f0b485f85141bbc518d0581ae96b912d9f8b070eaf68a9beef8eb1e60"}, 902 | {file = "ujson-5.6.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2cb7a4bd91de97b4c8e57fb5289d1e5f3f019723b59d01d79e2df83783dce5a6"}, 903 | {file = "ujson-5.6.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:ae723b8308ac17a591bb8be9478b58c2c26fada23fd2211fc323796801ad7ff5"}, 904 | {file = "ujson-5.6.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2a24b9a96364f943a4754fa00b47855d0a01b84ac4b8b11ebf058c8fb68c1f77"}, 905 | {file = "ujson-5.6.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d2ac99503a9a5846157631addacc9f74e23f64d5a886fe910e9662660fa10"}, 906 | {file = "ujson-5.6.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fadebaddd3eb71a5c986f0bdc7bb28b072bfc585c141eef37474fc66d1830b0a"}, 907 | {file = "ujson-5.6.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f4efcac06f45183b6ed8e2321554739a964a02d8aa3089ec343253d86bf2804"}, 908 | {file = "ujson-5.6.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e97af10b6f13a498de197fb852e9242064217c25dfca79ebe7ad0cf2b0dd0cb7"}, 909 | {file = "ujson-5.6.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:355ef5311854936b9edc7f1ce638f8257cb45fb6b9873f6b2d16a715eafc9570"}, 910 | {file = "ujson-5.6.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4277f6b1d24be30b7f87ec5346a87693cbc1e55bbc5877f573381b2250c4dd6"}, 911 | {file = "ujson-5.6.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6f4be832d97836d62ac0c148026ec021f9f36481f38e455b51538fcd949ed2a"}, 912 | {file = "ujson-5.6.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bca074d08f0677f05df8170b25ce6e61db3bcdfda78062444972fa6508dc825f"}, 913 | {file = "ujson-5.6.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:87578ccfc35461c77e73660fb7d89bc577732f671364f442bda9e2c58b571765"}, 914 | {file = "ujson-5.6.0.tar.gz", hash = "sha256:f881e2d8a022e9285aa2eab6ba8674358dbcb2b57fa68618d88d62937ac3ff04"}, 915 | ] 916 | 917 | [[package]] 918 | name = "virtualenv" 919 | version = "20.17.1" 920 | description = "Virtual Python Environment builder" 921 | category = "dev" 922 | optional = false 923 | python-versions = ">=3.6" 924 | files = [ 925 | {file = "virtualenv-20.17.1-py3-none-any.whl", hash = "sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4"}, 926 | {file = "virtualenv-20.17.1.tar.gz", hash = "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058"}, 927 | ] 928 | 929 | [package.dependencies] 930 | distlib = ">=0.3.6,<1" 931 | filelock = ">=3.4.1,<4" 932 | platformdirs = ">=2.4,<3" 933 | 934 | [package.extras] 935 | docs = ["proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-argparse (>=0.3.2)", "sphinx-rtd-theme (>=1)", "towncrier (>=22.8)"] 936 | testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"] 937 | 938 | [extras] 939 | all = ["redis", "lupa", "fastapi", "starlette"] 940 | fastapi = ["fastapi"] 941 | redis = ["redis", "lupa"] 942 | starlette = ["starlette"] 943 | 944 | [metadata] 945 | lock-version = "2.0" 946 | python-versions = '^3.8' 947 | content-hash = "b58dbe1be3f33501ce22d30a71b1f51b4958ba7ade26cd7c6387e7eb0409c73a" 948 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = 'asgi-idempotency-header' 3 | version = '0.2.0' 4 | description = 'Enable idempotent operations for your endpoints.' 5 | authors = ['Sondre Lillebø Gundersen '] 6 | maintainers = ["Patrick Gleeson "] 7 | license = 'BSD-3' 8 | readme = 'README.md' 9 | homepage = 'https://github.com/snok/asgi-idempotency-header' 10 | repository = 'https://github.com/snok/asgi-idempotency-header' 11 | keywords = [ 12 | 'idempotence', 'idempotency', 'header', 'fastapi', 'starlette', 'asgi', 13 | 'middleware', 'api', 'endpoint', 'http' 14 | ] 15 | classifiers = [ 16 | 'Development Status :: 4 - Beta', 17 | 'Environment :: Web Environment', 18 | 'Intended Audience :: Developers', 19 | 'License :: OSI Approved :: BSD License', 20 | 'Framework :: AsyncIO', 21 | 'Operating System :: OS Independent', 22 | 'Topic :: Internet :: WWW/HTTP', 23 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 24 | 'Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware', 25 | 'Programming Language :: Python', 26 | 'Programming Language :: Python :: 3.7', 27 | 'Programming Language :: Python :: 3.8', 28 | 'Programming Language :: Python :: 3.9', 29 | 'Programming Language :: Python :: 3.10', 30 | 'Topic :: Software Development :: Libraries', 31 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 32 | 'Topic :: Software Development :: Libraries :: Python Modules', 33 | 'Typing :: Typed', 34 | ] 35 | packages = [ 36 | { include = 'idempotency_header_middleware' }, 37 | ] 38 | 39 | [tool.poetry.dependencies] 40 | python = '^3.8' 41 | fastapi = { version = '^0.70.0', optional = true } 42 | starlette = { version = '*', optional = true } 43 | redis = { version = '^4.2', optional = true } 44 | lupa = { version = '*', optional = true } # needed for redis locks 45 | 46 | [tool.poetry.dev-dependencies] 47 | pytest = '*' 48 | coverage = '*' 49 | pre-commit = '*' 50 | httpx = '*' 51 | pytest-cov = '*' 52 | pytest-asyncio = '*' 53 | orjson = '*' 54 | ujson = '*' 55 | fakeredis = '*' 56 | 57 | [tool.poetry.extras] 58 | fastapi = ['fastapi'] 59 | starlette = ['starlette'] 60 | redis = ['redis', 'lupa'] 61 | all = ['redis', 'lupa', 'fastapi', 'starlette'] 62 | 63 | [build-system] 64 | requires = ['poetry-core>=1.0.0'] 65 | build-backend = 'poetry.core.masonry.api' 66 | 67 | [tool.black] 68 | quiet = true 69 | line-length = 120 70 | skip-string-normalization = true 71 | experimental-string-processing = true 72 | 73 | [tool.isort] 74 | profile = 'black' 75 | line_length = 120 76 | 77 | [tool.coverage.run] 78 | omit = [ 79 | 'idempotency_header_middleware/backends/base.py' 80 | ] 81 | 82 | [tool.coverage.report] 83 | show_missing = true 84 | skip_covered = true 85 | exclude_lines = [ 86 | "if TYPE_CHECKING:", 87 | "pragma: no cover", 88 | ] 89 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | addopts = --cov=idempotency_header_middleware --cov-report term-missing 3 | testpaths = tests 4 | 5 | [flake8] 6 | max-line-length = 120 7 | pytest-mark-no-parentheses = true 8 | pytest-fixture-no-parentheses = true 9 | pytest-parametrize-names-type = csv 10 | 11 | [mypy] 12 | python_version = 3.10 13 | show_error_codes = True 14 | show_traceback = True 15 | warn_unused_ignores = True 16 | ignore_missing_imports = True 17 | warn_redundant_casts = True 18 | warn_unused_configs = True 19 | warn_no_return = False 20 | incremental = True 21 | disallow_untyped_calls = True 22 | disallow_untyped_defs = True 23 | check_untyped_defs = True 24 | 25 | [mypy-tests.*] 26 | ignore_errors = True 27 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/asgi-idempotency-header/8f89a5c5c713881aff89cfb44b524c1792b6429d/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from logging.config import dictConfig 5 | from pathlib import Path 6 | 7 | import fakeredis.aioredis 8 | import pytest 9 | import pytest_asyncio 10 | from fastapi import FastAPI 11 | from fastapi.responses import ORJSONResponse, UJSONResponse 12 | from httpx import AsyncClient 13 | from starlette.responses import ( 14 | FileResponse, 15 | HTMLResponse, 16 | JSONResponse, 17 | PlainTextResponse, 18 | RedirectResponse, 19 | Response, 20 | StreamingResponse, 21 | ) 22 | 23 | from idempotency_header_middleware.backends.redis import RedisBackend 24 | from idempotency_header_middleware.middleware import IdempotencyHeaderMiddleware 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | @pytest.fixture(autouse=True, scope='session') 30 | def _configure_logging(): 31 | LOGGING = { 32 | 'version': 1, 33 | 'disable_existing_loggers': False, 34 | 'filters': {}, 35 | 'formatters': { 36 | 'full': { 37 | 'class': 'logging.Formatter', 38 | 'datefmt': '%H:%M:%S', 39 | 'format': '%(message)s', 40 | }, 41 | }, 42 | 'handlers': { 43 | 'console': { 44 | 'class': 'logging.StreamHandler', 45 | 'formatter': 'full', 46 | }, 47 | }, 48 | 'loggers': { 49 | # project logger 50 | '': { 51 | 'handlers': ['console'], 52 | 'level': 'DEBUG', 53 | 'propagate': True, 54 | }, 55 | }, 56 | } 57 | dictConfig(LOGGING) 58 | 59 | 60 | app = FastAPI() 61 | 62 | method_configs = { 63 | 'default': {'setting': ['POST', 'PATCH'], 'applicable_methods': ['post', 'patch']}, 64 | 'post only': {'setting': ['POST'], 'applicable_methods': ['post']}, 65 | 'with put': {'setting': ['POST', 'PATCH', 'PUT'], 'applicable_methods': ['post', 'patch', 'put']}, 66 | } 67 | 68 | 69 | @pytest.fixture(scope='session', ids=method_configs.keys(), params=method_configs.values()) 70 | def method_config(request): 71 | return request.param 72 | 73 | 74 | @pytest.fixture(scope='session', autouse=True) 75 | def app_with_middleware(method_config): 76 | app.add_middleware( 77 | IdempotencyHeaderMiddleware, 78 | enforce_uuid4_formatting=True, 79 | backend=RedisBackend(redis=fakeredis.aioredis.FakeRedis(decode_responses=True)), 80 | applicable_methods=method_config['setting'], 81 | ) 82 | yield app 83 | # Remove the middleware 84 | app.user_middleware.pop(0) 85 | app.middleware_stack = app.build_middleware_stack() 86 | 87 | 88 | dummy_response = {'test': 'test'} 89 | 90 | 91 | @app.patch('/json-response') 92 | @app.post('/json-response') 93 | @app.put('/json-response') 94 | async def create_json_response() -> JSONResponse: 95 | return JSONResponse(dummy_response, 201) 96 | 97 | 98 | @app.patch('/dict-response', status_code=201) 99 | @app.post('/dict-response', status_code=201) 100 | @app.put('/dict-response', status_code=201) 101 | async def create_dict_response() -> dict: 102 | return dummy_response 103 | 104 | 105 | @app.patch('/normal-byte-response') 106 | @app.post('/normal-byte-response') 107 | @app.put('/normal-byte-response') 108 | async def create_normal_byte_response() -> Response: 109 | return Response(content=json.dumps(dummy_response).encode(), status_code=201) 110 | 111 | 112 | @app.patch('/normal-response', response_class=Response) 113 | @app.post('/normal-response', response_class=Response) 114 | @app.put('/normal-response', response_class=Response) 115 | async def create_normal_response(): 116 | return Response(content=json.dumps(dummy_response), media_type='application/json') 117 | 118 | 119 | @app.patch('/bad-response', response_class=Response) 120 | @app.post('/bad-response', response_class=Response) 121 | @app.put('/bad-response', response_class=Response) 122 | async def create_bad_response(): 123 | return Response(content=json.dumps(dummy_response), media_type='application/xml') 124 | 125 | 126 | @app.patch('/xml-response') 127 | @app.post('/xml-response') 128 | @app.put('/xml-response') 129 | async def create_xml_response(): 130 | data = """ 131 | 132 |
133 | Apply shampoo here. 134 |
135 | 136 | You'll have to use soap here. 137 | 138 |
139 | """ 140 | return Response(content=data, media_type='application/xml') 141 | 142 | 143 | @app.patch('/orjson-response', response_class=ORJSONResponse) 144 | @app.post('/orjson-response', response_class=ORJSONResponse) 145 | @app.put('/orjson-response', response_class=ORJSONResponse) 146 | async def create_orjson_response(): 147 | return dummy_response 148 | 149 | 150 | @app.patch('/ujson-response', response_class=UJSONResponse) 151 | @app.post('/ujson-response', response_class=UJSONResponse) 152 | @app.put('/ujson-response', response_class=UJSONResponse) 153 | async def create_ujson_response(): 154 | return dummy_response 155 | 156 | 157 | @app.patch('/html-response', response_class=HTMLResponse) 158 | @app.post('/html-response', response_class=HTMLResponse) 159 | @app.put('/html-response', response_class=HTMLResponse) 160 | async def create_html_response(): 161 | return """ 162 | 163 | 164 | Some HTML in here 165 | 166 | 167 |

Look ma! HTML!

168 | 169 | 170 | """ 171 | 172 | 173 | @app.patch('/file-response', response_class=FileResponse) 174 | @app.post('/file-response', response_class=FileResponse) 175 | @app.put('/file-response', response_class=FileResponse) 176 | async def create_file_response(): 177 | path = Path(__file__) 178 | return path.parent / 'static/image.jpeg' 179 | 180 | 181 | @app.patch('/plain-text-response', response_class=FileResponse) 182 | @app.post('/plain-text-response', response_class=FileResponse) 183 | @app.put('/plain-text-response', response_class=FileResponse) 184 | async def create_plaintext_response(): 185 | return PlainTextResponse('test') 186 | 187 | 188 | @app.patch('/redirect-response', response_class=FileResponse) 189 | @app.post('/redirect-response', response_class=FileResponse) 190 | @app.put('/redirect-response', response_class=FileResponse) 191 | async def create_redirect_response(): 192 | return RedirectResponse('test') 193 | 194 | 195 | @app.get('/idempotent-method', response_class=JSONResponse) 196 | @app.options('/idempotent-method', response_class=JSONResponse) 197 | @app.delete('/idempotent-method', response_class=JSONResponse) 198 | @app.put('/idempotent-method', response_class=JSONResponse) 199 | @app.head('/idempotent-method', response_class=JSONResponse) 200 | @app.patch('/idempotent-method', response_class=JSONResponse) 201 | async def idempotent_method(): 202 | return Response(status_code=204) 203 | 204 | 205 | @app.post('/slow-endpoint', response_class=JSONResponse) 206 | async def slow_endpoint(): 207 | await asyncio.sleep(1) 208 | return dummy_response 209 | 210 | 211 | async def fake_video_streamer(): 212 | for _ in range(10): 213 | yield b'some fake video bytes' 214 | 215 | 216 | @app.patch('/streaming-response', response_class=FileResponse) 217 | @app.post('/streaming-response', response_class=FileResponse) 218 | @app.put('/streaming-response', response_class=FileResponse) 219 | async def create_streaming_response(): 220 | return StreamingResponse(fake_video_streamer()) 221 | 222 | 223 | @pytest.fixture(scope='session', autouse=True) 224 | def event_loop(): 225 | loop = asyncio.get_event_loop_policy().new_event_loop() 226 | yield loop 227 | loop.close() 228 | 229 | 230 | @pytest_asyncio.fixture(scope='module') 231 | async def client() -> AsyncClient: 232 | async with AsyncClient(app=app, base_url='http://test') as client: 233 | yield client 234 | 235 | 236 | @pytest.fixture(params=['post', 'patch', 'put']) 237 | def applicable_method(method_config, client, request): 238 | if request.param in method_config['applicable_methods']: 239 | return client.__getattribute__(request.param) 240 | else: 241 | raise pytest.skip(request.param + ' is not an applicable method in this configuration.') 242 | 243 | 244 | @pytest.fixture(params=['get', 'delete', 'options', 'head', 'put', 'patch']) 245 | def inapplicable_method(method_config, client, request): 246 | if request.param in method_config['applicable_methods']: 247 | raise pytest.skip(request.param + ' is an applicable method in this configuration.') 248 | else: 249 | return client.__getattribute__(request.param) 250 | -------------------------------------------------------------------------------- /tests/static/image.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/asgi-idempotency-header/8f89a5c5c713881aff89cfb44b524c1792b6429d/tests/static/image.jpeg -------------------------------------------------------------------------------- /tests/test_backends.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from uuid import uuid4 3 | 4 | import fakeredis.aioredis 5 | import pytest 6 | 7 | from idempotency_header_middleware.backends.base import Backend 8 | from idempotency_header_middleware.backends.memory import MemoryBackend 9 | from idempotency_header_middleware.backends.redis import RedisBackend 10 | from tests.conftest import dummy_response 11 | 12 | pytestmark = pytest.mark.asyncio 13 | 14 | base_methods = [ 15 | 'get_stored_response', 16 | 'store_response_data', 17 | 'store_idempotency_key', 18 | 'clear_idempotency_key', 19 | ] 20 | 21 | 22 | def test_base_backend(): 23 | h = Backend 24 | for method in base_methods: 25 | assert hasattr(h, method) 26 | 27 | 28 | redis = fakeredis.aioredis.FakeRedis(decode_responses=True) 29 | 30 | 31 | @pytest.mark.parametrize('backend', [RedisBackend(redis, expiry=1), MemoryBackend(expiry=1)]) 32 | async def test_backend(backend: Backend): 33 | assert issubclass(backend.__class__, Backend) 34 | 35 | # Test setting and clearing key 36 | id_ = str(uuid4()) 37 | already_existed = await backend.store_idempotency_key(id_) 38 | assert already_existed is False 39 | already_existed = await backend.store_idempotency_key(id_) 40 | assert already_existed is True 41 | await backend.clear_idempotency_key(id_) 42 | already_existed = await backend.store_idempotency_key(id_) 43 | assert already_existed is False 44 | 45 | # Test storing and fetching response data 46 | assert (await backend.get_stored_response(id_)) is None 47 | await backend.store_response_data(id_, dummy_response, 201) 48 | stored_response = await backend.get_stored_response(id_) 49 | assert stored_response.status_code == 201 50 | assert stored_response.body == b'{"test":"test"}' 51 | 52 | # Test fetching data after expiry 53 | await backend.store_response_data(id_, dummy_response, 201) 54 | await asyncio.sleep(1) 55 | assert (await backend.get_stored_response(id_)) is None 56 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Awaitable, Callable 3 | from uuid import uuid4 4 | 5 | import pytest 6 | from httpx import AsyncClient, Response 7 | 8 | from tests.conftest import app, dummy_response 9 | 10 | pytestmark = pytest.mark.asyncio 11 | 12 | http_call = Callable[..., Awaitable[Response]] 13 | 14 | 15 | async def test_no_idempotence(applicable_method: http_call) -> None: 16 | response = await applicable_method('/json-response') 17 | assert response.json() == dummy_response 18 | assert dict(response.headers) == {'content-length': '15', 'content-type': 'application/json'} 19 | 20 | 21 | json_response_endpoints = [ 22 | '/json-response', 23 | '/dict-response', 24 | '/normal-response', 25 | '/normal-byte-response', 26 | '/orjson-response', 27 | '/ujson-response', 28 | ] 29 | 30 | 31 | @pytest.mark.parametrize('endpoint', json_response_endpoints) 32 | async def test_idempotence_works_for_json_responses(applicable_method: http_call, endpoint: str) -> None: 33 | idempotency_header = {'Idempotency-Key': uuid4().hex} 34 | 35 | # First request 36 | response = await applicable_method(endpoint, headers=idempotency_header) 37 | assert response.json() == dummy_response 38 | assert 'idempotent-replayed' not in dict(response.headers) 39 | 40 | # Second request 41 | response = await applicable_method(endpoint, headers=idempotency_header) 42 | assert response.json() == dummy_response 43 | assert dict(response.headers)['idempotent-replayed'] == 'true' 44 | 45 | 46 | other_response_endpoints = [ 47 | '/xml-response', 48 | '/html-response', 49 | '/bad-response', 50 | '/file-response', 51 | '/plain-text-response', 52 | ] 53 | 54 | 55 | @pytest.mark.parametrize('endpoint', other_response_endpoints) 56 | async def test_non_json_responses(applicable_method: http_call, endpoint: str) -> None: 57 | idempotency_header = {'Idempotency-Key': uuid4().hex} 58 | 59 | # First request 60 | response = await applicable_method(endpoint, headers=idempotency_header) 61 | assert 'idempotent-replayed' not in dict(response.headers) 62 | 63 | # Second request 64 | response = await applicable_method(endpoint, headers=idempotency_header) 65 | assert 'idempotent-replayed' not in dict(response.headers) 66 | 67 | 68 | non_json_encoding_endpoints = [ 69 | '/redirect-response', 70 | '/streaming-response', 71 | ] 72 | 73 | 74 | @pytest.mark.parametrize('endpoint', non_json_encoding_endpoints) 75 | async def test_wrong_response_encoding(caplog, applicable_method: http_call, endpoint: str) -> None: 76 | idempotency_header = {'Idempotency-Key': uuid4().hex} 77 | 78 | # First request 79 | response = await applicable_method(endpoint, headers=idempotency_header) 80 | assert 'idempotent-replayed' not in dict(response.headers) 81 | 82 | # Second request 83 | response = await applicable_method(endpoint, headers=idempotency_header) 84 | assert 'idempotent-replayed' not in dict(response.headers) 85 | 86 | 87 | async def test_idempotent_method(inapplicable_method: http_call) -> None: 88 | idempotency_header = {'Idempotency-Key': uuid4().hex} 89 | await inapplicable_method('/idempotent-method', headers=idempotency_header) 90 | second_response = await inapplicable_method('/idempotent-method', headers=idempotency_header) 91 | assert second_response.headers == {} 92 | 93 | 94 | async def test_multiple_concurrent_requests(caplog) -> None: 95 | async with AsyncClient(app=app, base_url='http://test') as client: 96 | id_ = str(uuid4()) 97 | 98 | async def fire_request(): 99 | return await client.post('/slow-endpoint', headers={'Idempotency-key': id_}) 100 | 101 | response1, response2 = await asyncio.gather( 102 | *[asyncio.create_task(fire_request()), asyncio.create_task(fire_request())] 103 | ) 104 | 105 | assert response1.status_code == 200 106 | assert response2.status_code == 409 107 | 108 | 109 | bad_header_values = ['test', uuid4().hex[:-1] + 'u', '123', 'ssssssssssssssssssss'] 110 | 111 | 112 | @pytest.mark.parametrize('value', bad_header_values) 113 | async def test_bad_header_formatting(value: str) -> None: 114 | async with AsyncClient(app=app, base_url='http://test') as client: 115 | response = await client.post('/json-response', headers={'Idempotency-key': value}) 116 | assert response.json() == {'detail': "'Idempotency-Key' header value must be formatted as a v4 UUID"} 117 | assert response.status_code == 422 118 | --------------------------------------------------------------------------------