├── 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 |
36 |
--------------------------------------------------------------------------------
/docs_overrides/.icons/saleor/saleor.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
--------------------------------------------------------------------------------