├── src └── saleor_app │ ├── __init__.py │ ├── saleor │ ├── __init__.py │ ├── utils.py │ ├── mutations.py │ ├── exceptions.py │ └── client.py │ ├── tests │ ├── __init__.py │ ├── test_exception_handlers.py │ ├── test_app.py │ ├── test_endpoints.py │ ├── test_install.py │ ├── saleor │ │ └── test_client.py │ ├── conftest.py │ └── test_deps.py │ ├── schemas │ ├── __init__.py │ ├── exception_handlers.py │ ├── webhook.py │ ├── core.py │ ├── utils.py │ ├── manifest.py │ └── handlers.py │ ├── errors.py │ ├── settings.py │ ├── app.py │ ├── install.py │ ├── endpoints.py │ ├── webhook.py │ └── deps.py ├── docs_overrides ├── main.html └── .icons │ └── saleor │ └── saleor.svg ├── .flake8 ├── .pre-commit-config.yaml ├── .github └── workflows │ ├── documentation.yml │ └── check-pr.yml ├── docs ├── local_app_development │ └── index.md ├── event_handlers │ ├── sqs.md │ └── http.md ├── assets │ └── saleor.svg └── index.md ├── LICENSE ├── mkdocs.yml ├── .gitignore ├── pyproject.toml ├── README.md └── poetry.lock /src/saleor_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/saleor_app/saleor/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/saleor_app/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/saleor_app/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs_overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block announce %} 4 | Saleor App Framework is still in development, expect things to change. 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | .*/, 4 | __pycache__/, 5 | docs/, 6 | node_modules/, 7 | */versions/ 8 | ignore = H101,H238,H301,H306,W503,E501 9 | max-line-length = 88 10 | -------------------------------------------------------------------------------- /src/saleor_app/errors.py: -------------------------------------------------------------------------------- 1 | class SaleorAppError(Exception): 2 | """Generic Saleor App Error, all framework errros inherit from this""" 3 | 4 | 5 | class InstallAppError(SaleorAppError): 6 | """Install App error""" 7 | 8 | 9 | class ConfigurationError(SaleorAppError): 10 | """App is misconfigured""" 11 | -------------------------------------------------------------------------------- /src/saleor_app/settings.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseSettings 4 | 5 | 6 | class AWSSettings(BaseSettings): 7 | account_id: str 8 | access_key_id: str 9 | secret_access_key: str 10 | region: str 11 | endpoint_url: Optional[str] 12 | 13 | class Config: 14 | env_prefix = "AWS_" 15 | -------------------------------------------------------------------------------- /src/saleor_app/saleor/utils.py: -------------------------------------------------------------------------------- 1 | from saleor_app.saleor.client import SaleorClient 2 | from saleor_app.schemas.manifest import Manifest 3 | 4 | 5 | def get_client_for_app(saleor_url: str, manifest: Manifest, **kwargs) -> SaleorClient: 6 | return SaleorClient( 7 | saleor_url=saleor_url, 8 | user_agent=f"saleor_client/{manifest.id}-{manifest.version}", 9 | **kwargs, 10 | ) 11 | -------------------------------------------------------------------------------- /src/saleor_app/saleor/mutations.py: -------------------------------------------------------------------------------- 1 | CREATE_WEBHOOK = """ 2 | mutation WebhookCreate($input: WebhookCreateInput!) { 3 | webhookCreate(input: $input) { 4 | webhookErrors { 5 | field 6 | message 7 | code 8 | } 9 | webhook { 10 | id 11 | } 12 | } 13 | } 14 | 15 | """ 16 | 17 | VERIFY_TOKEN = """ 18 | mutation TokenVerify($token: String!) { 19 | tokenVerify(token: $token) { 20 | isValid 21 | user { 22 | id 23 | } 24 | } 25 | } 26 | 27 | """ 28 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 21.5b0 4 | hooks: 5 | - id: black 6 | language_version: python3.9 7 | 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v2.4.0 10 | hooks: 11 | - id: trailing-whitespace 12 | - id: end-of-file-fixer 13 | - id: flake8 14 | 15 | - repo: https://github.com/pycqa/isort 16 | rev: 5.8.0 17 | hooks: 18 | - id: isort 19 | name: isort (python) 20 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: documentation 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | env: 8 | PYTHON_VERSION: 3.10.2 9 | 10 | jobs: 11 | documentation: 12 | name: Build documentation 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v3 17 | 18 | - name: Install poetry 19 | run: pipx install poetry 20 | 21 | - uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | cache: 'poetry' 25 | 26 | - run: poetry install 27 | 28 | - name: Deploy documentation 29 | run: | 30 | poetry run mkdocs gh-deploy --force 31 | poetry run mkdocs --version 32 | -------------------------------------------------------------------------------- /src/saleor_app/schemas/exception_handlers.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import Request 4 | 5 | from saleor_app.saleor.exceptions import IgnoredPrincipal 6 | 7 | 8 | class IgnoredIssuingPrincipalChecker: 9 | def __init__(self, principal_ids: List[str], raise_exception: bool = True): 10 | self.principal_ids = principal_ids 11 | self.raise_exception = raise_exception 12 | 13 | async def __call__(self, request: Request): 14 | json_data = await request.json() 15 | for payload in json_data: 16 | if meta := payload.get("meta"): 17 | if meta["issuing_principal"]["id"] in self.principal_ids: 18 | if self.raise_exception: 19 | raise IgnoredPrincipal(self.principal_ids) 20 | -------------------------------------------------------------------------------- /docs/local_app_development/index.md: -------------------------------------------------------------------------------- 1 | # Running everything locally 2 | 3 | ## Development mode 4 | 5 | For local development and testing you can trick the app to use a Saleor that is not behind HTTPS and also force an auth token. **You shouldn't do neither in a production environment!**. 6 | 7 | ```python 8 | from pydantic import BaseSettings 9 | 10 | 11 | class Settings(BaseSettings): 12 | debug: bool = False 13 | development_auth_token: Optional[str] = None 14 | 15 | 16 | settings = Settings( 17 | debug=True, 18 | development_auth_token="test_token", 19 | ) 20 | 21 | 22 | app = SaleorApp( 23 | # [...] 24 | use_insecure_saleor_http=settings.debug, 25 | development_auth_token=settings.development_auth_token, 26 | ) 27 | ``` 28 | 29 | ## Developing Apps on a local Saleor 30 | 31 | Coming soon... 32 | -------------------------------------------------------------------------------- /src/saleor_app/saleor/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional, Sequence 2 | 3 | 4 | class GraphQLError(Exception): 5 | """ 6 | Raised on Saleor GraphQL errors 7 | """ 8 | 9 | def __init__( 10 | self, 11 | errors: Sequence[Dict[str, Any]], 12 | response_data: Optional[Dict[str, Any]] = None, 13 | ): 14 | self.errors = errors 15 | self.response_data = response_data 16 | 17 | def __str__(self): 18 | return ( 19 | f"GraphQLError: {', '.join([error['message'] for error in self.errors])}." 20 | ) 21 | 22 | 23 | class IgnoredPrincipal(Exception): 24 | message = "Ignore webhook with {} principal ids." 25 | 26 | def __init__(self, principal_ids: List[str]): 27 | super().__init__(self.message.format(",".join(principal_ids))) 28 | -------------------------------------------------------------------------------- /src/saleor_app/tests/test_exception_handlers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from saleor_app.saleor.exceptions import IgnoredPrincipal 4 | from saleor_app.schemas.exception_handlers import IgnoredIssuingPrincipalChecker 5 | 6 | 7 | async def test_ignored_issuing_principal_checker_raise_exception( 8 | mock_request_with_metadata, 9 | ): 10 | ignored_issuing_app_principal = IgnoredIssuingPrincipalChecker(["VXNlcjox"]) 11 | with pytest.raises(IgnoredPrincipal): 12 | await ignored_issuing_app_principal(mock_request_with_metadata) 13 | 14 | 15 | async def test_ignored_issuing_principal_checker_without_raise_exception( 16 | mock_request_with_metadata, 17 | ): 18 | ignored_issuing_app_principal = IgnoredIssuingPrincipalChecker(["dummy-id"]) 19 | try: 20 | await ignored_issuing_app_principal(mock_request_with_metadata) 21 | except IgnoredPrincipal: 22 | assert False 23 | -------------------------------------------------------------------------------- /src/saleor_app/tests/test_app.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from starlette.routing import NoMatchFound 3 | 4 | from saleor_app.webhook import WebhookRouter 5 | 6 | 7 | async def test_saleor_app_init( 8 | saleor_app, 9 | manifest, 10 | ): 11 | assert saleor_app.manifest == manifest 12 | 13 | assert saleor_app.url_path_for("manifest") == "/configuration/manifest" 14 | assert saleor_app.url_path_for("app-install") == "/configuration/install" 15 | 16 | with pytest.raises(NoMatchFound): 17 | saleor_app.url_path_for("handle-webhook") 18 | 19 | 20 | async def test_include_webhook_router(saleor_app, get_webhook_details): 21 | saleor_app.include_webhook_router(get_webhook_details) 22 | 23 | assert saleor_app.get_webhook_details == get_webhook_details 24 | assert saleor_app.url_path_for("handle-webhook") == "/webhook" 25 | assert isinstance(saleor_app.webhook_router, WebhookRouter) 26 | -------------------------------------------------------------------------------- /docs/event_handlers/sqs.md: -------------------------------------------------------------------------------- 1 | # AWS SQS Handlers 2 | 3 | !!! warning "Experimental" 4 | 5 | SQS event handing is in the works, more content to come 6 | 7 | 8 | ## SQS Consumer 9 | 10 | The Saleor App Framework does not provide any means to consume events from an SQS queue. An SQS worker is a work in progress. 11 | 12 | ## Registering SQS handlers 13 | 14 | ```python 15 | from saleor_app.schemas.handlers import SQSUrl 16 | 17 | 18 | @app.webhook_router.sqs_event_route( 19 | SQSUrl( 20 | None, 21 | scheme="awssqs", 22 | user="test", 23 | password="test", 24 | host="localstack", 25 | port="4566", 26 | path="/00000000/product_updated", 27 | ), 28 | SaleorEventType.PRODUCT_UPDATED, 29 | ) 30 | async def product_updated( 31 | payload: List[Webhook], 32 | saleor_domain=Depends(saleor_domain_header), 33 | example=Depends(example_dependency), 34 | ): 35 | print("Product updated!") 36 | print(payload) 37 | ``` 38 | -------------------------------------------------------------------------------- /.github/workflows/check-pr.yml: -------------------------------------------------------------------------------- 1 | name: check_build 2 | on: pull_request 3 | 4 | jobs: 5 | documentation: 6 | name: Check Pull request 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8", "3.9", "3.10"] 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v3 14 | 15 | - name: Install poetry 16 | run: pipx install poetry 17 | 18 | - uses: actions/setup-python@v4 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | cache: 'poetry' 22 | 23 | - run: poetry install 24 | 25 | - run: | 26 | poetry run black --check src/saleor_app/ 27 | poetry run isort --check src/saleor_app/ 28 | poetry run flake8 src/saleor_app/ 29 | 30 | - name: Run docs build test 31 | run: poetry run mkdocs build -s 32 | 33 | - name: Run unit tests 34 | run: | 35 | poetry run coverage erase 36 | poetry run coverage run --source="saleor_app" -p -m pytest src/saleor_app 37 | poetry run coverage combine 38 | poetry run coverage report --fail-under=90 39 | -------------------------------------------------------------------------------- /src/saleor_app/schemas/webhook.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import Any, Optional, Union 4 | 5 | from pydantic import BaseModel 6 | from pydantic.fields import Field 7 | from pydantic.main import Extra 8 | 9 | 10 | class WebhookV1(BaseModel): 11 | class Config: 12 | extra = Extra.allow 13 | allow_mutation = False 14 | 15 | 16 | class PrincipalType(str, Enum): 17 | app = "app" 18 | user = "user" 19 | 20 | 21 | class Principal(BaseModel): 22 | id: str = Field(..., description="Unique identifier of the principal") 23 | type: PrincipalType = Field(..., description="Defines the principal type") 24 | 25 | 26 | class WebhookMeta(BaseModel): 27 | issuing_principal: Principal 28 | issued_at: datetime 29 | cipher_spec: Optional[str] 30 | format: Optional[str] 31 | 32 | 33 | class WebhookV2(BaseModel): 34 | meta: WebhookMeta 35 | 36 | class Config: 37 | extra = Extra.allow 38 | allow_mutation = False 39 | 40 | 41 | class WebhookV3(BaseModel): 42 | meta: WebhookMeta 43 | payload: Any 44 | 45 | class Config: 46 | extra = Extra.forbid 47 | allow_mutation = False 48 | 49 | 50 | Webhook = Union[WebhookV3, WebhookV2, WebhookV1] 51 | -------------------------------------------------------------------------------- /src/saleor_app/schemas/core.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from pydantic import BaseModel 4 | 5 | DomainName = str 6 | AppToken = str 7 | Url = str 8 | 9 | 10 | class WebhookData(BaseModel): 11 | webhook_id: str 12 | webhook_secret_key: str 13 | 14 | 15 | class InstallData(BaseModel): 16 | auth_token: str 17 | 18 | 19 | class SaleorPermissions(str, Enum): 20 | HANDLE_CHECKOUTS = "HANDLE_CHECKOUTS" 21 | HANDLE_PAYMENTS = "HANDLE_PAYMENTS" 22 | HANDLE_TAXES = "HANDLE_TAXES" 23 | IMPERSONATE_USER = "IMPERSONATE_USER" 24 | MANAGE_APPS = "MANAGE_APPS" 25 | MANAGE_CHANNELS = "MANAGE_CHANNELS" 26 | MANAGE_CHECKOUTS = "MANAGE_CHECKOUTS" 27 | MANAGE_DISCOUNTS = "MANAGE_DISCOUNTS" 28 | MANAGE_GIFT_CARD = "MANAGE_GIFT_CARD" 29 | MANAGE_MENUS = "MANAGE_MENUS" 30 | MANAGE_OBSERVABILITY = "MANAGE_OBSERVABILITY" 31 | MANAGE_ORDERS = "MANAGE_ORDERS" 32 | MANAGE_PAGES = "MANAGE_PAGES" 33 | MANAGE_PAGE_TYPES_AND_ATTRIBUTES = "MANAGE_PAGE_TYPES_AND_ATTRIBUTES" 34 | MANAGE_PLUGINS = "MANAGE_PLUGINS" 35 | MANAGE_PRODUCTS = "MANAGE_PRODUCTS" 36 | MANAGE_PRODUCT_TYPES_AND_ATTRIBUTES = "MANAGE_PRODUCT_TYPES_AND_ATTRIBUTES" 37 | MANAGE_SETTINGS = "MANAGE_SETTINGS" 38 | MANAGE_SHIPPING = "MANAGE_SHIPPING" 39 | MANAGE_STAFF = "MANAGE_STAFF" 40 | MANAGE_TRANSLATIONS = "MANAGE_TRANSLATIONS" 41 | MANAGE_USERS = "MANAGE_USERS" 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, Saleor Commerce 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. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /src/saleor_app/saleor/client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import aiohttp 4 | from aiohttp.client import ClientTimeout 5 | 6 | from saleor_app.saleor.exceptions import GraphQLError 7 | 8 | logger = logging.getLogger("saleor.client") 9 | 10 | 11 | class SaleorClient: 12 | def __init__(self, saleor_url, user_agent, auth_token=None, timeout=15): 13 | headers = {"User-Agent": user_agent} 14 | if auth_token: 15 | headers["Authorization"] = f"Bearer {auth_token}" 16 | self.session = aiohttp.ClientSession( 17 | base_url=saleor_url, 18 | headers=headers, 19 | raise_for_status=True, 20 | timeout=ClientTimeout(total=timeout), 21 | ) 22 | 23 | async def close(self): 24 | await self.session.close() 25 | 26 | async def __aenter__(self) -> aiohttp.ClientSession: 27 | return self 28 | 29 | async def __aexit__( 30 | self, 31 | exc_type, 32 | exc_val, 33 | exc_tb, 34 | ) -> None: 35 | await self.close() 36 | 37 | async def execute(self, query, variables=None): 38 | async with self.session.post( 39 | url="/graphql/", json={"query": query, "variables": variables} 40 | ) as resp: 41 | response_data = await resp.json() 42 | if errors := response_data.get("errors"): 43 | exc = GraphQLError( 44 | errors=errors, response_data=response_data.get("data") 45 | ) 46 | logger.error("Error when executing a GraphQL call to Saleor") 47 | logger.debug(str(exc)) 48 | raise exc 49 | return response_data.get("data") 50 | -------------------------------------------------------------------------------- /src/saleor_app/schemas/utils.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request 2 | from starlette.routing import NoMatchFound 3 | 4 | from saleor_app.errors import ConfigurationError 5 | 6 | 7 | class LazyUrl(str): 8 | """ 9 | Used to declare a fully qualified url that is to be resolved when the 10 | request is available. 11 | """ 12 | 13 | def __init__(self, name: str): 14 | self.name = name 15 | 16 | @classmethod 17 | def __get_validators__(cls): 18 | yield cls.validate 19 | 20 | @classmethod 21 | def validate(cls, v): 22 | return v 23 | 24 | def resolve(self): 25 | return self.request.url_for(self.name) 26 | 27 | def __call__(self, request: Request): 28 | self.request = request 29 | try: 30 | return self.resolve() 31 | except NoMatchFound: 32 | raise ConfigurationError( 33 | f"Failed to resolve a lazy url, check if an endpoint named '{self.name}' is defined." 34 | ) 35 | 36 | def __hash__(self): 37 | return hash(self.name) 38 | 39 | def __eq__(self, other): 40 | return self.name == other.name 41 | 42 | def __ne__(self, other): 43 | return not (self.name == other.name) 44 | 45 | def __str__(self): 46 | return f"LazyURL('{self.name}')" 47 | 48 | def __repr__(self): 49 | return str(self) 50 | 51 | 52 | class LazyPath(LazyUrl): 53 | """ 54 | Much like LazyUrl but resolves only to the path part of an url. 55 | The lazy aspect of this class is very redundant but is built like so to 56 | maintain the same usage as the LazyUrl class. 57 | """ 58 | 59 | def resolve(self): 60 | return self.request.app.url_path_for(self.name) 61 | 62 | def __str__(self): 63 | return f"LazyPath('{self.name}')" 64 | -------------------------------------------------------------------------------- /docs/assets/saleor.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /docs_overrides/.icons/saleor/saleor.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Saleor App Framework 2 | site_url: https://github.com/saleor/saleor-app-framework-python 3 | site_author: Saleor.io 4 | site_description: >- 5 | Small Framework helping you to bootstrap your Saleor 3rd Party App 6 | # Repository 7 | repo_name: saleor/saleor-app-framework-python 8 | repo_url: https://github.com/saleor/saleor-app-framework-python 9 | edit_uri: "" 10 | 11 | theme: 12 | name: material 13 | custom_dir: docs_overrides 14 | logo: assets/saleor.svg 15 | favicon: assets/saleor.svg 16 | palette: 17 | - scheme: default 18 | primary: white 19 | accent: light blue 20 | toggle: 21 | icon: material/toggle-switch 22 | name: Switch to dark mode 23 | - scheme: slate 24 | primary: light blue 25 | accent: light blue 26 | toggle: 27 | icon: material/toggle-switch-off-outline 28 | name: Switch to light mode 29 | features: 30 | - navigation.instant 31 | - navigation.tracking 32 | - navigation.sections 33 | - navigation.expand 34 | - navigation.top 35 | - content.code.annotate 36 | 37 | plugins: 38 | - search 39 | 40 | markdown_extensions: 41 | - admonition 42 | - abbr 43 | - attr_list 44 | - def_list 45 | - footnotes 46 | - meta 47 | - md_in_html 48 | - toc: 49 | permalink: true 50 | - pymdownx.arithmatex: 51 | generic: true 52 | - pymdownx.betterem: 53 | smart_enable: all 54 | - pymdownx.caret 55 | - pymdownx.details 56 | - pymdownx.emoji: 57 | emoji_index: !!python/name:materialx.emoji.twemoji 58 | emoji_generator: !!python/name:materialx.emoji.to_svg 59 | options: 60 | custom_icons: 61 | - docs_overrides/.icons 62 | - pymdownx.highlight: 63 | anchor_linenums: true 64 | - pymdownx.inlinehilite 65 | - pymdownx.keys 66 | - pymdownx.magiclink: 67 | repo_url_shorthand: true 68 | user: squidfunk 69 | repo: mkdocs-material 70 | - pymdownx.mark 71 | - pymdownx.smartsymbols 72 | - pymdownx.snippets 73 | - pymdownx.superfences: 74 | custom_fences: 75 | - name: mermaid 76 | class: mermaid 77 | format: !!python/name:pymdownx.superfences.fence_code_format 78 | - pymdownx.tabbed: 79 | alternate_style: true 80 | - pymdownx.tasklist: 81 | custom_checkbox: true 82 | - pymdownx.tilde 83 | - mdx_include 84 | -------------------------------------------------------------------------------- /src/saleor_app/app.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable, Callable, Optional 2 | 3 | from fastapi import APIRouter, FastAPI 4 | 5 | from saleor_app.endpoints import install, manifest 6 | from saleor_app.schemas.core import DomainName, WebhookData 7 | from saleor_app.schemas.manifest import Manifest 8 | from saleor_app.webhook import WebhookRoute, WebhookRouter 9 | 10 | 11 | class SaleorApp(FastAPI): 12 | def __init__( 13 | self, 14 | *, 15 | manifest: Manifest, 16 | validate_domain: Callable[[DomainName], Awaitable[bool]], 17 | save_app_data: Callable[[DomainName, str, WebhookData], Awaitable], 18 | use_insecure_saleor_http: bool = False, 19 | development_auth_token: Optional[str] = None, 20 | **kwargs, 21 | ): 22 | super().__init__(**kwargs) 23 | 24 | self.manifest = manifest 25 | 26 | self.validate_domain = validate_domain 27 | self.save_app_data = save_app_data 28 | 29 | self.use_insecure_saleor_http = use_insecure_saleor_http 30 | self.development_auth_token = development_auth_token 31 | 32 | self.configuration_router = APIRouter( 33 | prefix="/configuration", tags=["configuration"] 34 | ) 35 | 36 | def include_saleor_app_routes(self): 37 | self.configuration_router.get( 38 | "/manifest", response_model=Manifest, name="manifest" 39 | )(manifest) 40 | self.configuration_router.post( 41 | "/install", 42 | responses={ 43 | 400: {"description": "Missing required header"}, 44 | 403: {"description": "Incorrect token or not enough permissions"}, 45 | }, 46 | name="app-install", 47 | )(install) 48 | 49 | self.include_router(self.configuration_router) 50 | 51 | def include_webhook_router( 52 | self, get_webhook_details: Callable[[DomainName], Awaitable[WebhookData]] 53 | ): 54 | self.get_webhook_details = get_webhook_details 55 | self.webhook_router = WebhookRouter( 56 | prefix="/webhook", 57 | responses={ 58 | 400: {"description": "Missing required header"}, 59 | 401: {"description": "Incorrect signature"}, 60 | 404: {"description": "Incorrect saleor event"}, 61 | }, 62 | route_class=WebhookRoute, 63 | ) 64 | 65 | self.include_router(self.webhook_router) 66 | -------------------------------------------------------------------------------- /src/saleor_app/schemas/manifest.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import List, Optional, Union 3 | 4 | from pydantic import AnyHttpUrl, BaseModel, Field 5 | 6 | from saleor_app.schemas.utils import LazyPath, LazyUrl 7 | 8 | 9 | class TargetType(str, Enum): 10 | POPUP = "POPUP" 11 | APP_PAGE = "APP_PAGE" 12 | 13 | 14 | class MountType(str, Enum): 15 | CUSTOMER_DETAILS_MORE_ACTIONS = "CUSTOMER_DETAILS_MORE_ACTIONS" 16 | CUSTOMER_OVERVIEW_CREATE = "CUSTOMER_OVERVIEW_CREATE" 17 | CUSTOMER_OVERVIEW_MORE_ACTIONS = "CUSTOMER_OVERVIEW_MORE_ACTIONS" 18 | 19 | NAVIGATION_CATALOG = "NAVIGATION_CATALOG" 20 | NAVIGATION_CUSTOMERS = "NAVIGATION_CUSTOMERS" 21 | NAVIGATION_DISCOUNTS = "NAVIGATION_DISCOUNTS" 22 | NAVIGATION_ORDERS = "NAVIGATION_ORDERS" 23 | NAVIGATION_PAGES = "NAVIGATION_PAGES" 24 | NAVIGATION_TRANSLATIONS = "NAVIGATION_TRANSLATIONS" 25 | 26 | ORDER_DETAILS_MORE_ACTIONS = "ORDER_DETAILS_MORE_ACTIONS" 27 | ORDER_OVERVIEW_CREATE = "ORDER_OVERVIEW_CREATE" 28 | ORDER_OVERVIEW_MORE_ACTIONS = "ORDER_OVERVIEW_MORE_ACTIONS" 29 | 30 | PRODUCT_DETAILS_MORE_ACTIONS = "PRODUCT_DETAILS_MORE_ACTIONS" 31 | PRODUCT_OVERVIEW_CREATE = "PRODUCT_OVERVIEW_CREATE" 32 | PRODUCT_OVERVIEW_MORE_ACTIONS = "PRODUCT_OVERVIEW_MORE_ACTIONS" 33 | 34 | 35 | class Extension(BaseModel): 36 | label: str 37 | mount: MountType 38 | target: TargetType 39 | permissions: List[str] 40 | url: Union[AnyHttpUrl, LazyUrl, LazyPath] 41 | 42 | class Config: 43 | allow_population_by_field_name = True 44 | 45 | 46 | class Manifest(BaseModel): 47 | id: str 48 | permissions: List[str] 49 | name: str 50 | version: str 51 | about: str 52 | extensions: List[Extension] 53 | data_privacy: str = Field(..., alias="dataPrivacy") 54 | data_privacy_url: Union[AnyHttpUrl, LazyUrl] = Field(..., alias="dataPrivacyUrl") 55 | homepage_url: Union[AnyHttpUrl, LazyUrl] = Field(..., alias="homepageUrl") 56 | support_url: Union[AnyHttpUrl, LazyUrl] = Field(..., alias="supportUrl") 57 | configuration_url: Optional[Union[AnyHttpUrl, LazyUrl]] = Field( 58 | None, alias="configurationUrl" 59 | ) 60 | app_url: Union[AnyHttpUrl, LazyUrl] = Field(..., alias="appUrl") 61 | token_target_url: Union[AnyHttpUrl, LazyUrl] = Field( 62 | LazyUrl("app-install"), alias="tokenTargetUrl" 63 | ) 64 | 65 | class Config: 66 | allow_population_by_field_name = True 67 | -------------------------------------------------------------------------------- /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 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 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | IDEs 132 | .vscode/ 133 | .idea/ 134 | -------------------------------------------------------------------------------- /src/saleor_app/install.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import secrets 3 | import string 4 | from typing import Dict, Tuple 5 | 6 | from saleor_app.errors import InstallAppError 7 | from saleor_app.saleor.exceptions import GraphQLError 8 | from saleor_app.saleor.mutations import CREATE_WEBHOOK 9 | from saleor_app.saleor.utils import get_client_for_app 10 | from saleor_app.schemas.core import AppToken, DomainName, WebhookData 11 | from saleor_app.schemas.handlers import SaleorEventType 12 | from saleor_app.schemas.manifest import Manifest 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | async def install_app( 18 | saleor_domain: DomainName, 19 | auth_token: AppToken, 20 | manifest: Manifest, 21 | events: Dict[str, Tuple[SaleorEventType, str]], 22 | use_insecure_saleor_http: bool, 23 | ): 24 | alphabet = string.ascii_letters + string.digits 25 | secret_key = "".join(secrets.choice(alphabet) for _ in range(20)) 26 | 27 | schema = "http" if use_insecure_saleor_http else "https" 28 | 29 | errors = [] 30 | 31 | async with get_client_for_app( 32 | f"{schema}://{saleor_domain}", manifest=manifest, auth_token=auth_token 33 | ) as saleor_client: 34 | for target_url, target_events in events.items(): 35 | for event_type, subscription_query in target_events: 36 | webhook_input = { 37 | "targetUrl": str(target_url), 38 | "events": [event_type.upper()], 39 | "name": f"{manifest.name}", 40 | "secretKey": secret_key, 41 | } 42 | 43 | if subscription_query: 44 | webhook_input["query"] = subscription_query 45 | 46 | try: 47 | response = await saleor_client.execute( 48 | CREATE_WEBHOOK, 49 | variables={"input": webhook_input}, 50 | ) 51 | except GraphQLError as exc: 52 | errors.append(exc) 53 | 54 | if errors: 55 | logger.error("Unable to finish installation of app for %s.", saleor_domain) 56 | logger.debug( 57 | "Unable to finish installation of app for %s. Received errors: %s", 58 | saleor_domain, 59 | list(map(str, errors)), 60 | ) 61 | raise InstallAppError("Failed to create webhooks for %s.", saleor_domain) 62 | 63 | saleor_webhook_id = response["webhookCreate"]["webhook"]["id"] 64 | return WebhookData(webhook_id=saleor_webhook_id, webhook_secret_key=secret_key) 65 | -------------------------------------------------------------------------------- /src/saleor_app/tests/test_endpoints.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import AsyncMock 3 | 4 | from httpx import AsyncClient 5 | 6 | from saleor_app.deps import SALEOR_DOMAIN_HEADER 7 | from saleor_app.schemas.handlers import SaleorEventType 8 | from saleor_app.schemas.manifest import Manifest 9 | 10 | 11 | async def test_manifest(saleor_app): 12 | base_url = "http://test_app.saleor.local" 13 | 14 | async with AsyncClient(app=saleor_app, base_url=base_url) as ac: 15 | response = await ac.get("configuration/manifest") 16 | 17 | manifest = saleor_app.manifest.dict(by_alias=True) 18 | manifest["appUrl"] = f"{base_url}/configuration" 19 | manifest["tokenTargetUrl"] = f"{base_url}/configuration/install" 20 | manifest["configurationUrl"] = None 21 | manifest["extensions"][0]["url"] = "/extension" 22 | 23 | manifest = json.loads(json.dumps(Manifest(**manifest).dict(by_alias=True))) 24 | 25 | assert response.status_code == 200 26 | assert response.json() == manifest 27 | 28 | 29 | async def test_install(saleor_app_with_webhooks, get_webhook_details, monkeypatch): 30 | install_app_mock = AsyncMock() 31 | monkeypatch.setattr("saleor_app.endpoints.install_app", install_app_mock) 32 | base_url = "http://test_app.saleor.local" 33 | 34 | saleor_app_with_webhooks.validate_domain = AsyncMock(return_value=True) 35 | 36 | async with AsyncClient(app=saleor_app_with_webhooks, base_url=base_url) as ac: 37 | response = await ac.post( 38 | "configuration/install", 39 | json={"auth_token": "saleor-app-token"}, 40 | headers={SALEOR_DOMAIN_HEADER: "example.com"}, 41 | ) 42 | 43 | assert response.status_code == 200 44 | 45 | install_app_mock.assert_awaited_once_with( 46 | saleor_domain="example.com", 47 | auth_token="saleor-app-token", 48 | manifest=saleor_app_with_webhooks.manifest, 49 | events={ 50 | "awssqs://username:password@localstack:4566/account_id/order_created": [ 51 | (SaleorEventType.ORDER_CREATED, None), 52 | ], 53 | "awssqs://username:password@localstack:4566/account_id/order_updated": [ 54 | (SaleorEventType.ORDER_UPDATED, None), 55 | ], 56 | "http://test_app.saleor.local/webhook": [ 57 | (SaleorEventType.PRODUCT_CREATED, None), 58 | (SaleorEventType.PRODUCT_UPDATED, None), 59 | (SaleorEventType.PRODUCT_DELETED, None), 60 | ], 61 | }, 62 | use_insecure_saleor_http=False, 63 | ) 64 | -------------------------------------------------------------------------------- /src/saleor_app/endpoints.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import defaultdict 3 | 4 | from fastapi import Depends, Request 5 | from fastapi.exceptions import HTTPException 6 | 7 | from saleor_app.deps import saleor_domain_header, verify_saleor_domain 8 | from saleor_app.errors import InstallAppError 9 | from saleor_app.install import install_app 10 | from saleor_app.saleor.exceptions import GraphQLError 11 | from saleor_app.schemas.core import InstallData 12 | from saleor_app.schemas.utils import LazyUrl 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | async def manifest(request: Request): 18 | manifest = request.app.manifest 19 | for name, field in manifest: 20 | if isinstance(field, LazyUrl): 21 | setattr(manifest, name, field(request)) 22 | for extension in manifest.extensions: 23 | if isinstance(extension.url, LazyUrl): 24 | extension.url = extension.url(request) 25 | return manifest 26 | 27 | 28 | async def install( 29 | request: Request, 30 | data: InstallData, 31 | _domain_is_valid=Depends(verify_saleor_domain), 32 | saleor_domain=Depends(saleor_domain_header), 33 | ): 34 | events = defaultdict(list) 35 | if hasattr(request.app, "webhook_router"): 36 | for event_type in request.app.webhook_router.http_routes: 37 | events[request.url_for("handle-webhook")].append( 38 | ( 39 | event_type, 40 | request.app.webhook_router.http_routes_subscriptions.get( 41 | event_type 42 | ), 43 | ) 44 | ) 45 | for event_type, sqs_handler in request.app.webhook_router.sqs_routes.items(): 46 | key = str(sqs_handler.target_url) 47 | events[key].append((event_type, None)) 48 | 49 | if events: 50 | try: 51 | webhook_data = await install_app( 52 | saleor_domain=saleor_domain, 53 | auth_token=data.auth_token, 54 | manifest=request.app.manifest, 55 | events=events, 56 | use_insecure_saleor_http=request.app.use_insecure_saleor_http, 57 | ) 58 | except (InstallAppError, GraphQLError) as exc: 59 | logger.debug(str(exc), exc_info=1) 60 | raise HTTPException( 61 | status_code=403, detail="Incorrect token or not enough permissions" 62 | ) 63 | else: 64 | webhook_data = None 65 | 66 | await request.app.save_app_data( 67 | saleor_domain=saleor_domain, 68 | auth_token=data.auth_token, 69 | webhook_data=webhook_data, 70 | ) 71 | 72 | return {} 73 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "saleor-app" 3 | version = "0.2.12" 4 | description = "Saleor app framework" 5 | authors = [ "Saleor Commerce " ] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8" 9 | fastapi = "^0" 10 | uvicorn = "^0" 11 | aiofiles = "^0" 12 | aiohttp = "^3.8" 13 | jwt = "^1" 14 | boto3 = {version = "^1.20.24", optional = true} 15 | Jinja2 = ">=2.11.2,<4.0.0" 16 | 17 | [tool.poetry.dev-dependencies] 18 | ipython = "^7" 19 | pytest = "^6" 20 | isort = "^5" 21 | flake8 = "^3" 22 | pytest-sugar = "^0" 23 | pytest-cov = "^2" 24 | pytest-asyncio = "^0" 25 | black = "^22" 26 | pre-commit = "^2" 27 | tox = "^3" 28 | tox-poetry = "^0" 29 | ipdb = "^0" 30 | httpx = "^0" 31 | pytest-mock = "^3" 32 | mkdocs-material = "^8" 33 | mdx-include = "^1" 34 | 35 | [tool.poetry.extras] 36 | sqs = ["boto3"] 37 | 38 | [tool.pytest.ini_options] 39 | asyncio_mode = "auto" 40 | 41 | [tool.black] 42 | target_version = ['py38'] 43 | include = '\.pyi?$' 44 | exclude = ''' 45 | /(\.git/ 46 | |\.eggs 47 | |\.hg 48 | |__pycache__ 49 | |\.cache 50 | |\.ipynb_checkpoints 51 | |\.mypy_cache 52 | |\.pytest_cache 53 | |\.tox 54 | |\.venv 55 | |node_modules 56 | |_build 57 | |buck-out 58 | |build 59 | |dist 60 | |media 61 | |infrastructure 62 | |templates 63 | |locale 64 | |docs 65 | )/ 66 | ''' 67 | 68 | [tool.isort] 69 | # Vertical Hanging Indent 70 | multi_line_output = 3 71 | include_trailing_comma = true 72 | 73 | line_length = 88 74 | known_first_party = "" 75 | 76 | [build-system] 77 | requires = ["poetry-core>=1.0.0"] 78 | build-backend = "poetry.core.masonry.api" 79 | 80 | [tool.tox] 81 | legacy_tox_ini = """ 82 | [tox] 83 | envlist = py38, py39, py310, lint, docs, coverage 84 | 85 | [testenv] 86 | description = run the test driver with {basepython} 87 | deps = .[develop] 88 | commands = 89 | pytest src/saleor_app 90 | 91 | [testenv:docs] 92 | description = check if docs have no errors or warnings 93 | basepython = python3.10 94 | commands = 95 | mkdocs build -s 96 | 97 | [testenv:lint] 98 | description = check the code style 99 | basepython = python3.10 100 | commands = 101 | black --diff --check src/saleor_app samples/ 102 | isort -c -rc --diff src/saleor_app samples/ 103 | flake8 src/saleor_app samples/ 104 | 105 | [testenv:coverage] 106 | description = [run locally after tests]: combine coverage data and create report 107 | deps = 108 | coverage 109 | skip_install = True 110 | commands = 111 | coverage erase 112 | coverage run --source="saleor_app" -p -m pytest src/saleor_app 113 | coverage combine 114 | coverage report --fail-under=90 115 | depends = 116 | py38, 117 | py39, 118 | py310 119 | parallel_show_output = True 120 | """ 121 | -------------------------------------------------------------------------------- /src/saleor_app/tests/test_install.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | from saleor_app.install import install_app 4 | from saleor_app.saleor.client import SaleorClient 5 | from saleor_app.saleor.mutations import CREATE_WEBHOOK 6 | from saleor_app.schemas.core import WebhookData 7 | 8 | 9 | async def test_install_app(mocker, manifest): 10 | mock_saleor_client = AsyncMock(SaleorClient) 11 | mock_saleor_client.__aenter__.return_value.execute.return_value = { 12 | "webhookCreate": {"webhook": {"id": "123"}} 13 | } 14 | mock_get_client_for_app = mocker.patch( 15 | "saleor_app.install.get_client_for_app", return_value=mock_saleor_client 16 | ) 17 | mocker.patch("saleor_app.install.secrets.choice", return_value="A") 18 | 19 | assert await install_app( 20 | saleor_domain="saleor_domain", 21 | auth_token="test_token", 22 | manifest=manifest, 23 | events={"queue_1": [("TEST_EVENT_1", None)], "url_1": [("TEST_EVENT_2", None)]}, 24 | use_insecure_saleor_http=True, 25 | ) == WebhookData(webhook_id="123", webhook_secret_key="A" * 20) 26 | 27 | mock_get_client_for_app.assert_called_once_with( 28 | "http://saleor_domain", manifest=manifest, auth_token="test_token" 29 | ) 30 | 31 | assert mock_saleor_client.__aenter__.return_value.execute.call_count == 2 32 | mock_saleor_client.__aenter__.return_value.execute.assert_any_await( 33 | CREATE_WEBHOOK, 34 | variables={ 35 | "input": { 36 | "targetUrl": "queue_1", 37 | "events": ["TEST_EVENT_1"], 38 | "name": f"{manifest.name}", 39 | "secretKey": "A" * 20, 40 | } 41 | }, 42 | ) 43 | 44 | mock_saleor_client.__aenter__.return_value.execute.assert_any_await( 45 | CREATE_WEBHOOK, 46 | variables={ 47 | "input": { 48 | "targetUrl": "url_1", 49 | "events": ["TEST_EVENT_2"], 50 | "name": f"{manifest.name}", 51 | "secretKey": "A" * 20, 52 | } 53 | }, 54 | ) 55 | 56 | 57 | async def test_install_app_secure_https(mocker, manifest): 58 | mock_saleor_client = AsyncMock(SaleorClient) 59 | mock_saleor_client.__aenter__.return_value.execute.return_value = { 60 | "webhookCreate": {"webhook": {"id": "123"}} 61 | } 62 | mock_get_client_for_app = mocker.patch( 63 | "saleor_app.install.get_client_for_app", return_value=mock_saleor_client 64 | ) 65 | mocker.patch("saleor_app.install.secrets.choice", return_value="A") 66 | assert await install_app( 67 | saleor_domain="saleor_domain", 68 | auth_token="test_token", 69 | manifest=manifest, 70 | events={"queue_1": [("TEST_EVENT_1", None)], "url_1": [("TEST_EVENT_2", None)]}, 71 | use_insecure_saleor_http=False, 72 | ) == WebhookData(webhook_id="123", webhook_secret_key="A" * 20) 73 | 74 | mock_get_client_for_app.assert_called_once_with( 75 | "https://saleor_domain", manifest=manifest, auth_token="test_token" 76 | ) 77 | -------------------------------------------------------------------------------- /src/saleor_app/tests/saleor/test_client.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import aiohttp 4 | import pytest 5 | from aiohttp import ClientTimeout 6 | 7 | from saleor_app.saleor.client import SaleorClient 8 | from saleor_app.saleor.exceptions import GraphQLError 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "auth_token, timeout", ((None, None), (None, 5), ("token", None), ("token", 10)) 13 | ) 14 | async def test__init__(auth_token, timeout): 15 | kwargs = { 16 | "saleor_url": "http://saleor.local", 17 | "user_agent": "saleor_client/test-0.0.1", 18 | } 19 | if auth_token is not None: 20 | kwargs["auth_token"] = auth_token 21 | if timeout is not None: 22 | kwargs["timeout"] = timeout 23 | 24 | client = SaleorClient(**kwargs) 25 | 26 | assert str(client.session._base_url) == kwargs["saleor_url"] 27 | 28 | if auth_token is not None: 29 | assert client.session.headers["Authorization"] == f"Bearer {auth_token}" 30 | if timeout is not None: 31 | assert client.session.timeout == ClientTimeout(timeout) 32 | 33 | 34 | async def test_close(mocker): 35 | client = SaleorClient(saleor_url="http://saleor.local", user_agent="test") 36 | spy = mocker.spy(client, "close") 37 | 38 | await client.close() 39 | 40 | spy.assert_awaited_once_with() 41 | 42 | 43 | async def test_context_manager(mocker): 44 | async with SaleorClient( 45 | saleor_url="http://saleor.local", user_agent="test" 46 | ) as saleor: 47 | spy = mocker.spy(saleor, "close") 48 | assert isinstance(saleor, SaleorClient) 49 | 50 | spy.assert_awaited_once_with() 51 | 52 | 53 | async def test_execute(monkeypatch): 54 | mock_session = AsyncMock(aiohttp.ClientSession) 55 | mock_session.post.return_value.__aenter__.return_value.json.return_value = { 56 | "data": "response_data" 57 | } 58 | async with SaleorClient( 59 | saleor_url="http://saleor.local", user_agent="test" 60 | ) as saleor: 61 | monkeypatch.setattr(saleor, "session", mock_session, raising=True) 62 | assert ( 63 | await saleor.execute("QUERY", variables={"test": "value"}) 64 | == "response_data" 65 | ) 66 | 67 | mock_session.post.assert_called_once_with( 68 | url="/graphql/", json={"query": "QUERY", "variables": {"test": "value"}} 69 | ) 70 | 71 | 72 | async def test_execute_error(monkeypatch): 73 | mock_session = AsyncMock(aiohttp.ClientSession) 74 | mock_session.post.return_value.__aenter__.return_value.json.return_value = { 75 | "data": "response_data", 76 | "errors": [{"message": "there are errors"}], 77 | } 78 | async with SaleorClient( 79 | saleor_url="http://saleor.local", user_agent="test" 80 | ) as saleor: 81 | monkeypatch.setattr(saleor, "session", mock_session, raising=True) 82 | with pytest.raises(GraphQLError) as excinfo: 83 | await saleor.execute("QUERY", variables={"test": "value"}) 84 | 85 | assert excinfo.value.errors == [{"message": "there are errors"}] 86 | assert excinfo.value.response_data == "response_data" 87 | -------------------------------------------------------------------------------- /src/saleor_app/webhook.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Optional 2 | 3 | from fastapi import APIRouter, Depends, Header, HTTPException, Request 4 | from fastapi.routing import APIRoute 5 | from starlette.responses import Response 6 | 7 | from saleor_app.deps import ( 8 | saleor_domain_header, 9 | verify_saleor_domain, 10 | verify_webhook_signature, 11 | ) 12 | from saleor_app.schemas.handlers import ( 13 | SaleorEventType, 14 | SQSHandler, 15 | SQSUrl, 16 | WebHookHandlerSignature, 17 | ) 18 | from saleor_app.schemas.webhook import Webhook 19 | 20 | SALEOR_EVENT_HEADER = "x-saleor-event" 21 | 22 | 23 | class WebhookRoute(APIRoute): 24 | def get_route_handler(self) -> Callable: 25 | async def custom_route_handler(request: Request) -> Response: 26 | if event_type := request.headers.get(SALEOR_EVENT_HEADER): 27 | route = request.app.webhook_router.http_routes[event_type.upper()] 28 | handler = route.get_route_handler() 29 | response: Response = await handler(request) 30 | return response 31 | 32 | raise HTTPException( 33 | status_code=400, detail=f"Missing {SALEOR_EVENT_HEADER.upper()} header." 34 | ) 35 | 36 | return custom_route_handler 37 | 38 | 39 | class WebhookRouter(APIRouter): 40 | def __init__(self, *args, **kwargs): 41 | super().__init__(*args, **kwargs) 42 | self.http_routes = {} 43 | self.http_routes_subscriptions = {} 44 | self.sqs_routes = {} 45 | self.post("", name="handle-webhook")(self.__handle_webhook_stub) 46 | 47 | async def __handle_webhook_stub( 48 | request: Request, 49 | payload: List[Webhook], # FIXME provide a way to proper define payload types 50 | saleor_domain=Depends(saleor_domain_header), 51 | _verify_saleor_domain=Depends(verify_saleor_domain), 52 | _verify_webhook_signature=Depends(verify_webhook_signature), 53 | _event_type=Header(None, alias=SALEOR_EVENT_HEADER), 54 | ): 55 | """ 56 | This definition will never be used, it's here for the sake of the 57 | OpenAPI spec being complete. 58 | Endpoints registered by `http_event_route` are invoked in place of this. 59 | """ 60 | return {} 61 | 62 | def http_event_route( 63 | self, event_type: SaleorEventType, subscription_query: Optional[str] = None 64 | ): 65 | def decorator(func: WebHookHandlerSignature): 66 | self.http_routes[event_type] = APIRoute( 67 | "", 68 | func, 69 | dependencies=[ 70 | Depends(verify_saleor_domain), 71 | Depends(verify_webhook_signature), 72 | ], 73 | ) 74 | 75 | if subscription_query: 76 | self.http_routes_subscriptions[event_type] = subscription_query 77 | 78 | return decorator 79 | 80 | def sqs_event_route(self, target_url: SQSUrl, event_type: SaleorEventType): 81 | def decorator(func): 82 | self.sqs_routes[event_type] = SQSHandler( 83 | target_url=str(target_url), handler=func 84 | ) 85 | 86 | return decorator 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # saleor-app-framework-python 2 | 3 | Saleor App Framework (Python) provides an easy way to install Your app into the [Saleor Commerce](https://github.com/saleor/saleor). 4 | 5 | Supported features: 6 | 7 | - Installation 8 | - Webhooks handling 9 | - Exception handling 10 | - Ignoring Webhooks triggered by your app 11 | 12 | More on usage You can find in the official [Documentation](https://mirumee.github.io/saleor-app-framework-python/) 13 | 14 | ## Installation 15 | 16 | To use saleor app framework simply install it by 17 | 18 | Using [poetry](https://python-poetry.org/) 19 | 20 | ``` 21 | poetry add git+https://github.com/saleor/saleor-app-framework-python.git@main 22 | ``` 23 | 24 | Using pip 25 | 26 | ``` 27 | pip install git+https://github.com/saleor/saleor-app-framework-python.git@main 28 | ``` 29 | 30 | ## Usage 31 | 32 | The recommended way of building Saleor Python Applications using this framework, is to use project template from [saleor-app-template](https://github.com/mirumee/saleor-app-template). This template will save You a lot of time configuring Your project. 33 | 34 | It is preconfigured to use: 35 | 36 | - uvicorn [[and gunicorn](https://gunicorn.org/)] - as HTTP server 37 | - [SQLAlchemy](https://docs.sqlalchemy.org/en/14/core/) - as an ORM 38 | - [alembic](https://alembic.sqlalchemy.org/en/latest/) - as a database migration tool with configured migration names, black and isort 39 | - [encode/databases](https://www.encode.io/databases/) - as an asyncio support for SQLAlchemy 40 | - [pytest](https://docs.pytest.org/en/7.1.x/) - for unit tests 41 | - [poetry](https://python-poetry.org/) - as python package manager 42 | 43 | With this template You will get: 44 | 45 | - working Dockerfile and docker-compose.yaml 46 | - working database with async support 47 | - working configured tests 48 | - working Saleor installation process 49 | 50 | You can always develop Your own application from scratch, basing on the steps from [Documentation](https://mirumee.github.io/saleor-app-framework-python/) or change any of the existing tools. 51 | 52 |
53 | 54 | ## Development 55 | 56 | ### Tox 57 | 58 | To execeute tests with tox just invoke `tox` or `tox -p`. The tox-poetry plugin will read pyproject.toml and handle the envs creation. In case of a change in the dependencies you can force a recreation of the envs with `tox -r`. 59 | 60 | One might also want to just run a specific testenv like: `tox -e coverage`. 61 | To reduce the noisy output use `-q` like: `tox -p -q` 62 | 63 |
64 | 65 | ## Deployment 66 | 67 | #### Gunicorn 68 | 69 | Here's an example `gunicorn.conf.py` file: 70 | 71 | ```python 72 | from my_app.settings import LOGGING 73 | 74 | workers = 2 75 | keepalive = 30 76 | worker_class = "uvicorn.workers.UvicornH11Worker" 77 | bind = ["0.0.0.0:8080"] 78 | 79 | accesslog = "-" 80 | errorlog = "-" 81 | loglevel = "info" 82 | logconfig_dict = LOGGING 83 | 84 | forwarded_allow_ips = "*" 85 | ``` 86 | 87 | It's a good starting point, keeps the log config in one place and includes the very important (`forwarded_allow_ips` flag)[https://docs.gunicorn.org/en/stable/settings.html#forwarded-allow-ips] **this flag needs to be understood when deploying your app** - it's not always safe to set it to `*` but in some setups it's the only option to allow FastAPI to generate proper urls with `url_for`. 88 | -------------------------------------------------------------------------------- /src/saleor_app/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock, Mock, create_autospec 2 | 3 | import pytest 4 | 5 | from saleor_app.app import SaleorApp 6 | from saleor_app.schemas.handlers import SaleorEventType, SQSUrl 7 | from saleor_app.schemas.manifest import Extension, Manifest 8 | from saleor_app.schemas.utils import LazyPath, LazyUrl 9 | from saleor_app.settings import AWSSettings 10 | 11 | 12 | @pytest.fixture 13 | def aws_settings(): 14 | return AWSSettings( 15 | account_id="", 16 | access_key_id="", 17 | secret_access_key="", 18 | region="", 19 | ) 20 | 21 | 22 | @pytest.fixture 23 | def manifest(): 24 | return Manifest( 25 | name="Sample Saleor App", 26 | version="0.1.0", 27 | about="Sample Saleor App seving as an example.", 28 | data_privacy="", 29 | data_privacy_url="http://172.17.0.1:5000/dataPrivacyUrl", 30 | homepage_url="http://172.17.0.1:5000/homepageUrl", 31 | support_url="http://172.17.0.1:5000/supportUrl", 32 | id="saleor-simple-sample", 33 | permissions=["MANAGE_PRODUCTS", "MANAGE_USERS"], 34 | app_url=LazyUrl("configuration-form"), 35 | extensions=[ 36 | Extension( 37 | label="Custom Product Create", 38 | mount="PRODUCT_OVERVIEW_CREATE", 39 | target="POPUP", 40 | permissions=["MANAGE_PRODUCTS"], 41 | url=LazyPath("extension"), 42 | ) 43 | ], 44 | ) 45 | 46 | 47 | @pytest.fixture 48 | def get_webhook_details(): 49 | return AsyncMock() 50 | 51 | 52 | async def _webhook_handler(): 53 | pass 54 | 55 | 56 | @pytest.fixture 57 | def webhook_handler(): 58 | return create_autospec(_webhook_handler) 59 | 60 | 61 | @pytest.fixture 62 | def saleor_app(manifest): 63 | saleor_app = SaleorApp( 64 | manifest=manifest, 65 | validate_domain=AsyncMock(), 66 | save_app_data=AsyncMock(), 67 | use_insecure_saleor_http=False, 68 | development_auth_token="test_token", 69 | ) 70 | 71 | saleor_app.get("/configuration", name="configuration-form")(lambda x: x) 72 | saleor_app.get("/extension", name="extension")(lambda x: x) 73 | saleor_app.get("/test_webhook_handler", name="test-webhook-handler")(lambda x: x) 74 | saleor_app.include_saleor_app_routes() 75 | return saleor_app 76 | 77 | 78 | @pytest.fixture 79 | def saleor_app_with_webhooks(saleor_app, get_webhook_details, webhook_handler): 80 | saleor_app.include_webhook_router(get_webhook_details) 81 | saleor_app.webhook_router.http_event_route(SaleorEventType.PRODUCT_CREATED)( 82 | webhook_handler 83 | ) 84 | saleor_app.webhook_router.http_event_route(SaleorEventType.PRODUCT_UPDATED)( 85 | webhook_handler 86 | ) 87 | saleor_app.webhook_router.http_event_route(SaleorEventType.PRODUCT_DELETED)( 88 | webhook_handler 89 | ) 90 | saleor_app.webhook_router.sqs_event_route( 91 | SQSUrl( 92 | None, 93 | scheme="awssqs", 94 | user="username", 95 | password="password", 96 | host="localstack", 97 | port="4566", 98 | path="/account_id/order_created", 99 | ), 100 | SaleorEventType.ORDER_CREATED, 101 | )(webhook_handler) 102 | saleor_app.webhook_router.sqs_event_route( 103 | SQSUrl( 104 | None, 105 | scheme="awssqs", 106 | user="username", 107 | password="password", 108 | host="localstack", 109 | port="4566", 110 | path="/account_id/order_updated", 111 | ), 112 | SaleorEventType.ORDER_UPDATED, 113 | )(webhook_handler) 114 | return saleor_app 115 | 116 | 117 | @pytest.fixture 118 | def mock_request(saleor_app): 119 | return Mock(app=saleor_app, body=AsyncMock(return_value=b"request_body")) 120 | 121 | 122 | @pytest.fixture 123 | def mock_request_with_metadata(saleor_app): 124 | return AsyncMock( 125 | app=saleor_app, 126 | json=AsyncMock( 127 | return_value=[ 128 | { 129 | "meta": { 130 | "issued_at": "2022-03-09T14:42:00.756412+00:00", 131 | "version": "3.1.0-a.25", 132 | "issuing_principal": {"id": "VXNlcjox", "type": "user"}, 133 | } 134 | } 135 | ] 136 | ), 137 | ) 138 | -------------------------------------------------------------------------------- /src/saleor_app/tests/test_deps.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from unittest.mock import AsyncMock 3 | 4 | import pytest 5 | from fastapi import HTTPException 6 | 7 | from saleor_app.deps import ( 8 | saleor_domain_header, 9 | saleor_token, 10 | verify_saleor_domain, 11 | verify_saleor_token, 12 | verify_webhook_signature, 13 | ) 14 | from saleor_app.saleor.client import SaleorClient 15 | from saleor_app.saleor.exceptions import GraphQLError 16 | from saleor_app.schemas.core import WebhookData 17 | 18 | 19 | async def test_saleor_domain_header_missing(): 20 | with pytest.raises(HTTPException) as excinfo: 21 | await saleor_domain_header(None) 22 | 23 | assert str(excinfo.value.detail) == "Missing X-SALEOR-DOMAIN header." 24 | 25 | 26 | async def test_saleor_domain_header(): 27 | assert await saleor_domain_header("saleor_domain") == "saleor_domain" 28 | 29 | 30 | async def test_saleor_token(mock_request): 31 | assert await saleor_token(mock_request, "token") == "token" 32 | 33 | 34 | async def test_saleor_token_from_settings(mock_request): 35 | assert await saleor_token(mock_request, None) == "test_token" 36 | 37 | 38 | async def test_saleor_token_missing(mock_request): 39 | mock_request.app.development_auth_token = None 40 | with pytest.raises(HTTPException) as excinfo: 41 | assert await saleor_token(mock_request, None) == "test_token" 42 | 43 | assert str(excinfo.value.detail) == "Missing X-SALEOR-TOKEN header." 44 | 45 | 46 | async def test_verify_saleor_token(mock_request, mocker): 47 | mock_saleor_client = AsyncMock(SaleorClient) 48 | mock_saleor_client.__aenter__.return_value.execute.return_value = { 49 | "tokenVerify": {"isValid": True} 50 | } 51 | mocker.patch("saleor_app.deps.get_client_for_app", return_value=mock_saleor_client) 52 | assert await verify_saleor_token(mock_request, "saleor_domain", "token") 53 | 54 | 55 | async def test_verify_saleor_token_invalid(mock_request, mocker): 56 | mock_saleor_client = AsyncMock(SaleorClient) 57 | mock_saleor_client.__aenter__.return_value.execute.return_value = { 58 | "tokenVerify": {"isValid": False} 59 | } 60 | mocker.patch("saleor_app.deps.get_client_for_app", return_value=mock_saleor_client) 61 | with pytest.raises(HTTPException) as excinfo: 62 | await verify_saleor_token(mock_request, "saleor_domain", "token") 63 | 64 | assert ( 65 | excinfo.value.detail 66 | == "Provided X-SALEOR-DOMAIN and X-SALEOR-TOKEN are incorrect." 67 | ) 68 | 69 | 70 | async def test_verify_saleor_token_saleor_error(mock_request, mocker): 71 | mock_saleor_client = AsyncMock(SaleorClient) 72 | mock_saleor_client.__aenter__.return_value.execute.side_effect = GraphQLError( 73 | "error" 74 | ) 75 | mocker.patch("saleor_app.deps.get_client_for_app", return_value=mock_saleor_client) 76 | assert not await verify_saleor_token(mock_request, "saleor_domain", "token") 77 | 78 | 79 | async def test_verify_saleor_domain(mock_request): 80 | mock_request.app.validate_domain.return_value = True 81 | assert await verify_saleor_domain(mock_request, "saleor_domain") 82 | 83 | 84 | async def test_verify_saleor_domain_invalid(mock_request): 85 | mock_request.app.validate_domain.return_value = False 86 | with pytest.raises(HTTPException) as excinfo: 87 | await verify_saleor_domain(mock_request, "saleor_domain") 88 | 89 | assert excinfo.value.detail == "Provided domain saleor_domain is invalid." 90 | 91 | 92 | async def test_verify_webhook_signature(get_webhook_details, mock_request, mocker): 93 | mock_request.app.include_webhook_router(get_webhook_details) 94 | mock_request.app.get_webhook_details.return_value = WebhookData( 95 | webhook_id="webhook_id", webhook_secret_key="webhook_secret_key" 96 | ) 97 | mock_hmac_new = mocker.patch("saleor_app.deps.hmac.new") 98 | mock_hmac_new.return_value.hexdigest.return_value = "test_signature" 99 | assert ( 100 | await verify_webhook_signature(mock_request, "test_signature", "saleor_domain") 101 | is None 102 | ) 103 | mock_hmac_new.assert_called_once_with( 104 | b"webhook_secret_key", b"request_body", hashlib.sha256 105 | ) 106 | 107 | 108 | async def test_verify_webhook_signature_invalid( 109 | get_webhook_details, mock_request, mocker 110 | ): 111 | mock_request.app.include_webhook_router(get_webhook_details) 112 | mock_request.app.get_webhook_details.return_value = WebhookData( 113 | webhook_id="webhook_id", webhook_secret_key="webhook_secret_key" 114 | ) 115 | mock_hmac_new = mocker.patch("saleor_app.deps.hmac.new") 116 | mock_hmac_new.return_value.hexdigest.return_value = "test_signature" 117 | 118 | with pytest.raises(HTTPException) as excinfo: 119 | await verify_webhook_signature(mock_request, "BAD_signature", "saleor_domain") 120 | 121 | assert excinfo.value.detail == "Invalid webhook signature for x-saleor-signature" 122 | -------------------------------------------------------------------------------- /src/saleor_app/schemas/handlers.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Awaitable, Callable, List, Optional 3 | 4 | from pydantic import AnyHttpUrl, BaseModel 5 | 6 | from saleor_app.schemas.core import DomainName 7 | from saleor_app.schemas.webhook import Webhook 8 | 9 | 10 | class SaleorEventType(str, Enum): 11 | ADDRESS_CREATED = "ADDRESS_CREATED" 12 | ADDRESS_DELETED = "ADDRESS_DELETED" 13 | ADDRESS_UPDATED = "ADDRESS_UPDATED" 14 | ANY_EVENTS = "ANY_EVENTS" 15 | APP_DELETED = "APP_DELETED" 16 | APP_INSTALLED = "APP_INSTALLED" 17 | APP_STATUS_CHANGED = "APP_STATUS_CHANGED" 18 | APP_UPDATED = "APP_UPDATED" 19 | ATTRIBUTE_CREATED = "ATTRIBUTE_CREATED" 20 | ATTRIBUTE_DELETED = "ATTRIBUTE_DELETED" 21 | ATTRIBUTE_UPDATED = "ATTRIBUTE_UPDATED" 22 | ATTRIBUTE_VALUE_CREATED = "ATTRIBUTE_VALUE_CREATED" 23 | ATTRIBUTE_VALUE_DELETED = "ATTRIBUTE_VALUE_DELETED" 24 | ATTRIBUTE_VALUE_UPDATED = "ATTRIBUTE_VALUE_UPDATED" 25 | CATEGORY_CREATED = "CATEGORY_CREATED" 26 | CATEGORY_DELETED = "CATEGORY_DELETED" 27 | CATEGORY_UPDATED = "CATEGORY_UPDATED" 28 | CHANNEL_CREATED = "CHANNEL_CREATED" 29 | CHANNEL_DELETED = "CHANNEL_DELETED" 30 | CHANNEL_STATUS_CHANGED = "CHANNEL_STATUS_CHANGED" 31 | CHANNEL_UPDATED = "CHANNEL_UPDATED" 32 | CHECKOUT_CREATED = "CHECKOUT_CREATED" 33 | CHECKOUT_UPDATED = "CHECKOUT_UPDATED" 34 | COLLECTION_CREATED = "COLLECTION_CREATED" 35 | COLLECTION_DELETED = "COLLECTION_DELETED" 36 | COLLECTION_UPDATED = "COLLECTION_UPDATED" 37 | CUSTOMER_CREATED = "CUSTOMER_CREATED" 38 | CUSTOMER_DELETED = "CUSTOMER_DELETED" 39 | CUSTOMER_UPDATED = "CUSTOMER_UPDATED" 40 | DRAFT_ORDER_CREATED = "DRAFT_ORDER_CREATED" 41 | DRAFT_ORDER_DELETED = "DRAFT_ORDER_DELETED" 42 | DRAFT_ORDER_UPDATED = "DRAFT_ORDER_UPDATED" 43 | FULFILLMENT_CANCELED = "FULFILLMENT_CANCELED" 44 | FULFILLMENT_CREATED = "FULFILLMENT_CREATED" 45 | GIFT_CARD_CREATED = "GIFT_CARD_CREATED" 46 | GIFT_CARD_DELETED = "GIFT_CARD_DELETED" 47 | GIFT_CARD_STATUS_CHANGED = "GIFT_CARD_STATUS_CHANGED" 48 | GIFT_CARD_UPDATED = "GIFT_CARD_UPDATED" 49 | INVOICE_DELETED = "INVOICE_DELETED" 50 | INVOICE_REQUESTED = "INVOICE_REQUESTED" 51 | INVOICE_SENT = "INVOICE_SENT" 52 | MENU_CREATED = "MENU_CREATED" 53 | MENU_DELETED = "MENU_DELETED" 54 | MENU_ITEM_CREATED = "MENU_ITEM_CREATED" 55 | MENU_ITEM_DELETED = "MENU_ITEM_DELETED" 56 | MENU_ITEM_UPDATED = "MENU_ITEM_UPDATED" 57 | MENU_UPDATED = "MENU_UPDATED" 58 | NOTIFY_USER = "NOTIFY_USER" 59 | OBSERVABILITY = "OBSERVABILITY" 60 | ORDER_CANCELLED = "ORDER_CANCELLED" 61 | ORDER_CONFIRMED = "ORDER_CONFIRMED" 62 | ORDER_CREATED = "ORDER_CREATED" 63 | ORDER_FULFILLED = "ORDER_FULFILLED" 64 | ORDER_FULLY_PAID = "ORDER_FULLY_PAID" 65 | ORDER_UPDATED = "ORDER_UPDATED" 66 | PAGE_CREATED = "PAGE_CREATED" 67 | PAGE_DELETED = "PAGE_DELETED" 68 | PAGE_TYPE_CREATED = "PAGE_TYPE_CREATED" 69 | PAGE_TYPE_DELETED = "PAGE_TYPE_DELETED" 70 | PAGE_TYPE_UPDATED = "PAGE_TYPE_UPDATED" 71 | PAGE_UPDATED = "PAGE_UPDATED" 72 | PERMISSION_GROUP_CREATED = "PERMISSION_GROUP_CREATED" 73 | PERMISSION_GROUP_DELETED = "PERMISSION_GROUP_DELETED" 74 | PERMISSION_GROUP_UPDATED = "PERMISSION_GROUP_UPDATED" 75 | PRODUCT_CREATED = "PRODUCT_CREATED" 76 | PRODUCT_DELETED = "PRODUCT_DELETED" 77 | PRODUCT_UPDATED = "PRODUCT_UPDATED" 78 | PRODUCT_VARIANT_BACK_IN_STOCK = "PRODUCT_VARIANT_BACK_IN_STOCK" 79 | PRODUCT_VARIANT_CREATED = "PRODUCT_VARIANT_CREATED" 80 | PRODUCT_VARIANT_DELETED = "PRODUCT_VARIANT_DELETED" 81 | PRODUCT_VARIANT_OUT_OF_STOCK = "PRODUCT_VARIANT_OUT_OF_STOCK" 82 | PRODUCT_VARIANT_UPDATED = "PRODUCT_VARIANT_UPDATED" 83 | SALE_CREATED = "SALE_CREATED" 84 | SALE_DELETED = "SALE_DELETED" 85 | SALE_TOGGLE = "SALE_TOGGLE" 86 | SALE_UPDATED = "SALE_UPDATED" 87 | SHIPPING_PRICE_CREATED = "SHIPPING_PRICE_CREATED" 88 | SHIPPING_PRICE_DELETED = "SHIPPING_PRICE_DELETED" 89 | SHIPPING_PRICE_UPDATED = "SHIPPING_PRICE_UPDATED" 90 | SHIPPING_ZONE_CREATED = "SHIPPING_ZONE_CREATED" 91 | SHIPPING_ZONE_DELETED = "SHIPPING_ZONE_DELETED" 92 | SHIPPING_ZONE_UPDATED = "SHIPPING_ZONE_UPDATED" 93 | STAFF_CREATED = "STAFF_CREATED" 94 | STAFF_DELETED = "STAFF_DELETED" 95 | STAFF_UPDATED = "STAFF_UPDATED" 96 | TRANSACTION_ACTION_REQUEST = "TRANSACTION_ACTION_REQUEST" 97 | TRANSLATION_CREATED = "TRANSLATION_CREATED" 98 | TRANSLATION_UPDATED = "TRANSLATION_UPDATED" 99 | VOUCHER_CREATED = "VOUCHER_CREATED" 100 | VOUCHER_DELETED = "VOUCHER_DELETED" 101 | VOUCHER_UPDATED = "VOUCHER_UPDATED" 102 | WAREHOUSE_CREATED = "WAREHOUSE_CREATED" 103 | WAREHOUSE_DELETED = "WAREHOUSE_DELETED" 104 | WAREHOUSE_UPDATED = "WAREHOUSE_UPDATED" 105 | 106 | PAYMENT_AUTHORIZE = "PAYMENT_AUTHORIZE" 107 | PAYMENT_CAPTURE = "PAYMENT_CAPTURE" 108 | PAYMENT_CONFIRM = "PAYMENT_CONFIRM" 109 | PAYMENT_LIST_GATEWAYS = "PAYMENT_LIST_GATEWAYS" 110 | PAYMENT_PROCESS = "PAYMENT_PROCESS" 111 | PAYMENT_REFUND = "PAYMENT_REFUND" 112 | PAYMENT_VOID = "PAYMENT_VOID" 113 | CHECKOUT_CALCULATE_TAXES = "CHECKOUT_CALCULATE_TAXES" 114 | ORDER_CALCULATE_TAXES = "ORDER_CALCULATE_TAXES" 115 | SHIPPING_LIST_METHODS_FOR_CHECKOUT = "SHIPPING_LIST_METHODS_FOR_CHECKOUT" 116 | ORDER_FILTER_SHIPPING_METHODS = "ORDER_FILTER_SHIPPING_METHODS" 117 | CHECKOUT_FILTER_SHIPPING_METHODS = "CHECKOUT_FILTER_SHIPPING_METHODS" 118 | 119 | 120 | WebHookHandlerSignature = Optional[Callable[[List[Webhook], DomainName], Awaitable]] 121 | 122 | 123 | class SQSUrl(AnyHttpUrl): 124 | allowed_schemes = {"awssqs"} 125 | 126 | 127 | class SQSHandler(BaseModel): 128 | target_url: SQSUrl 129 | handler: WebHookHandlerSignature 130 | -------------------------------------------------------------------------------- /src/saleor_app/deps.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import logging 4 | from typing import List, Optional 5 | 6 | import jwt 7 | from fastapi import Depends, Header, HTTPException, Query, Request 8 | 9 | from saleor_app.saleor.exceptions import GraphQLError 10 | from saleor_app.saleor.mutations import VERIFY_TOKEN 11 | from saleor_app.saleor.utils import get_client_for_app 12 | from saleor_app.schemas.core import DomainName 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | SALEOR_DOMAIN_HEADER = "x-saleor-domain" 17 | SALEOR_TOKEN_HEADER = "x-saleor-token" 18 | SALEOR_SIGNATURE_HEADER = "x-saleor-signature" 19 | 20 | 21 | async def saleor_domain_header( 22 | saleor_domain: Optional[str] = Header(None, alias=SALEOR_DOMAIN_HEADER), 23 | ) -> DomainName: 24 | if not saleor_domain: 25 | logger.warning(f"Missing {SALEOR_DOMAIN_HEADER.upper()} header.") 26 | raise HTTPException( 27 | status_code=400, detail=f"Missing {SALEOR_DOMAIN_HEADER.upper()} header." 28 | ) 29 | return saleor_domain 30 | 31 | 32 | async def saleor_token( 33 | request: Request, 34 | token: Optional[str] = Header(None, alias=SALEOR_TOKEN_HEADER), 35 | ) -> str: 36 | if request.app.development_auth_token: 37 | token = token or request.app.development_auth_token 38 | if not token: 39 | logger.warning(f"Missing {SALEOR_TOKEN_HEADER.upper()} header.") 40 | raise HTTPException( 41 | status_code=400, detail=f"Missing {SALEOR_TOKEN_HEADER.upper()} header." 42 | ) 43 | return token 44 | 45 | 46 | async def verify_saleor_token( 47 | request: Request, 48 | saleor_domain=Depends(saleor_domain_header), 49 | token=Depends(saleor_token), 50 | ) -> bool: 51 | schema = "http" if request.app.use_insecure_saleor_http else "https" 52 | async with get_client_for_app( 53 | f"{schema}://{saleor_domain}", manifest=request.app.manifest 54 | ) as saleor_client: 55 | try: 56 | response = await saleor_client.execute( 57 | VERIFY_TOKEN, 58 | variables={ 59 | "token": token, 60 | }, 61 | ) 62 | except GraphQLError: 63 | return False 64 | try: 65 | is_valid = response["tokenVerify"]["isValid"] is True 66 | except KeyError: 67 | is_valid = False 68 | 69 | if not is_valid: 70 | logger.warning( 71 | f"Provided {SALEOR_DOMAIN_HEADER.upper()} and " 72 | f"{SALEOR_TOKEN_HEADER.upper()} are incorrect." 73 | ) 74 | raise HTTPException( 75 | status_code=400, 76 | detail=( 77 | f"Provided {SALEOR_DOMAIN_HEADER.upper()} and " 78 | f"{SALEOR_TOKEN_HEADER.upper()} are incorrect." 79 | ), 80 | ) 81 | return True 82 | 83 | 84 | async def verify_saleor_domain( 85 | request: Request, 86 | saleor_domain=Depends(saleor_domain_header), 87 | ) -> bool: 88 | domain_is_valid = await request.app.validate_domain(saleor_domain) 89 | if not domain_is_valid: 90 | logger.warning(f"Provided domain {saleor_domain} is invalid.") 91 | raise HTTPException( 92 | status_code=400, detail=f"Provided domain {saleor_domain} is invalid." 93 | ) 94 | return True 95 | 96 | 97 | async def verify_webhook_signature( 98 | request: Request, 99 | signature: Optional[str] = Header(None, alias=SALEOR_SIGNATURE_HEADER), 100 | domain_name=Depends(saleor_domain_header), 101 | ): 102 | if not signature: 103 | raise HTTPException( 104 | status_code=401, 105 | detail=(f"Missing signature header - {SALEOR_SIGNATURE_HEADER}"), 106 | ) 107 | webhook_details = await request.app.get_webhook_details(domain_name) 108 | content = await request.body() 109 | webhook_signature_bytes = bytes(signature, "utf-8") 110 | 111 | secret_key_bytes = bytes(webhook_details.webhook_secret_key, "utf-8") 112 | content_signature_str = hmac.new( 113 | secret_key_bytes, content, hashlib.sha256 114 | ).hexdigest() 115 | content_signature = bytes(content_signature_str, "utf-8") 116 | 117 | if not hmac.compare_digest(content_signature, webhook_signature_bytes): 118 | raise HTTPException( 119 | status_code=401, 120 | detail=(f"Invalid webhook signature for {SALEOR_SIGNATURE_HEADER}"), 121 | ) 122 | 123 | 124 | def require_permission(permissions: List): 125 | """ 126 | Validates is the requesting principal is authorized for the specified action 127 | 128 | Usage: 129 | 130 | ``` 131 | Depends(require_permission([SaleorPermissions.MANAGE_PRODUCTS])) 132 | ``` 133 | """ 134 | 135 | async def func( 136 | saleor_domain=Depends(saleor_domain_header), 137 | saleor_token=Depends(saleor_token), 138 | _token_is_valid=Depends(verify_saleor_token), 139 | ): 140 | jwt_payload = jwt.decode(saleor_token, verify=False) 141 | user_permissions = set(jwt_payload.get("permissions", [])) 142 | if not set([p.value for p in permissions]) - user_permissions: 143 | return True 144 | raise HTTPException(status_code=403, detail="Unauthorized user") 145 | 146 | return func 147 | 148 | 149 | class ConfigurationFormDeps: 150 | def __init__( 151 | self, 152 | request: Request, 153 | domain=Query(...), 154 | ): 155 | self.request = request 156 | self.saleor_domain = domain 157 | 158 | 159 | class ConfigurationDataDeps: 160 | def __init__( 161 | self, 162 | request: Request, 163 | saleor_domain=Depends(saleor_domain_header), 164 | _domain_is_valid=Depends(verify_saleor_domain), 165 | _token_is_valid=Depends(verify_saleor_token), 166 | token=Depends(saleor_token), 167 | ): 168 | self.request = request 169 | self.saleor_domain = saleor_domain 170 | self.token = token 171 | -------------------------------------------------------------------------------- /docs/event_handlers/http.md: -------------------------------------------------------------------------------- 1 | # HTTP Webhook Event Handling 2 | 3 | While it's not necessary for every Saleor app to receive domain events from Saleor it is possible, as described in [:saleor-saleor: Saleor's docs](https://docs.saleor.io/docs/3.0/developer/extending#apps). 4 | 5 | To configure your app to listen to HTTP webhooks issued from Saleor you need to **register your handlers** similarly as you would register your FastAPI endpoints. 6 | 7 | ## Setting up the Saleor App 8 | 9 | ### Getting Webhook details 10 | 11 | The framework ensures that the webhook comes from a trusted source but to achieve that it needs to be provided with a way of retrieving the `webhook_secret` your app stored when the `save_app_data` was invoked (upon app installation). To do that you need to provide the `SaleorApp` with an async function doing just that. 12 | 13 | ```python linenums="1" 14 | from saleor_app.schemas.core import DomainName, WebhookData 15 | 16 | 17 | async def get_webhook_details(saleor_domain: DomainName) -> WebhookData: 18 | return WebhookData( 19 | webhook_id="webhook-id", 20 | webhook_secret_key="webhook-secret-key", 21 | ) # (1) 22 | 23 | ``` 24 | 25 | 1. :material-database: Typically the data would be taken from a database 26 | 27 | The function takes the `saleor_domain` and must return a `WebhookData` Pydantic model instance 28 | 29 | ### Enabling the webhook router 30 | 31 | The framework provides a special webhook router that allows you to use many different endpoints under the `/webhook` route. That router needs to be enabled with the `get_webhook_details` function: 32 | 33 | ```python linenums="1" hl_lines="16" 34 | from saleor_app.app import SaleorApp 35 | from saleor_app.schemas.core import DomainName, WebhookData 36 | 37 | 38 | async def get_webhook_details(saleor_domain: DomainName) -> WebhookData: 39 | return WebhookData( 40 | webhook_id="webhook-id", 41 | webhook_secret_key="webhook-secret-key", 42 | ) 43 | 44 | 45 | app = SaleorApp( 46 | #[...] 47 | ) 48 | 49 | app.include_webhook_router(get_webhook_details=get_webhook_details) 50 | ``` 51 | ### Defining webhook handlers 52 | 53 | An HTTP webhook handler is a function that is exactly like one that one would use as a FastAPI endpoint. The difference is that we register those with a special router. 54 | 55 | An example of a HTTP webhook handler is: 56 | 57 | ```python linenums="1" hl_lines="21-26" 58 | from saleor_app.app import SaleorApp 59 | from saleor_app.deps import saleor_domain_header # (1) 60 | from saleor_app.schemas.handlers import SaleorEventType 61 | from saleor_app.schemas.webhook import Webhook 62 | from saleor_app.schemas.core import DomainName, WebhookData 63 | 64 | 65 | async def get_webhook_details(saleor_domain: DomainName) -> WebhookData: 66 | return WebhookData( 67 | webhook_id="webhook-id", 68 | webhook_secret_key="webhook-secret-key", 69 | ) 70 | 71 | 72 | app = SaleorApp( 73 | #[...] 74 | ) 75 | app.include_webhook_router(get_webhook_details=get_webhook_details) 76 | 77 | 78 | @app.webhook_router.http_event_route(SaleorEventType.PRODUCT_CREATED) 79 | async def product_created( 80 | payload: List[Webhook], 81 | saleor_domain=Depends(saleor_domain_header) # (2) 82 | ): 83 | await do_something(payload, saleor_domain) 84 | ``` 85 | 86 | 1. :information_source: `saleor_app.deps` contains a set of FastAPI dependencies that you might find useful 87 | 2. :information_source: since `product_created` is just a FastAPI endpoint you have access to everything a usual endpoint would, like `request: Request` 88 | 89 | If your app is bigger and you need to import your endpoints from a different module you can: 90 | 91 | ```python linenums="1" hl_lines="6 22-26" 92 | from saleor_app.app import SaleorApp 93 | from saleor_app.schemas.handlers import SaleorEventType 94 | from saleor_app.schemas.webhook import Webhook 95 | from saleor_app.schemas.core import DomainName, WebhookData 96 | 97 | from my_app.webhook_handlers import product_created 98 | 99 | 100 | async def get_webhook_details(saleor_domain: DomainName) -> WebhookData: 101 | return WebhookData( 102 | webhook_id="webhook-id", 103 | webhook_secret_key="webhook-secret-key", 104 | ) 105 | 106 | 107 | app = SaleorApp( 108 | #[...] 109 | ) 110 | app.include_webhook_router(get_webhook_details=get_webhook_details) 111 | 112 | 113 | @app.webhook_router.http_event_route( 114 | SaleorEventType.PRODUCT_CREATED 115 | )(product_created) 116 | ``` 117 | 118 | ### Support for subscription webhook payloads 119 | 120 | The difference between subscriptions and the basic approach for webhook handlers is that we add an optional argument for the subscription query, and also a more general payload parameter type in the endpoint. This is because the structure of the subscription payload sent by Saelor is different in this case. 121 | 122 | You can find documentation for Saleor subscription here: [Saleor's docs - subscription](https://docs.saleor.io/docs/3.0/developer/extending/apps/subscription-webhook-payloads) 123 | 124 | An example of a HTTP subscription webhook handler is: 125 | 126 | ```python linenums="1" hl_lines="21-26" 127 | from saleor_app.app import SaleorApp 128 | from saleor_app.deps import saleor_domain_header # (1) 129 | from saleor_app.schemas.handlers import SaleorEventType 130 | from saleor_app.schemas.webhook import Webhook 131 | from saleor_app.schemas.core import DomainName, WebhookData 132 | 133 | 134 | async def get_webhook_details(saleor_domain: DomainName) -> WebhookData: 135 | return WebhookData( 136 | webhook_id="webhook-id", 137 | webhook_secret_key="webhook-secret-key", 138 | ) 139 | 140 | 141 | app = SaleorApp( 142 | #[...] 143 | ) 144 | app.include_webhook_router(get_webhook_details=get_webhook_details) 145 | 146 | 147 | SUBSCRIPTION_ORDER_CREATED = "subscription { event { ... on DraftOrderCreated { order { id status created } } } }" 148 | 149 | @app.webhook_router.http_event_route(SaleorEventType.ORDER_CREATED, subscription_query=SUBSCRIPTION_ORDER_CREATED) 150 | async def order_created( 151 | payload: Request, 152 | saleor_domain=Depends(saleor_domain_header) # (2) 153 | ): 154 | await do_something(payload, saleor_domain) 155 | ``` 156 | 157 | ### Reinstall the app 158 | 159 | Neither Saleor nor the app will automatically update the registered webhooks, you need to reinstall the app in Saleor if it was already installed. 160 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to Saleor App Framework 2 | 3 | You are reading the Saleor App Framework (Python) documentation. This document should help you to quickly bootstrap a 3rd Party Saleor App, read more about those [:saleor-saleor: Saleor's documentation](https://docs.saleor.io/docs/3.0/developer/extending/apps/key-concepts){ target=_blank }. 4 | 5 | The only supported web framework is **FastAPI**. 6 | 7 | ## Quickstart 8 | 9 | ### Install the framework 10 | 11 | Using Poetry (recommended, [:material-file-link: installing poetry](https://python-poetry.org/docs/#installation){ target=_blank }): 12 | 13 | ```bash 14 | poetry add git+https://github.com/mirumee/saleor-app-framework-python.git@main 15 | # (1) 16 | ``` 17 | 18 | 1. Not on PyPi yet, you must install from git 19 | 20 | Using Pip: 21 | 22 | ```bash 23 | pip install git+https://github.com/mirumee/saleor-app-framework-python.git@main 24 | ``` 25 | 26 | ### Create the Saleor app 27 | 28 | To run your Saleor App you can use the ```#!python SaleorApp``` class which overloads the usual ```#!python FastAPI``` class. 29 | 30 | ```python linenums="1" 31 | from saleor_app.app import SaleorApp 32 | 33 | app = SaleorApp( 34 | # more arguments to come 35 | ) 36 | ``` 37 | 38 | You can use the ```#!python app``` instance as you would normally use the standard one, i.e. to initialize Sentry or add Middleware. None of the core FastAPI logic is changed by the framework. 39 | 40 | #### Manifest 41 | 42 | As described in [:saleor-saleor: App manifest](https://docs.saleor.io/docs/3.0/developer/extending/apps/manifest){ target=_blank } an app needs a manifest, the framework provides a Pydantic representation of that which needs to be provided when initializing the app. 43 | 44 | ```python linenums="1" hl_lines="2-3 6-18 22" 45 | from saleor_app.app import SaleorApp 46 | from saleor_app.schemas.manifest import Manifest 47 | from saleor_app.schemas.utils import LazyUrl 48 | 49 | 50 | manifest = Manifest( 51 | name="Sample Saleor App", 52 | version="0.1.0", 53 | about="Sample Saleor App seving as an example.", 54 | data_privacy="", 55 | data_privacy_url="http://samle-saleor-app.example.com/dataPrivacyUrl", 56 | homepage_url="http://samle-saleor-app.example.com/homepageUrl", 57 | support_url="http://samle-saleor-app.example.com/supportUrl", 58 | id="saleor-simple-sample", 59 | permissions=["MANAGE_PRODUCTS", "MANAGE_USERS"], 60 | configuration_url=LazyUrl("configuration-form"), 61 | extensions=[], 62 | ) 63 | 64 | 65 | app = SaleorApp( 66 | manifest=manifest, 67 | # more arguments to come 68 | ) 69 | ``` 70 | 71 | ??? info "LazyUrl" 72 | 73 | ```#!python saleor_app.schemas.utils.LazyUrl``` is a lazy loader for app url paths, when a manifest is requested the app will resolve the path name to a full url of that endpoint. 74 | 75 | #### Validate Domain 76 | 77 | 3rd Patry Apps work in a multi-tenant fashion - one app service can serve multiple Saleor instances. To prevent any Saleor instance from using your app the app need to authorize a Saleor instance that's done by a simple function that can be as simple as comparing the incoming Saleor domain or as complex to check the allowed domains in a database. 78 | 79 | ```python linenums="1" hl_lines="2 7-8 28" 80 | from saleor_app.app import SaleorApp 81 | from saleor_app.schemas.core import DomainName 82 | from saleor_app.schemas.manifest import Manifest 83 | from saleor_app.schemas.utils import LazyUrl 84 | 85 | 86 | async def validate_domain(saleor_domain: DomainName) -> bool: 87 | return saleor_domain == "172.17.0.1:8000" 88 | 89 | 90 | manifest = Manifest( 91 | name="Sample Saleor App", 92 | version="0.1.0", 93 | about="Sample Saleor App seving as an example.", 94 | data_privacy="", 95 | data_privacy_url="http://samle-saleor-app.example.com/dataPrivacyUrl", 96 | homepage_url="http://samle-saleor-app.example.com/homepageUrl", 97 | support_url="http://samle-saleor-app.example.com/supportUrl", 98 | id="saleor-simple-sample", 99 | permissions=["MANAGE_PRODUCTS", "MANAGE_USERS"], 100 | configuration_url=LazyUrl("configuration-form"), 101 | extensions=[], 102 | ) 103 | 104 | 105 | app = SaleorApp( 106 | manifest=manifest, 107 | validate_domain=validate_domain, 108 | # more arguments to come 109 | ) 110 | ``` 111 | 112 | 113 | #### Saving Application Data 114 | 115 | When Saleor is authorized to install the app an authentication key is issued, that key needs to be securely stored by the app as it provides as much access as the app requested in the manifest. 116 | 117 | ```python linenums="1" hl_lines="2 11-17 39" 118 | from saleor_app.app import SaleorApp 119 | from saleor_app.schemas.core import DomainName, WebhookData 120 | from saleor_app.schemas.manifest import Manifest 121 | from saleor_app.schemas.utils import LazyUrl 122 | 123 | 124 | async def validate_domain(saleor_domain: DomainName) -> bool: 125 | return saleor_domain == "172.17.0.1:8000" 126 | 127 | 128 | async def store_app_data( 129 | saleor_domain: DomainName, auth_token: str, webhook_data: WebhookData 130 | ): 131 | print("Called store_app_data") 132 | print(saleor_domain) 133 | print(auth_token) 134 | print(webhook_data) # 135 | 136 | 137 | 138 | manifest = Manifest( 139 | name="Sample Saleor App", 140 | version="0.1.0", 141 | about="Sample Saleor App serving as an example.", 142 | data_privacy="", 143 | data_privacy_url="http://sample-saleor-app.example.com/dataPrivacyUrl", 144 | homepage_url="http://sample-saleor-app.example.com/homepageUrl", 145 | support_url="http://sample-saleor-app.example.com/supportUrl", 146 | id="saleor-simple-sample", 147 | permissions=["MANAGE_PRODUCTS", "MANAGE_USERS"], 148 | configuration_url=LazyUrl("configuration-form"), 149 | extensions=[], 150 | ) 151 | 152 | 153 | app = SaleorApp( 154 | manifest=manifest, 155 | validate_domain=validate_domain, 156 | save_app_data=store_app_data, # (1) 157 | ) 158 | ``` 159 | 160 | 1. :material-database: Typically, you'd store all the data passed to this function to a DB table 161 | 162 | 163 | #### Configuration URL 164 | 165 | To finalize, you need to provide the endpoint named ```#!python configuration-form``` specified in the [#Manifest](#manifest). 166 | 167 | ```python linenums="1" hl_lines="1 3-4 8 48-100" 168 | import json 169 | 170 | from fastapi.param_functions import Depends 171 | from fastapi.responses import HTMLResponse, PlainTextResponse 172 | 173 | from saleor_app.app import SaleorApp 174 | from saleor_app.deps import ConfigurationFormDeps 175 | from saleor_app.schemas.core import DomainName, WebhookData 176 | from saleor_app.schemas.manifest import Manifest 177 | from saleor_app.schemas.utils import LazyUrl 178 | 179 | 180 | async def validate_domain(saleor_domain: DomainName) -> bool: 181 | return saleor_domain == "172.17.0.1:8000" 182 | 183 | 184 | async def store_app_data( 185 | saleor_domain: DomainName, auth_token: str, webhook_data: WebhookData 186 | ): 187 | print("Called store_app_data") 188 | print(saleor_domain) 189 | print(auth_token) 190 | print(webhook_data) 191 | 192 | 193 | manifest = Manifest( 194 | name="Sample Saleor App", 195 | version="0.1.0", 196 | about="Sample Saleor App seving as an example.", 197 | data_privacy="", 198 | data_privacy_url="http://samle-saleor-app.example.com/dataPrivacyUrl", 199 | homepage_url="http://samle-saleor-app.example.com/homepageUrl", 200 | support_url="http://samle-saleor-app.example.com/supportUrl", 201 | id="saleor-simple-sample", 202 | permissions=["MANAGE_PRODUCTS", "MANAGE_USERS"], 203 | configuration_url=LazyUrl("configuration-form"), 204 | extensions=[], 205 | ) 206 | 207 | 208 | app = SaleorApp( 209 | manifest=manifest, 210 | validate_domain=validate_domain, 211 | save_app_data=store_app_data, 212 | ) 213 | 214 | 215 | @app.configuration_router.get( 216 | "/", response_class=HTMLResponse, name="configuration-form" 217 | ) 218 | async def get_public_form(commons: ConfigurationFormDeps = Depends()): 219 | context = { 220 | "request": str(commons.request), 221 | "form_url": str(commons.request.url), 222 | "saleor_domain": commons.saleor_domain, 223 | } 224 | return PlainTextResponse(json.dumps(context, indent=4)) # (1) 225 | 226 | 227 | app.include_saleor_app_routes() # (2) 228 | ``` 229 | 230 | 1. This view would normally return a UI that will be rendered in the Dashboard 231 | 1. Once you are done defining all the configuration routes you need to tell the app to load them 232 | 233 | > This is a complete example that will work as is. 234 | 235 | !!! warning "Remember about `app.include_saleor_app_routes()`" 236 | 237 | ### Running the App 238 | 239 | To run the app you can save the above example in `simple_app/app.py` and run it with: 240 | 241 | ```bash 242 | uvicorn simple_app.app:app --host 0.0.0.0 --port 5000 --reload 243 | ``` 244 | 245 | Or create a `simple_app/__main__.py` with: 246 | 247 | ```python linenums="1" 248 | import uvicorn 249 | 250 | 251 | def main(): 252 | uvicorn.run( 253 | "simple_app.app:app", host="0.0.0.0", port=5000, debug=True, reload=True 254 | ) 255 | 256 | 257 | if __name__ == "__main__": 258 | main() 259 | ``` 260 | 261 | and run the module as a script with Python's `-m` flag: 262 | 263 | ```bash 264 | python -m simple_app 265 | ``` 266 | 267 | ## Examples 268 | 269 | Visit the [:material-github: Samples directory](https://github.com/saleor/saleor-app-framework-python/tree/main/samples){ target=_blank } to check apps that were built as examples of how the framework can be used. 270 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "aiofiles" 3 | version = "0.8.0" 4 | description = "File support for asyncio." 5 | category = "main" 6 | optional = false 7 | python-versions = ">=3.6,<4.0" 8 | 9 | [[package]] 10 | name = "aiohttp" 11 | version = "3.8.1" 12 | description = "Async http client/server framework (asyncio)" 13 | category = "main" 14 | optional = false 15 | python-versions = ">=3.6" 16 | 17 | [package.dependencies] 18 | aiosignal = ">=1.1.2" 19 | async-timeout = ">=4.0.0a3,<5.0" 20 | attrs = ">=17.3.0" 21 | charset-normalizer = ">=2.0,<3.0" 22 | frozenlist = ">=1.1.1" 23 | multidict = ">=4.5,<7.0" 24 | yarl = ">=1.0,<2.0" 25 | 26 | [package.extras] 27 | speedups = ["aiodns", "brotli", "cchardet"] 28 | 29 | [[package]] 30 | name = "aiosignal" 31 | version = "1.2.0" 32 | description = "aiosignal: a list of registered asynchronous callbacks" 33 | category = "main" 34 | optional = false 35 | python-versions = ">=3.6" 36 | 37 | [package.dependencies] 38 | frozenlist = ">=1.1.0" 39 | 40 | [[package]] 41 | name = "anyio" 42 | version = "3.6.1" 43 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 44 | category = "main" 45 | optional = false 46 | python-versions = ">=3.6.2" 47 | 48 | [package.dependencies] 49 | idna = ">=2.8" 50 | sniffio = ">=1.1" 51 | 52 | [package.extras] 53 | doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] 54 | test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] 55 | trio = ["trio (>=0.16)"] 56 | 57 | [[package]] 58 | name = "appnope" 59 | version = "0.1.3" 60 | description = "Disable App Nap on macOS >= 10.9" 61 | category = "dev" 62 | optional = false 63 | python-versions = "*" 64 | 65 | [[package]] 66 | name = "async-timeout" 67 | version = "4.0.2" 68 | description = "Timeout context manager for asyncio programs" 69 | category = "main" 70 | optional = false 71 | python-versions = ">=3.6" 72 | 73 | [[package]] 74 | name = "atomicwrites" 75 | version = "1.4.1" 76 | description = "Atomic file writes." 77 | category = "dev" 78 | optional = false 79 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 80 | 81 | [[package]] 82 | name = "attrs" 83 | version = "22.1.0" 84 | description = "Classes Without Boilerplate" 85 | category = "main" 86 | optional = false 87 | python-versions = ">=3.5" 88 | 89 | [package.extras] 90 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] 91 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 92 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] 93 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] 94 | 95 | [[package]] 96 | name = "backcall" 97 | version = "0.2.0" 98 | description = "Specifications for callback functions passed in to an API" 99 | category = "dev" 100 | optional = false 101 | python-versions = "*" 102 | 103 | [[package]] 104 | name = "black" 105 | version = "22.8.0" 106 | description = "The uncompromising code formatter." 107 | category = "dev" 108 | optional = false 109 | python-versions = ">=3.6.2" 110 | 111 | [package.dependencies] 112 | click = ">=8.0.0" 113 | mypy-extensions = ">=0.4.3" 114 | pathspec = ">=0.9.0" 115 | platformdirs = ">=2" 116 | tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} 117 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 118 | 119 | [package.extras] 120 | colorama = ["colorama (>=0.4.3)"] 121 | d = ["aiohttp (>=3.7.4)"] 122 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 123 | uvloop = ["uvloop (>=0.15.2)"] 124 | 125 | [[package]] 126 | name = "boto3" 127 | version = "1.24.75" 128 | description = "The AWS SDK for Python" 129 | category = "main" 130 | optional = true 131 | python-versions = ">= 3.7" 132 | 133 | [package.dependencies] 134 | botocore = ">=1.27.75,<1.28.0" 135 | jmespath = ">=0.7.1,<2.0.0" 136 | s3transfer = ">=0.6.0,<0.7.0" 137 | 138 | [package.extras] 139 | crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] 140 | 141 | [[package]] 142 | name = "botocore" 143 | version = "1.27.75" 144 | description = "Low-level, data-driven core of boto 3." 145 | category = "main" 146 | optional = true 147 | python-versions = ">= 3.7" 148 | 149 | [package.dependencies] 150 | jmespath = ">=0.7.1,<2.0.0" 151 | python-dateutil = ">=2.1,<3.0.0" 152 | urllib3 = ">=1.25.4,<1.27" 153 | 154 | [package.extras] 155 | crt = ["awscrt (==0.14.0)"] 156 | 157 | [[package]] 158 | name = "certifi" 159 | version = "2022.9.14" 160 | description = "Python package for providing Mozilla's CA Bundle." 161 | category = "dev" 162 | optional = false 163 | python-versions = ">=3.6" 164 | 165 | [[package]] 166 | name = "cffi" 167 | version = "1.15.1" 168 | description = "Foreign Function Interface for Python calling C code." 169 | category = "main" 170 | optional = false 171 | python-versions = "*" 172 | 173 | [package.dependencies] 174 | pycparser = "*" 175 | 176 | [[package]] 177 | name = "cfgv" 178 | version = "3.3.1" 179 | description = "Validate configuration and produce human readable error messages." 180 | category = "dev" 181 | optional = false 182 | python-versions = ">=3.6.1" 183 | 184 | [[package]] 185 | name = "charset-normalizer" 186 | version = "2.1.1" 187 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 188 | category = "main" 189 | optional = false 190 | python-versions = ">=3.6.0" 191 | 192 | [package.extras] 193 | unicode_backport = ["unicodedata2"] 194 | 195 | [[package]] 196 | name = "click" 197 | version = "8.1.3" 198 | description = "Composable command line interface toolkit" 199 | category = "main" 200 | optional = false 201 | python-versions = ">=3.7" 202 | 203 | [package.dependencies] 204 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 205 | 206 | [[package]] 207 | name = "colorama" 208 | version = "0.4.5" 209 | description = "Cross-platform colored terminal text." 210 | category = "main" 211 | optional = false 212 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 213 | 214 | [[package]] 215 | name = "coverage" 216 | version = "6.4.4" 217 | description = "Code coverage measurement for Python" 218 | category = "dev" 219 | optional = false 220 | python-versions = ">=3.7" 221 | 222 | [package.extras] 223 | toml = ["tomli"] 224 | 225 | [[package]] 226 | name = "cryptography" 227 | version = "38.0.1" 228 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 229 | category = "main" 230 | optional = false 231 | python-versions = ">=3.6" 232 | 233 | [package.dependencies] 234 | cffi = ">=1.12" 235 | 236 | [package.extras] 237 | docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] 238 | docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] 239 | pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] 240 | sdist = ["setuptools-rust (>=0.11.4)"] 241 | ssh = ["bcrypt (>=3.1.5)"] 242 | test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] 243 | 244 | [[package]] 245 | name = "cyclic" 246 | version = "1.0.0" 247 | description = "Handle cyclic relations" 248 | category = "dev" 249 | optional = false 250 | python-versions = "*" 251 | 252 | [[package]] 253 | name = "decorator" 254 | version = "5.1.1" 255 | description = "Decorators for Humans" 256 | category = "dev" 257 | optional = false 258 | python-versions = ">=3.5" 259 | 260 | [[package]] 261 | name = "distlib" 262 | version = "0.3.6" 263 | description = "Distribution utilities" 264 | category = "dev" 265 | optional = false 266 | python-versions = "*" 267 | 268 | [[package]] 269 | name = "fastapi" 270 | version = "0.85.0" 271 | description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" 272 | category = "main" 273 | optional = false 274 | python-versions = ">=3.7" 275 | 276 | [package.dependencies] 277 | 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" 278 | starlette = "0.20.4" 279 | 280 | [package.extras] 281 | 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,<7.0.0)", "requests (>=2.24.0,<3.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "uvicorn[standard] (>=0.12.0,<0.19.0)"] 282 | dev = ["autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<6.0.0)", "pre-commit (>=2.17.0,<3.0.0)", "uvicorn[standard] (>=0.12.0,<0.19.0)"] 283 | doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer (>=0.4.1,<0.7.0)"] 284 | test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==22.8.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flake8 (>=3.8.3,<6.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.971)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "pyyaml (>=5.3.1,<7.0.0)", "requests (>=2.24.0,<3.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "types-orjson (==3.6.2)", "types-ujson (==5.4.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"] 285 | 286 | [[package]] 287 | name = "filelock" 288 | version = "3.8.0" 289 | description = "A platform independent file lock." 290 | category = "dev" 291 | optional = false 292 | python-versions = ">=3.7" 293 | 294 | [package.extras] 295 | testing = ["pytest-timeout (>=2.1)", "pytest-cov (>=3)", "pytest (>=7.1.2)", "coverage (>=6.4.2)", "covdefaults (>=2.2)"] 296 | docs = ["sphinx-autodoc-typehints (>=1.19.1)", "sphinx (>=5.1.1)", "furo (>=2022.6.21)"] 297 | 298 | [[package]] 299 | name = "flake8" 300 | version = "3.9.2" 301 | description = "the modular source code checker: pep8 pyflakes and co" 302 | category = "dev" 303 | optional = false 304 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 305 | 306 | [package.dependencies] 307 | mccabe = ">=0.6.0,<0.7.0" 308 | pycodestyle = ">=2.7.0,<2.8.0" 309 | pyflakes = ">=2.3.0,<2.4.0" 310 | 311 | [[package]] 312 | name = "frozenlist" 313 | version = "1.3.1" 314 | description = "A list-like structure which implements collections.abc.MutableSequence" 315 | category = "main" 316 | optional = false 317 | python-versions = ">=3.7" 318 | 319 | [[package]] 320 | name = "ghp-import" 321 | version = "2.1.0" 322 | description = "Copy your docs directly to the gh-pages branch." 323 | category = "dev" 324 | optional = false 325 | python-versions = "*" 326 | 327 | [package.dependencies] 328 | python-dateutil = ">=2.8.1" 329 | 330 | [package.extras] 331 | dev = ["wheel", "flake8", "markdown", "twine"] 332 | 333 | [[package]] 334 | name = "h11" 335 | version = "0.12.0" 336 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 337 | category = "main" 338 | optional = false 339 | python-versions = ">=3.6" 340 | 341 | [[package]] 342 | name = "httpcore" 343 | version = "0.15.0" 344 | description = "A minimal low-level HTTP client." 345 | category = "dev" 346 | optional = false 347 | python-versions = ">=3.7" 348 | 349 | [package.dependencies] 350 | anyio = ">=3.0.0,<4.0.0" 351 | certifi = "*" 352 | h11 = ">=0.11,<0.13" 353 | sniffio = ">=1.0.0,<2.0.0" 354 | 355 | [package.extras] 356 | http2 = ["h2 (>=3,<5)"] 357 | socks = ["socksio (>=1.0.0,<2.0.0)"] 358 | 359 | [[package]] 360 | name = "httpx" 361 | version = "0.23.0" 362 | description = "The next generation HTTP client." 363 | category = "dev" 364 | optional = false 365 | python-versions = ">=3.7" 366 | 367 | [package.dependencies] 368 | certifi = "*" 369 | httpcore = ">=0.15.0,<0.16.0" 370 | rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} 371 | sniffio = "*" 372 | 373 | [package.extras] 374 | brotli = ["brotlicffi", "brotli"] 375 | cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10,<13)", "pygments (>=2.0.0,<3.0.0)"] 376 | http2 = ["h2 (>=3,<5)"] 377 | socks = ["socksio (>=1.0.0,<2.0.0)"] 378 | 379 | [[package]] 380 | name = "identify" 381 | version = "2.5.5" 382 | description = "File identification library for Python" 383 | category = "dev" 384 | optional = false 385 | python-versions = ">=3.7" 386 | 387 | [package.extras] 388 | license = ["ukkonen"] 389 | 390 | [[package]] 391 | name = "idna" 392 | version = "3.4" 393 | description = "Internationalized Domain Names in Applications (IDNA)" 394 | category = "main" 395 | optional = false 396 | python-versions = ">=3.5" 397 | 398 | [[package]] 399 | name = "importlib-metadata" 400 | version = "4.12.0" 401 | description = "Read metadata from Python packages" 402 | category = "dev" 403 | optional = false 404 | python-versions = ">=3.7" 405 | 406 | [package.dependencies] 407 | zipp = ">=0.5" 408 | 409 | [package.extras] 410 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] 411 | perf = ["ipython"] 412 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] 413 | 414 | [[package]] 415 | name = "iniconfig" 416 | version = "1.1.1" 417 | description = "iniconfig: brain-dead simple config-ini parsing" 418 | category = "dev" 419 | optional = false 420 | python-versions = "*" 421 | 422 | [[package]] 423 | name = "ipdb" 424 | version = "0.13.9" 425 | description = "IPython-enabled pdb" 426 | category = "dev" 427 | optional = false 428 | python-versions = ">=2.7" 429 | 430 | [package.dependencies] 431 | decorator = {version = "*", markers = "python_version > \"3.6\""} 432 | ipython = {version = ">=7.17.0", markers = "python_version > \"3.6\""} 433 | toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""} 434 | 435 | [[package]] 436 | name = "ipython" 437 | version = "7.34.0" 438 | description = "IPython: Productive Interactive Computing" 439 | category = "dev" 440 | optional = false 441 | python-versions = ">=3.7" 442 | 443 | [package.dependencies] 444 | appnope = {version = "*", markers = "sys_platform == \"darwin\""} 445 | backcall = "*" 446 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 447 | decorator = "*" 448 | jedi = ">=0.16" 449 | matplotlib-inline = "*" 450 | pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} 451 | pickleshare = "*" 452 | prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0" 453 | pygments = "*" 454 | traitlets = ">=4.2" 455 | 456 | [package.extras] 457 | all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.17)", "pygments", "qtconsole", "requests", "testpath"] 458 | doc = ["Sphinx (>=1.3)"] 459 | kernel = ["ipykernel"] 460 | nbconvert = ["nbconvert"] 461 | nbformat = ["nbformat"] 462 | notebook = ["notebook", "ipywidgets"] 463 | parallel = ["ipyparallel"] 464 | qtconsole = ["qtconsole"] 465 | test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.17)"] 466 | 467 | [[package]] 468 | name = "isort" 469 | version = "5.10.1" 470 | description = "A Python utility / library to sort Python imports." 471 | category = "dev" 472 | optional = false 473 | python-versions = ">=3.6.1,<4.0" 474 | 475 | [package.extras] 476 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 477 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 478 | colors = ["colorama (>=0.4.3,<0.5.0)"] 479 | plugins = ["setuptools"] 480 | 481 | [[package]] 482 | name = "jedi" 483 | version = "0.18.1" 484 | description = "An autocompletion tool for Python that can be used for text editors." 485 | category = "dev" 486 | optional = false 487 | python-versions = ">=3.6" 488 | 489 | [package.dependencies] 490 | parso = ">=0.8.0,<0.9.0" 491 | 492 | [package.extras] 493 | qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] 494 | testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<7.0.0)"] 495 | 496 | [[package]] 497 | name = "jinja2" 498 | version = "3.1.2" 499 | description = "A very fast and expressive template engine." 500 | category = "main" 501 | optional = false 502 | python-versions = ">=3.7" 503 | 504 | [package.dependencies] 505 | MarkupSafe = ">=2.0" 506 | 507 | [package.extras] 508 | i18n = ["Babel (>=2.7)"] 509 | 510 | [[package]] 511 | name = "jmespath" 512 | version = "1.0.1" 513 | description = "JSON Matching Expressions" 514 | category = "main" 515 | optional = true 516 | python-versions = ">=3.7" 517 | 518 | [[package]] 519 | name = "jwt" 520 | version = "1.3.1" 521 | description = "JSON Web Token library for Python 3." 522 | category = "main" 523 | optional = false 524 | python-versions = ">= 3.6" 525 | 526 | [package.dependencies] 527 | cryptography = ">=3.1,<3.4.0 || >3.4.0" 528 | 529 | [[package]] 530 | name = "markdown" 531 | version = "3.4.1" 532 | description = "Python implementation of Markdown." 533 | category = "dev" 534 | optional = false 535 | python-versions = ">=3.7" 536 | 537 | [package.dependencies] 538 | importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} 539 | 540 | [package.extras] 541 | testing = ["coverage", "pyyaml"] 542 | 543 | [[package]] 544 | name = "markupsafe" 545 | version = "2.1.1" 546 | description = "Safely add untrusted strings to HTML/XML markup." 547 | category = "main" 548 | optional = false 549 | python-versions = ">=3.7" 550 | 551 | [[package]] 552 | name = "matplotlib-inline" 553 | version = "0.1.6" 554 | description = "Inline Matplotlib backend for Jupyter" 555 | category = "dev" 556 | optional = false 557 | python-versions = ">=3.5" 558 | 559 | [package.dependencies] 560 | traitlets = "*" 561 | 562 | [[package]] 563 | name = "mccabe" 564 | version = "0.6.1" 565 | description = "McCabe checker, plugin for flake8" 566 | category = "dev" 567 | optional = false 568 | python-versions = "*" 569 | 570 | [[package]] 571 | name = "mdx-include" 572 | version = "1.4.2" 573 | description = "Python Markdown extension to include local or remote files" 574 | category = "dev" 575 | optional = false 576 | python-versions = "*" 577 | 578 | [package.dependencies] 579 | cyclic = "*" 580 | Markdown = ">=2.6" 581 | rcslice = ">=1.1.0" 582 | 583 | [[package]] 584 | name = "mergedeep" 585 | version = "1.3.4" 586 | description = "A deep merge function for 🐍." 587 | category = "dev" 588 | optional = false 589 | python-versions = ">=3.6" 590 | 591 | [[package]] 592 | name = "mkdocs" 593 | version = "1.3.0" 594 | description = "Project documentation with Markdown." 595 | category = "dev" 596 | optional = false 597 | python-versions = ">=3.6" 598 | 599 | [package.dependencies] 600 | click = ">=3.3" 601 | ghp-import = ">=1.0" 602 | importlib-metadata = ">=4.3" 603 | Jinja2 = ">=2.10.2" 604 | Markdown = ">=3.2.1" 605 | mergedeep = ">=1.3.4" 606 | packaging = ">=20.5" 607 | PyYAML = ">=3.10" 608 | pyyaml-env-tag = ">=0.1" 609 | watchdog = ">=2.0" 610 | 611 | [package.extras] 612 | i18n = ["babel (>=2.9.0)"] 613 | 614 | [[package]] 615 | name = "mkdocs-material" 616 | version = "8.5.2" 617 | description = "Documentation that simply works" 618 | category = "dev" 619 | optional = false 620 | python-versions = ">=3.7" 621 | 622 | [package.dependencies] 623 | jinja2 = ">=3.0.2" 624 | markdown = ">=3.2" 625 | mkdocs = ">=1.3.0" 626 | mkdocs-material-extensions = ">=1.0.3" 627 | pygments = ">=2.12" 628 | pymdown-extensions = ">=9.4" 629 | requests = ">=2.26" 630 | 631 | [[package]] 632 | name = "mkdocs-material-extensions" 633 | version = "1.0.3" 634 | description = "Extension pack for Python Markdown." 635 | category = "dev" 636 | optional = false 637 | python-versions = ">=3.6" 638 | 639 | [[package]] 640 | name = "multidict" 641 | version = "6.0.2" 642 | description = "multidict implementation" 643 | category = "main" 644 | optional = false 645 | python-versions = ">=3.7" 646 | 647 | [[package]] 648 | name = "mypy-extensions" 649 | version = "0.4.3" 650 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 651 | category = "dev" 652 | optional = false 653 | python-versions = "*" 654 | 655 | [[package]] 656 | name = "nodeenv" 657 | version = "1.7.0" 658 | description = "Node.js virtual environment builder" 659 | category = "dev" 660 | optional = false 661 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 662 | 663 | [[package]] 664 | name = "packaging" 665 | version = "21.3" 666 | description = "Core utilities for Python packages" 667 | category = "dev" 668 | optional = false 669 | python-versions = ">=3.6" 670 | 671 | [package.dependencies] 672 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 673 | 674 | [[package]] 675 | name = "parso" 676 | version = "0.8.3" 677 | description = "A Python Parser" 678 | category = "dev" 679 | optional = false 680 | python-versions = ">=3.6" 681 | 682 | [package.extras] 683 | qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] 684 | testing = ["docopt", "pytest (<6.0.0)"] 685 | 686 | [[package]] 687 | name = "pathspec" 688 | version = "0.10.1" 689 | description = "Utility library for gitignore style pattern matching of file paths." 690 | category = "dev" 691 | optional = false 692 | python-versions = ">=3.7" 693 | 694 | [[package]] 695 | name = "pexpect" 696 | version = "4.8.0" 697 | description = "Pexpect allows easy control of interactive console applications." 698 | category = "dev" 699 | optional = false 700 | python-versions = "*" 701 | 702 | [package.dependencies] 703 | ptyprocess = ">=0.5" 704 | 705 | [[package]] 706 | name = "pickleshare" 707 | version = "0.7.5" 708 | description = "Tiny 'shelve'-like database with concurrency support" 709 | category = "dev" 710 | optional = false 711 | python-versions = "*" 712 | 713 | [[package]] 714 | name = "platformdirs" 715 | version = "2.5.2" 716 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 717 | category = "dev" 718 | optional = false 719 | python-versions = ">=3.7" 720 | 721 | [package.extras] 722 | docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] 723 | test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] 724 | 725 | [[package]] 726 | name = "pluggy" 727 | version = "1.0.0" 728 | description = "plugin and hook calling mechanisms for python" 729 | category = "dev" 730 | optional = false 731 | python-versions = ">=3.6" 732 | 733 | [package.extras] 734 | dev = ["pre-commit", "tox"] 735 | testing = ["pytest", "pytest-benchmark"] 736 | 737 | [[package]] 738 | name = "pre-commit" 739 | version = "2.20.0" 740 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 741 | category = "dev" 742 | optional = false 743 | python-versions = ">=3.7" 744 | 745 | [package.dependencies] 746 | cfgv = ">=2.0.0" 747 | identify = ">=1.0.0" 748 | nodeenv = ">=0.11.1" 749 | pyyaml = ">=5.1" 750 | toml = "*" 751 | virtualenv = ">=20.0.8" 752 | 753 | [[package]] 754 | name = "prompt-toolkit" 755 | version = "3.0.31" 756 | description = "Library for building powerful interactive command lines in Python" 757 | category = "dev" 758 | optional = false 759 | python-versions = ">=3.6.2" 760 | 761 | [package.dependencies] 762 | wcwidth = "*" 763 | 764 | [[package]] 765 | name = "ptyprocess" 766 | version = "0.7.0" 767 | description = "Run a subprocess in a pseudo terminal" 768 | category = "dev" 769 | optional = false 770 | python-versions = "*" 771 | 772 | [[package]] 773 | name = "py" 774 | version = "1.11.0" 775 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 776 | category = "dev" 777 | optional = false 778 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 779 | 780 | [[package]] 781 | name = "pycodestyle" 782 | version = "2.7.0" 783 | description = "Python style guide checker" 784 | category = "dev" 785 | optional = false 786 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 787 | 788 | [[package]] 789 | name = "pycparser" 790 | version = "2.21" 791 | description = "C parser in Python" 792 | category = "main" 793 | optional = false 794 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 795 | 796 | [[package]] 797 | name = "pydantic" 798 | version = "1.10.2" 799 | description = "Data validation and settings management using python type hints" 800 | category = "main" 801 | optional = false 802 | python-versions = ">=3.7" 803 | 804 | [package.dependencies] 805 | typing-extensions = ">=4.1.0" 806 | 807 | [package.extras] 808 | dotenv = ["python-dotenv (>=0.10.4)"] 809 | email = ["email-validator (>=1.0.3)"] 810 | 811 | [[package]] 812 | name = "pyflakes" 813 | version = "2.3.1" 814 | description = "passive checker of Python programs" 815 | category = "dev" 816 | optional = false 817 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 818 | 819 | [[package]] 820 | name = "pygments" 821 | version = "2.13.0" 822 | description = "Pygments is a syntax highlighting package written in Python." 823 | category = "dev" 824 | optional = false 825 | python-versions = ">=3.6" 826 | 827 | [package.extras] 828 | plugins = ["importlib-metadata"] 829 | 830 | [[package]] 831 | name = "pymdown-extensions" 832 | version = "9.5" 833 | description = "Extension pack for Python Markdown." 834 | category = "dev" 835 | optional = false 836 | python-versions = ">=3.7" 837 | 838 | [package.dependencies] 839 | markdown = ">=3.2" 840 | 841 | [[package]] 842 | name = "pyparsing" 843 | version = "3.0.9" 844 | description = "pyparsing module - Classes and methods to define and execute parsing grammars" 845 | category = "dev" 846 | optional = false 847 | python-versions = ">=3.6.8" 848 | 849 | [package.extras] 850 | diagrams = ["railroad-diagrams", "jinja2"] 851 | 852 | [[package]] 853 | name = "pytest" 854 | version = "6.2.5" 855 | description = "pytest: simple powerful testing with Python" 856 | category = "dev" 857 | optional = false 858 | python-versions = ">=3.6" 859 | 860 | [package.dependencies] 861 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 862 | attrs = ">=19.2.0" 863 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 864 | iniconfig = "*" 865 | packaging = "*" 866 | pluggy = ">=0.12,<2.0" 867 | py = ">=1.8.2" 868 | toml = "*" 869 | 870 | [package.extras] 871 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 872 | 873 | [[package]] 874 | name = "pytest-asyncio" 875 | version = "0.19.0" 876 | description = "Pytest support for asyncio" 877 | category = "dev" 878 | optional = false 879 | python-versions = ">=3.7" 880 | 881 | [package.dependencies] 882 | pytest = ">=6.1.0" 883 | 884 | [package.extras] 885 | testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] 886 | 887 | [[package]] 888 | name = "pytest-cov" 889 | version = "2.12.1" 890 | description = "Pytest plugin for measuring coverage." 891 | category = "dev" 892 | optional = false 893 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 894 | 895 | [package.dependencies] 896 | coverage = ">=5.2.1" 897 | pytest = ">=4.6" 898 | toml = "*" 899 | 900 | [package.extras] 901 | testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] 902 | 903 | [[package]] 904 | name = "pytest-mock" 905 | version = "3.8.2" 906 | description = "Thin-wrapper around the mock package for easier use with pytest" 907 | category = "dev" 908 | optional = false 909 | python-versions = ">=3.7" 910 | 911 | [package.dependencies] 912 | pytest = ">=5.0" 913 | 914 | [package.extras] 915 | dev = ["pre-commit", "tox", "pytest-asyncio"] 916 | 917 | [[package]] 918 | name = "pytest-sugar" 919 | version = "0.9.5" 920 | description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." 921 | category = "dev" 922 | optional = false 923 | python-versions = "*" 924 | 925 | [package.dependencies] 926 | packaging = ">=14.1" 927 | pytest = ">=2.9" 928 | termcolor = ">=1.1.0" 929 | 930 | [[package]] 931 | name = "python-dateutil" 932 | version = "2.8.2" 933 | description = "Extensions to the standard Python datetime module" 934 | category = "main" 935 | optional = false 936 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 937 | 938 | [package.dependencies] 939 | six = ">=1.5" 940 | 941 | [[package]] 942 | name = "pyyaml" 943 | version = "6.0" 944 | description = "YAML parser and emitter for Python" 945 | category = "dev" 946 | optional = false 947 | python-versions = ">=3.6" 948 | 949 | [[package]] 950 | name = "pyyaml-env-tag" 951 | version = "0.1" 952 | description = "A custom YAML tag for referencing environment variables in YAML files. " 953 | category = "dev" 954 | optional = false 955 | python-versions = ">=3.6" 956 | 957 | [package.dependencies] 958 | pyyaml = "*" 959 | 960 | [[package]] 961 | name = "rcslice" 962 | version = "1.1.0" 963 | description = "Slice a list of sliceables (1 indexed, start and end index both are inclusive)" 964 | category = "dev" 965 | optional = false 966 | python-versions = "*" 967 | 968 | [[package]] 969 | name = "requests" 970 | version = "2.28.1" 971 | description = "Python HTTP for Humans." 972 | category = "dev" 973 | optional = false 974 | python-versions = ">=3.7, <4" 975 | 976 | [package.dependencies] 977 | certifi = ">=2017.4.17" 978 | charset-normalizer = ">=2,<3" 979 | idna = ">=2.5,<4" 980 | urllib3 = ">=1.21.1,<1.27" 981 | 982 | [package.extras] 983 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 984 | use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] 985 | 986 | [[package]] 987 | name = "rfc3986" 988 | version = "1.5.0" 989 | description = "Validating URI References per RFC 3986" 990 | category = "dev" 991 | optional = false 992 | python-versions = "*" 993 | 994 | [package.dependencies] 995 | idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} 996 | 997 | [package.extras] 998 | idna2008 = ["idna"] 999 | 1000 | [[package]] 1001 | name = "s3transfer" 1002 | version = "0.6.0" 1003 | description = "An Amazon S3 Transfer Manager" 1004 | category = "main" 1005 | optional = true 1006 | python-versions = ">= 3.7" 1007 | 1008 | [package.dependencies] 1009 | botocore = ">=1.12.36,<2.0a.0" 1010 | 1011 | [package.extras] 1012 | crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] 1013 | 1014 | [[package]] 1015 | name = "six" 1016 | version = "1.16.0" 1017 | description = "Python 2 and 3 compatibility utilities" 1018 | category = "main" 1019 | optional = false 1020 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 1021 | 1022 | [[package]] 1023 | name = "sniffio" 1024 | version = "1.3.0" 1025 | description = "Sniff out which async library your code is running under" 1026 | category = "main" 1027 | optional = false 1028 | python-versions = ">=3.7" 1029 | 1030 | [[package]] 1031 | name = "starlette" 1032 | version = "0.20.4" 1033 | description = "The little ASGI library that shines." 1034 | category = "main" 1035 | optional = false 1036 | python-versions = ">=3.7" 1037 | 1038 | [package.dependencies] 1039 | anyio = ">=3.4.0,<5" 1040 | typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} 1041 | 1042 | [package.extras] 1043 | full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] 1044 | 1045 | [[package]] 1046 | name = "termcolor" 1047 | version = "2.0.1" 1048 | description = "ANSI color formatting for output in terminal" 1049 | category = "dev" 1050 | optional = false 1051 | python-versions = ">=3.7" 1052 | 1053 | [package.extras] 1054 | tests = ["pytest-cov", "pytest"] 1055 | 1056 | [[package]] 1057 | name = "toml" 1058 | version = "0.10.2" 1059 | description = "Python Library for Tom's Obvious, Minimal Language" 1060 | category = "dev" 1061 | optional = false 1062 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 1063 | 1064 | [[package]] 1065 | name = "tomli" 1066 | version = "2.0.1" 1067 | description = "A lil' TOML parser" 1068 | category = "dev" 1069 | optional = false 1070 | python-versions = ">=3.7" 1071 | 1072 | [[package]] 1073 | name = "tox" 1074 | version = "3.26.0" 1075 | description = "tox is a generic virtualenv management and test command line tool" 1076 | category = "dev" 1077 | optional = false 1078 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 1079 | 1080 | [package.dependencies] 1081 | colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} 1082 | filelock = ">=3.0.0" 1083 | packaging = ">=14" 1084 | pluggy = ">=0.12.0" 1085 | py = ">=1.4.17" 1086 | six = ">=1.14.0" 1087 | tomli = {version = ">=2.0.1", markers = "python_version >= \"3.7\" and python_version < \"3.11\""} 1088 | virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" 1089 | 1090 | [package.extras] 1091 | docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] 1092 | testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "psutil (>=5.6.1)", "pathlib2 (>=2.3.3)"] 1093 | 1094 | [[package]] 1095 | name = "tox-poetry" 1096 | version = "0.4.1" 1097 | description = "Tox poetry plugin" 1098 | category = "dev" 1099 | optional = false 1100 | python-versions = "*" 1101 | 1102 | [package.dependencies] 1103 | pluggy = "*" 1104 | toml = "*" 1105 | tox = {version = ">=3.7.0", markers = "python_version >= \"3\""} 1106 | 1107 | [package.extras] 1108 | test = ["pylint", "pycodestyle", "pytest", "coverage"] 1109 | 1110 | [[package]] 1111 | name = "traitlets" 1112 | version = "5.4.0" 1113 | description = "" 1114 | category = "dev" 1115 | optional = false 1116 | python-versions = ">=3.7" 1117 | 1118 | [package.extras] 1119 | test = ["pre-commit", "pytest"] 1120 | 1121 | [[package]] 1122 | name = "typing-extensions" 1123 | version = "4.3.0" 1124 | description = "Backported and Experimental Type Hints for Python 3.7+" 1125 | category = "main" 1126 | optional = false 1127 | python-versions = ">=3.7" 1128 | 1129 | [[package]] 1130 | name = "urllib3" 1131 | version = "1.26.12" 1132 | description = "HTTP library with thread-safe connection pooling, file post, and more." 1133 | category = "main" 1134 | optional = false 1135 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" 1136 | 1137 | [package.extras] 1138 | brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] 1139 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] 1140 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 1141 | 1142 | [[package]] 1143 | name = "uvicorn" 1144 | version = "0.18.3" 1145 | description = "The lightning-fast ASGI server." 1146 | category = "main" 1147 | optional = false 1148 | python-versions = ">=3.7" 1149 | 1150 | [package.dependencies] 1151 | click = ">=7.0" 1152 | h11 = ">=0.8" 1153 | 1154 | [package.extras] 1155 | standard = ["colorama (>=0.4)", "httptools (>=0.4.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.0)"] 1156 | 1157 | [[package]] 1158 | name = "virtualenv" 1159 | version = "20.16.5" 1160 | description = "Virtual Python Environment builder" 1161 | category = "dev" 1162 | optional = false 1163 | python-versions = ">=3.6" 1164 | 1165 | [package.dependencies] 1166 | distlib = ">=0.3.5,<1" 1167 | filelock = ">=3.4.1,<4" 1168 | platformdirs = ">=2.4,<3" 1169 | 1170 | [package.extras] 1171 | docs = ["proselint (>=0.13)", "sphinx (>=5.1.1)", "sphinx-argparse (>=0.3.1)", "sphinx-rtd-theme (>=1)", "towncrier (>=21.9)"] 1172 | 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)"] 1173 | 1174 | [[package]] 1175 | name = "watchdog" 1176 | version = "2.1.9" 1177 | description = "Filesystem events monitoring" 1178 | category = "dev" 1179 | optional = false 1180 | python-versions = ">=3.6" 1181 | 1182 | [package.extras] 1183 | watchmedo = ["PyYAML (>=3.10)"] 1184 | 1185 | [[package]] 1186 | name = "wcwidth" 1187 | version = "0.2.5" 1188 | description = "Measures the displayed width of unicode strings in a terminal" 1189 | category = "dev" 1190 | optional = false 1191 | python-versions = "*" 1192 | 1193 | [[package]] 1194 | name = "yarl" 1195 | version = "1.8.1" 1196 | description = "Yet another URL library" 1197 | category = "main" 1198 | optional = false 1199 | python-versions = ">=3.7" 1200 | 1201 | [package.dependencies] 1202 | idna = ">=2.0" 1203 | multidict = ">=4.0" 1204 | 1205 | [[package]] 1206 | name = "zipp" 1207 | version = "3.8.1" 1208 | description = "Backport of pathlib-compatible object wrapper for zip files" 1209 | category = "dev" 1210 | optional = false 1211 | python-versions = ">=3.7" 1212 | 1213 | [package.extras] 1214 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] 1215 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] 1216 | 1217 | [extras] 1218 | sqs = ["boto3"] 1219 | 1220 | [metadata] 1221 | lock-version = "1.1" 1222 | python-versions = "^3.8" 1223 | content-hash = "500688e3c50a62b7559fe6b83211d8a6a020bb6ccc94c62594a167d6382c274c" 1224 | 1225 | [metadata.files] 1226 | aiofiles = [ 1227 | {file = "aiofiles-0.8.0-py3-none-any.whl", hash = "sha256:7a973fc22b29e9962d0897805ace5856e6a566ab1f0c8e5c91ff6c866519c937"}, 1228 | {file = "aiofiles-0.8.0.tar.gz", hash = "sha256:8334f23235248a3b2e83b2c3a78a22674f39969b96397126cc93664d9a901e59"}, 1229 | ] 1230 | aiohttp = [ 1231 | {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8"}, 1232 | {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8"}, 1233 | {file = "aiohttp-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a79004bb58748f31ae1cbe9fa891054baaa46fb106c2dc7af9f8e3304dc30316"}, 1234 | {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12de6add4038df8f72fac606dff775791a60f113a725c960f2bab01d8b8e6b15"}, 1235 | {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f0d5f33feb5f69ddd57a4a4bd3d56c719a141080b445cbf18f238973c5c9923"}, 1236 | {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eaba923151d9deea315be1f3e2b31cc39a6d1d2f682f942905951f4e40200922"}, 1237 | {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:099ebd2c37ac74cce10a3527d2b49af80243e2a4fa39e7bce41617fbc35fa3c1"}, 1238 | {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2e5d962cf7e1d426aa0e528a7e198658cdc8aa4fe87f781d039ad75dcd52c516"}, 1239 | {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fa0ffcace9b3aa34d205d8130f7873fcfefcb6a4dd3dd705b0dab69af6712642"}, 1240 | {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61bfc23df345d8c9716d03717c2ed5e27374e0fe6f659ea64edcd27b4b044cf7"}, 1241 | {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:31560d268ff62143e92423ef183680b9829b1b482c011713ae941997921eebc8"}, 1242 | {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3"}, 1243 | {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97ef77eb6b044134c0b3a96e16abcb05ecce892965a2124c566af0fd60f717e2"}, 1244 | {file = "aiohttp-3.8.1-cp310-cp310-win32.whl", hash = "sha256:c2aef4703f1f2ddc6df17519885dbfa3514929149d3ff900b73f45998f2532fa"}, 1245 | {file = "aiohttp-3.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:713ac174a629d39b7c6a3aa757b337599798da4c1157114a314e4e391cd28e32"}, 1246 | {file = "aiohttp-3.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:473d93d4450880fe278696549f2e7aed8cd23708c3c1997981464475f32137db"}, 1247 | {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b5eeae8e019e7aad8af8bb314fb908dd2e028b3cdaad87ec05095394cce632"}, 1248 | {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af642b43ce56c24d063325dd2cf20ee012d2b9ba4c3c008755a301aaea720ad"}, 1249 | {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3630c3ef435c0a7c549ba170a0633a56e92629aeed0e707fec832dee313fb7a"}, 1250 | {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4a4a4e30bf1edcad13fb0804300557aedd07a92cabc74382fdd0ba6ca2661091"}, 1251 | {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6f8b01295e26c68b3a1b90efb7a89029110d3a4139270b24fda961893216c440"}, 1252 | {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a25fa703a527158aaf10dafd956f7d42ac6d30ec80e9a70846253dd13e2f067b"}, 1253 | {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5bfde62d1d2641a1f5173b8c8c2d96ceb4854f54a44c23102e2ccc7e02f003ec"}, 1254 | {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:51467000f3647d519272392f484126aa716f747859794ac9924a7aafa86cd411"}, 1255 | {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:03a6d5349c9ee8f79ab3ff3694d6ce1cfc3ced1c9d36200cb8f08ba06bd3b782"}, 1256 | {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:102e487eeb82afac440581e5d7f8f44560b36cf0bdd11abc51a46c1cd88914d4"}, 1257 | {file = "aiohttp-3.8.1-cp36-cp36m-win32.whl", hash = "sha256:4aed991a28ea3ce320dc8ce655875e1e00a11bdd29fe9444dd4f88c30d558602"}, 1258 | {file = "aiohttp-3.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b0e20cddbd676ab8a64c774fefa0ad787cc506afd844de95da56060348021e96"}, 1259 | {file = "aiohttp-3.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:37951ad2f4a6df6506750a23f7cbabad24c73c65f23f72e95897bb2cecbae676"}, 1260 | {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c23b1ad869653bc818e972b7a3a79852d0e494e9ab7e1a701a3decc49c20d51"}, 1261 | {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15b09b06dae900777833fe7fc4b4aa426556ce95847a3e8d7548e2d19e34edb8"}, 1262 | {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:477c3ea0ba410b2b56b7efb072c36fa91b1e6fc331761798fa3f28bb224830dd"}, 1263 | {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2f2f69dca064926e79997f45b2f34e202b320fd3782f17a91941f7eb85502ee2"}, 1264 | {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef9612483cb35171d51d9173647eed5d0069eaa2ee812793a75373447d487aa4"}, 1265 | {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6d69f36d445c45cda7b3b26afef2fc34ef5ac0cdc75584a87ef307ee3c8c6d00"}, 1266 | {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:55c3d1072704d27401c92339144d199d9de7b52627f724a949fc7d5fc56d8b93"}, 1267 | {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b9d00268fcb9f66fbcc7cd9fe423741d90c75ee029a1d15c09b22d23253c0a44"}, 1268 | {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:07b05cd3305e8a73112103c834e91cd27ce5b4bd07850c4b4dbd1877d3f45be7"}, 1269 | {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c34dc4958b232ef6188c4318cb7b2c2d80521c9a56c52449f8f93ab7bc2a8a1c"}, 1270 | {file = "aiohttp-3.8.1-cp37-cp37m-win32.whl", hash = "sha256:d2f9b69293c33aaa53d923032fe227feac867f81682f002ce33ffae978f0a9a9"}, 1271 | {file = "aiohttp-3.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6ae828d3a003f03ae31915c31fa684b9890ea44c9c989056fea96e3d12a9fa17"}, 1272 | {file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0c7ebbbde809ff4e970824b2b6cb7e4222be6b95a296e46c03cf050878fc1785"}, 1273 | {file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b7ef7cbd4fec9a1e811a5de813311ed4f7ac7d93e0fda233c9b3e1428f7dd7b"}, 1274 | {file = "aiohttp-3.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c3d6a4d0619e09dcd61021debf7059955c2004fa29f48788a3dfaf9c9901a7cd"}, 1275 | {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:718626a174e7e467f0558954f94af117b7d4695d48eb980146016afa4b580b2e"}, 1276 | {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:589c72667a5febd36f1315aa6e5f56dd4aa4862df295cb51c769d16142ddd7cd"}, 1277 | {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ed076098b171573161eb146afcb9129b5ff63308960aeca4b676d9d3c35e700"}, 1278 | {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:086f92daf51a032d062ec5f58af5ca6a44d082c35299c96376a41cbb33034675"}, 1279 | {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:11691cf4dc5b94236ccc609b70fec991234e7ef8d4c02dd0c9668d1e486f5abf"}, 1280 | {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:31d1e1c0dbf19ebccbfd62eff461518dcb1e307b195e93bba60c965a4dcf1ba0"}, 1281 | {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:11a67c0d562e07067c4e86bffc1553f2cf5b664d6111c894671b2b8712f3aba5"}, 1282 | {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:bb01ba6b0d3f6c68b89fce7305080145d4877ad3acaed424bae4d4ee75faa950"}, 1283 | {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:44db35a9e15d6fe5c40d74952e803b1d96e964f683b5a78c3cc64eb177878155"}, 1284 | {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:844a9b460871ee0a0b0b68a64890dae9c415e513db0f4a7e3cab41a0f2fedf33"}, 1285 | {file = "aiohttp-3.8.1-cp38-cp38-win32.whl", hash = "sha256:7d08744e9bae2ca9c382581f7dce1273fe3c9bae94ff572c3626e8da5b193c6a"}, 1286 | {file = "aiohttp-3.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:04d48b8ce6ab3cf2097b1855e1505181bdd05586ca275f2505514a6e274e8e75"}, 1287 | {file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5315a2eb0239185af1bddb1abf472d877fede3cc8d143c6cddad37678293237"}, 1288 | {file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a996d01ca39b8dfe77440f3cd600825d05841088fd6bc0144cc6c2ec14cc5f74"}, 1289 | {file = "aiohttp-3.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13487abd2f761d4be7c8ff9080de2671e53fff69711d46de703c310c4c9317ca"}, 1290 | {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea302f34477fda3f85560a06d9ebdc7fa41e82420e892fc50b577e35fc6a50b2"}, 1291 | {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2f635ce61a89c5732537a7896b6319a8fcfa23ba09bec36e1b1ac0ab31270d2"}, 1292 | {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e999f2d0e12eea01caeecb17b653f3713d758f6dcc770417cf29ef08d3931421"}, 1293 | {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0770e2806a30e744b4e21c9d73b7bee18a1cfa3c47991ee2e5a65b887c49d5cf"}, 1294 | {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d15367ce87c8e9e09b0f989bfd72dc641bcd04ba091c68cd305312d00962addd"}, 1295 | {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c7cefb4b0640703eb1069835c02486669312bf2f12b48a748e0a7756d0de33d"}, 1296 | {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:71927042ed6365a09a98a6377501af5c9f0a4d38083652bcd2281a06a5976724"}, 1297 | {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:28d490af82bc6b7ce53ff31337a18a10498303fe66f701ab65ef27e143c3b0ef"}, 1298 | {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b6613280ccedf24354406caf785db748bebbddcf31408b20c0b48cb86af76866"}, 1299 | {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81e3d8c34c623ca4e36c46524a3530e99c0bc95ed068fd6e9b55cb721d408fb2"}, 1300 | {file = "aiohttp-3.8.1-cp39-cp39-win32.whl", hash = "sha256:7187a76598bdb895af0adbd2fb7474d7f6025d170bc0a1130242da817ce9e7d1"}, 1301 | {file = "aiohttp-3.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:1c182cb873bc91b411e184dab7a2b664d4fea2743df0e4d57402f7f3fa644bac"}, 1302 | {file = "aiohttp-3.8.1.tar.gz", hash = "sha256:fc5471e1a54de15ef71c1bc6ebe80d4dc681ea600e68bfd1cbce40427f0b7578"}, 1303 | ] 1304 | aiosignal = [ 1305 | {file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"}, 1306 | {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"}, 1307 | ] 1308 | anyio = [ 1309 | {file = "anyio-3.6.1-py3-none-any.whl", hash = "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be"}, 1310 | {file = "anyio-3.6.1.tar.gz", hash = "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b"}, 1311 | ] 1312 | appnope = [ 1313 | {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"}, 1314 | {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, 1315 | ] 1316 | async-timeout = [ 1317 | {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, 1318 | {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, 1319 | ] 1320 | atomicwrites = [] 1321 | attrs = [] 1322 | backcall = [ 1323 | {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, 1324 | {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, 1325 | ] 1326 | black = [] 1327 | boto3 = [] 1328 | botocore = [] 1329 | certifi = [] 1330 | cffi = [] 1331 | cfgv = [] 1332 | charset-normalizer = [] 1333 | click = [ 1334 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 1335 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 1336 | ] 1337 | colorama = [ 1338 | {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, 1339 | {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, 1340 | ] 1341 | coverage = [] 1342 | cryptography = [] 1343 | cyclic = [] 1344 | decorator = [ 1345 | {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, 1346 | {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, 1347 | ] 1348 | distlib = [] 1349 | fastapi = [] 1350 | filelock = [] 1351 | flake8 = [] 1352 | frozenlist = [] 1353 | ghp-import = [] 1354 | h11 = [ 1355 | {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, 1356 | {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, 1357 | ] 1358 | httpcore = [ 1359 | {file = "httpcore-0.15.0-py3-none-any.whl", hash = "sha256:1105b8b73c025f23ff7c36468e4432226cbb959176eab66864b8e31c4ee27fa6"}, 1360 | {file = "httpcore-0.15.0.tar.gz", hash = "sha256:18b68ab86a3ccf3e7dc0f43598eaddcf472b602aba29f9aa6ab85fe2ada3980b"}, 1361 | ] 1362 | httpx = [ 1363 | {file = "httpx-0.23.0-py3-none-any.whl", hash = "sha256:42974f577483e1e932c3cdc3cd2303e883cbfba17fe228b0f63589764d7b9c4b"}, 1364 | {file = "httpx-0.23.0.tar.gz", hash = "sha256:f28eac771ec9eb4866d3fb4ab65abd42d38c424739e80c08d8d20570de60b0ef"}, 1365 | ] 1366 | identify = [] 1367 | idna = [] 1368 | importlib-metadata = [] 1369 | iniconfig = [ 1370 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 1371 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 1372 | ] 1373 | ipdb = [ 1374 | {file = "ipdb-0.13.9.tar.gz", hash = "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5"}, 1375 | ] 1376 | ipython = [] 1377 | isort = [ 1378 | {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, 1379 | {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, 1380 | ] 1381 | jedi = [ 1382 | {file = "jedi-0.18.1-py2.py3-none-any.whl", hash = "sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d"}, 1383 | {file = "jedi-0.18.1.tar.gz", hash = "sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"}, 1384 | ] 1385 | jinja2 = [ 1386 | {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, 1387 | {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, 1388 | ] 1389 | jmespath = [] 1390 | jwt = [ 1391 | {file = "jwt-1.3.1-py3-none-any.whl", hash = "sha256:61c9170f92e736b530655e75374681d4fcca9cfa8763ab42be57353b2b203494"}, 1392 | ] 1393 | markdown = [] 1394 | markupsafe = [ 1395 | {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, 1396 | {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, 1397 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, 1398 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, 1399 | {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, 1400 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, 1401 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, 1402 | {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, 1403 | {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, 1404 | {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, 1405 | {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, 1406 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, 1407 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, 1408 | {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, 1409 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, 1410 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, 1411 | {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, 1412 | {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, 1413 | {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, 1414 | {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, 1415 | {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, 1416 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, 1417 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, 1418 | {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, 1419 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, 1420 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, 1421 | {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, 1422 | {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, 1423 | {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, 1424 | {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, 1425 | {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, 1426 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, 1427 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, 1428 | {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, 1429 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, 1430 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, 1431 | {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, 1432 | {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, 1433 | {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, 1434 | {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, 1435 | ] 1436 | matplotlib-inline = [] 1437 | mccabe = [ 1438 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 1439 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 1440 | ] 1441 | mdx-include = [] 1442 | mergedeep = [] 1443 | mkdocs = [ 1444 | {file = "mkdocs-1.3.0-py3-none-any.whl", hash = "sha256:26bd2b03d739ac57a3e6eed0b7bcc86168703b719c27b99ad6ca91dc439aacde"}, 1445 | {file = "mkdocs-1.3.0.tar.gz", hash = "sha256:b504405b04da38795fec9b2e5e28f6aa3a73bb0960cb6d5d27ead28952bd35ea"}, 1446 | ] 1447 | mkdocs-material = [] 1448 | mkdocs-material-extensions = [ 1449 | {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"}, 1450 | {file = "mkdocs_material_extensions-1.0.3-py3-none-any.whl", hash = "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44"}, 1451 | ] 1452 | multidict = [ 1453 | {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"}, 1454 | {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"}, 1455 | {file = "multidict-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:041b81a5f6b38244b34dc18c7b6aba91f9cdaf854d9a39e5ff0b58e2b5773b9c"}, 1456 | {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fdda29a3c7e76a064f2477c9aab1ba96fd94e02e386f1e665bca1807fc5386f"}, 1457 | {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3368bf2398b0e0fcbf46d85795adc4c259299fec50c1416d0f77c0a843a3eed9"}, 1458 | {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4f052ee022928d34fe1f4d2bc743f32609fb79ed9c49a1710a5ad6b2198db20"}, 1459 | {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225383a6603c086e6cef0f2f05564acb4f4d5f019a4e3e983f572b8530f70c88"}, 1460 | {file = "multidict-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50bd442726e288e884f7be9071016c15a8742eb689a593a0cac49ea093eef0a7"}, 1461 | {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:47e6a7e923e9cada7c139531feac59448f1f47727a79076c0b1ee80274cd8eee"}, 1462 | {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0556a1d4ea2d949efe5fd76a09b4a82e3a4a30700553a6725535098d8d9fb672"}, 1463 | {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:626fe10ac87851f4cffecee161fc6f8f9853f0f6f1035b59337a51d29ff3b4f9"}, 1464 | {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8064b7c6f0af936a741ea1efd18690bacfbae4078c0c385d7c3f611d11f0cf87"}, 1465 | {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2d36e929d7f6a16d4eb11b250719c39560dd70545356365b494249e2186bc389"}, 1466 | {file = "multidict-6.0.2-cp310-cp310-win32.whl", hash = "sha256:fcb91630817aa8b9bc4a74023e4198480587269c272c58b3279875ed7235c293"}, 1467 | {file = "multidict-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:8cbf0132f3de7cc6c6ce00147cc78e6439ea736cee6bca4f068bcf892b0fd658"}, 1468 | {file = "multidict-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:05f6949d6169878a03e607a21e3b862eaf8e356590e8bdae4227eedadacf6e51"}, 1469 | {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2c2e459f7050aeb7c1b1276763364884595d47000c1cddb51764c0d8976e608"}, 1470 | {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0509e469d48940147e1235d994cd849a8f8195e0bca65f8f5439c56e17872a3"}, 1471 | {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:514fe2b8d750d6cdb4712346a2c5084a80220821a3e91f3f71eec11cf8d28fd4"}, 1472 | {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19adcfc2a7197cdc3987044e3f415168fc5dc1f720c932eb1ef4f71a2067e08b"}, 1473 | {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9d153e7f1f9ba0b23ad1568b3b9e17301e23b042c23870f9ee0522dc5cc79e8"}, 1474 | {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:aef9cc3d9c7d63d924adac329c33835e0243b5052a6dfcbf7732a921c6e918ba"}, 1475 | {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4571f1beddff25f3e925eea34268422622963cd8dc395bb8778eb28418248e43"}, 1476 | {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:d48b8ee1d4068561ce8033d2c344cf5232cb29ee1a0206a7b828c79cbc5982b8"}, 1477 | {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:45183c96ddf61bf96d2684d9fbaf6f3564d86b34cb125761f9a0ef9e36c1d55b"}, 1478 | {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:75bdf08716edde767b09e76829db8c1e5ca9d8bb0a8d4bd94ae1eafe3dac5e15"}, 1479 | {file = "multidict-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:a45e1135cb07086833ce969555df39149680e5471c04dfd6a915abd2fc3f6dbc"}, 1480 | {file = "multidict-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f3cdef8a247d1eafa649085812f8a310e728bdf3900ff6c434eafb2d443b23a"}, 1481 | {file = "multidict-6.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0327292e745a880459ef71be14e709aaea2f783f3537588fb4ed09b6c01bca60"}, 1482 | {file = "multidict-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e875b6086e325bab7e680e4316d667fc0e5e174bb5611eb16b3ea121c8951b86"}, 1483 | {file = "multidict-6.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d"}, 1484 | {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc57c68cb9139c7cd6fc39f211b02198e69fb90ce4bc4a094cf5fe0d20fd8b0"}, 1485 | {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:497988d6b6ec6ed6f87030ec03280b696ca47dbf0648045e4e1d28b80346560d"}, 1486 | {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89171b2c769e03a953d5969b2f272efa931426355b6c0cb508022976a17fd376"}, 1487 | {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684133b1e1fe91eda8fa7447f137c9490a064c6b7f392aa857bba83a28cfb693"}, 1488 | {file = "multidict-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd9fc9c4849a07f3635ccffa895d57abce554b467d611a5009ba4f39b78a8849"}, 1489 | {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e07c8e79d6e6fd37b42f3250dba122053fddb319e84b55dd3a8d6446e1a7ee49"}, 1490 | {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4070613ea2227da2bfb2c35a6041e4371b0af6b0be57f424fe2318b42a748516"}, 1491 | {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:47fbeedbf94bed6547d3aa632075d804867a352d86688c04e606971595460227"}, 1492 | {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5774d9218d77befa7b70d836004a768fb9aa4fdb53c97498f4d8d3f67bb9cfa9"}, 1493 | {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2957489cba47c2539a8eb7ab32ff49101439ccf78eab724c828c1a54ff3ff98d"}, 1494 | {file = "multidict-6.0.2-cp38-cp38-win32.whl", hash = "sha256:e5b20e9599ba74391ca0cfbd7b328fcc20976823ba19bc573983a25b32e92b57"}, 1495 | {file = "multidict-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:8004dca28e15b86d1b1372515f32eb6f814bdf6f00952699bdeb541691091f96"}, 1496 | {file = "multidict-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2e4a0785b84fb59e43c18a015ffc575ba93f7d1dbd272b4cdad9f5134b8a006c"}, 1497 | {file = "multidict-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6701bf8a5d03a43375909ac91b6980aea74b0f5402fbe9428fc3f6edf5d9677e"}, 1498 | {file = "multidict-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a007b1638e148c3cfb6bf0bdc4f82776cef0ac487191d093cdc316905e504071"}, 1499 | {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07a017cfa00c9890011628eab2503bee5872f27144936a52eaab449be5eaf032"}, 1500 | {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c207fff63adcdf5a485969131dc70e4b194327666b7e8a87a97fbc4fd80a53b2"}, 1501 | {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:373ba9d1d061c76462d74e7de1c0c8e267e9791ee8cfefcf6b0b2495762c370c"}, 1502 | {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfba7c6d5d7c9099ba21f84662b037a0ffd4a5e6b26ac07d19e423e6fdf965a9"}, 1503 | {file = "multidict-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19d9bad105dfb34eb539c97b132057a4e709919ec4dd883ece5838bcbf262b80"}, 1504 | {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:de989b195c3d636ba000ee4281cd03bb1234635b124bf4cd89eeee9ca8fcb09d"}, 1505 | {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7c40b7bbece294ae3a87c1bc2abff0ff9beef41d14188cda94ada7bcea99b0fb"}, 1506 | {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:d16cce709ebfadc91278a1c005e3c17dd5f71f5098bfae1035149785ea6e9c68"}, 1507 | {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a2c34a93e1d2aa35fbf1485e5010337c72c6791407d03aa5f4eed920343dd360"}, 1508 | {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937"}, 1509 | {file = "multidict-6.0.2-cp39-cp39-win32.whl", hash = "sha256:23b616fdc3c74c9fe01d76ce0d1ce872d2d396d8fa8e4899398ad64fb5aa214a"}, 1510 | {file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"}, 1511 | {file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"}, 1512 | ] 1513 | mypy-extensions = [ 1514 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 1515 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 1516 | ] 1517 | nodeenv = [] 1518 | packaging = [ 1519 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 1520 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 1521 | ] 1522 | parso = [ 1523 | {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, 1524 | {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, 1525 | ] 1526 | pathspec = [] 1527 | pexpect = [ 1528 | {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, 1529 | {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, 1530 | ] 1531 | pickleshare = [ 1532 | {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, 1533 | {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, 1534 | ] 1535 | platformdirs = [ 1536 | {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, 1537 | {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, 1538 | ] 1539 | pluggy = [ 1540 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 1541 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 1542 | ] 1543 | pre-commit = [] 1544 | prompt-toolkit = [] 1545 | ptyprocess = [ 1546 | {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, 1547 | {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, 1548 | ] 1549 | py = [ 1550 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 1551 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 1552 | ] 1553 | pycodestyle = [] 1554 | pycparser = [ 1555 | {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, 1556 | {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, 1557 | ] 1558 | pydantic = [] 1559 | pyflakes = [] 1560 | pygments = [] 1561 | pymdown-extensions = [] 1562 | pyparsing = [ 1563 | {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, 1564 | {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, 1565 | ] 1566 | pytest = [ 1567 | {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, 1568 | {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, 1569 | ] 1570 | pytest-asyncio = [] 1571 | pytest-cov = [] 1572 | pytest-mock = [] 1573 | pytest-sugar = [] 1574 | python-dateutil = [] 1575 | pyyaml = [] 1576 | pyyaml-env-tag = [] 1577 | rcslice = [] 1578 | requests = [] 1579 | rfc3986 = [ 1580 | {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, 1581 | {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, 1582 | ] 1583 | s3transfer = [] 1584 | six = [ 1585 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 1586 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 1587 | ] 1588 | sniffio = [] 1589 | starlette = [] 1590 | termcolor = [] 1591 | toml = [ 1592 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 1593 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 1594 | ] 1595 | tomli = [ 1596 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 1597 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 1598 | ] 1599 | tox = [] 1600 | tox-poetry = [] 1601 | traitlets = [] 1602 | typing-extensions = [] 1603 | urllib3 = [] 1604 | uvicorn = [] 1605 | virtualenv = [] 1606 | watchdog = [] 1607 | wcwidth = [ 1608 | {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, 1609 | {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, 1610 | ] 1611 | yarl = [] 1612 | zipp = [] 1613 | --------------------------------------------------------------------------------