From c6d3eb29c300955a5f73ba5062b9bb0f8cb19b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kucmus?= Date: Sun, 12 Dec 2021 23:42:50 +0100 Subject: [PATCH 1/7] Webhook handlers as FastAPI APIRoutes to allow for Depends injection, refactor - update deps - remove the need for the framework to have the apps Settings - deatach Manifest from settings - deatach development http/https handling from the debug setting (for unambiguity) - create APIRoutes for every webhook handler - introduce the Simple Saleor Client (potentially will land in a separate package in the future) - add url_for resololution in Manifest URLs - describe different verions of webhook payloads (with meta) --- poetry.lock | 860 ++++++++++++++--------- pyproject.toml | 2 +- samples/simple_app/app.py | 80 ++- src/saleor_app/app.py | 30 +- src/saleor_app/conf.py | 68 -- src/saleor_app/deps.py | 80 +-- src/saleor_app/endpoints.py | 73 +- src/saleor_app/errors.py | 10 +- src/saleor_app/graphql.py | 69 -- src/saleor_app/http.py | 23 + src/saleor_app/install.py | 69 +- src/saleor_app/saleor/client.py | 47 ++ src/saleor_app/saleor/exceptions.py | 20 + src/saleor_app/{ => saleor}/mutations.py | 0 src/saleor_app/saleor/utils.py | 10 + src/saleor_app/schemas/handlers.py | 145 ++-- src/saleor_app/schemas/manifest.py | 62 +- src/saleor_app/schemas/webhook.py | 50 ++ src/saleor_app/utils.py | 22 - src/saleor_app/validators.py | 19 - 20 files changed, 964 insertions(+), 775 deletions(-) delete mode 100644 src/saleor_app/conf.py delete mode 100644 src/saleor_app/graphql.py create mode 100644 src/saleor_app/http.py create mode 100644 src/saleor_app/saleor/client.py create mode 100644 src/saleor_app/saleor/exceptions.py rename src/saleor_app/{ => saleor}/mutations.py (100%) create mode 100644 src/saleor_app/saleor/utils.py create mode 100644 src/saleor_app/schemas/webhook.py delete mode 100644 src/saleor_app/utils.py delete mode 100644 src/saleor_app/validators.py diff --git a/poetry.lock b/poetry.lock index 45aa7b0..294f103 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,6 +1,6 @@ [[package]] name = "aiofiles" -version = "0.7.0" +version = "0.8.0" description = "File support for asyncio." category = "main" optional = false @@ -8,26 +8,38 @@ python-versions = ">=3.6,<4.0" [[package]] name = "aiohttp" -version = "3.7.4.post0" +version = "3.8.1" description = "Async http client/server framework (asyncio)" category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -async-timeout = ">=3.0,<4.0" +aiosignal = ">=1.1.2" +async-timeout = ">=4.0.0a3,<5.0" attrs = ">=17.3.0" -chardet = ">=2.0,<5.0" +charset-normalizer = ">=2.0,<3.0" +frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" -typing-extensions = ">=3.6.5" yarl = ">=1.0,<2.0" [package.extras] -speedups = ["aiodns", "brotlipy", "cchardet"] +speedups = ["aiodns", "brotli", "cchardet"] + +[[package]] +name = "aiosignal" +version = "1.2.0" +description = "aiosignal: a list of registered asynchronous callbacks" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +frozenlist = ">=1.1.0" [[package]] name = "anyio" -version = "3.3.4" +version = "3.4.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" category = "main" optional = false @@ -39,7 +51,7 @@ sniffio = ">=1.1" [package.extras] doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] -test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] +test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] trio = ["trio (>=0.16)"] [[package]] @@ -71,11 +83,14 @@ tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] [[package]] name = "async-timeout" -version = "3.0.1" +version = "4.0.1" description = "Timeout context manager for asyncio programs" category = "main" optional = false -python-versions = ">=3.5.3" +python-versions = ">=3.6" + +[package.dependencies] +typing-extensions = ">=3.6.5" [[package]] name = "atomicwrites" @@ -109,7 +124,7 @@ python-versions = "*" [[package]] name = "backports.entry-points-selectable" -version = "1.1.0" +version = "1.1.1" description = "Compatibility shim providing selectable entry points for older implementations" category = "dev" optional = false @@ -117,7 +132,7 @@ python-versions = ">=2.7" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] +testing = ["pytest", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] [[package]] name = "black" @@ -168,19 +183,11 @@ category = "dev" optional = false python-versions = ">=3.6.1" -[[package]] -name = "chardet" -version = "4.0.0" -description = "Universal encoding detector for Python 2 and 3" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - [[package]] name = "charset-normalizer" -version = "2.0.7" +version = "2.0.9" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "dev" +category = "main" optional = false python-versions = ">=3.5.0" @@ -208,7 +215,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "coverage" -version = "6.0.2" +version = "6.2" description = "Code coverage measurement for Python" category = "dev" optional = false @@ -219,7 +226,7 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "35.0.0" +version = "36.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." category = "main" optional = false @@ -230,7 +237,7 @@ cffi = ">=1.12" [package.extras] docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] -docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] sdist = ["setuptools_rust (>=0.11.4)"] ssh = ["bcrypt (>=3.1.5)"] @@ -246,7 +253,7 @@ python-versions = ">=3.5" [[package]] name = "distlib" -version = "0.3.3" +version = "0.3.4" description = "Distribution utilities" category = "dev" optional = false @@ -254,7 +261,7 @@ python-versions = "*" [[package]] name = "fastapi" -version = "0.70.0" +version = "0.70.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" category = "main" optional = false @@ -272,7 +279,7 @@ test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.91 [[package]] name = "filelock" -version = "3.3.1" +version = "3.4.0" description = "A platform independent file lock." category = "dev" optional = false @@ -295,6 +302,14 @@ mccabe = ">=0.6.0,<0.7.0" pycodestyle = ">=2.7.0,<2.8.0" pyflakes = ">=2.3.0,<2.4.0" +[[package]] +name = "frozenlist" +version = "1.2.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +category = "main" +optional = false +python-versions = ">=3.6" + [[package]] name = "h11" version = "0.12.0" @@ -305,7 +320,7 @@ python-versions = ">=3.6" [[package]] name = "httpcore" -version = "0.13.7" +version = "0.14.3" description = "A minimal low-level HTTP client." category = "dev" optional = false @@ -313,6 +328,7 @@ python-versions = ">=3.6" [package.dependencies] anyio = ">=3.0.0,<4.0.0" +certifi = "*" h11 = ">=0.11,<0.13" sniffio = ">=1.0.0,<2.0.0" @@ -321,7 +337,7 @@ http2 = ["h2 (>=3,<5)"] [[package]] name = "httpx" -version = "0.20.0" +version = "0.21.1" description = "The next generation HTTP client." category = "dev" optional = false @@ -330,7 +346,7 @@ python-versions = ">=3.6" [package.dependencies] certifi = "*" charset-normalizer = "*" -httpcore = ">=0.13.3,<0.14.0" +httpcore = ">=0.14.0,<0.15.0" rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} sniffio = "*" @@ -341,14 +357,14 @@ http2 = ["h2 (>=3,<5)"] [[package]] name = "identify" -version = "2.3.0" +version = "2.4.0" description = "File identification library for Python" category = "dev" optional = false python-versions = ">=3.6.1" [package.extras] -license = ["editdistance-s"] +license = ["ukkonen"] [[package]] name = "idna" @@ -381,7 +397,7 @@ toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""} [[package]] name = "ipython" -version = "7.28.0" +version = "7.30.1" description = "IPython: Productive Interactive Computing" category = "dev" optional = false @@ -413,7 +429,7 @@ test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipyk [[package]] name = "isort" -version = "5.9.3" +version = "5.10.1" description = "A Python utility / library to sort Python imports." category = "dev" optional = false @@ -427,7 +443,7 @@ plugins = ["setuptools"] [[package]] name = "jedi" -version = "0.18.0" +version = "0.18.1" description = "An autocompletion tool for Python that can be used for text editors." category = "dev" optional = false @@ -438,7 +454,7 @@ parso = ">=0.8.0,<0.9.0" [package.extras] qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] -testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<6.0.0)"] +testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<7.0.0)"] [[package]] name = "jinja2" @@ -518,18 +534,18 @@ python-versions = "*" [[package]] name = "packaging" -version = "21.0" +version = "21.3" description = "Core utilities for Python packages" category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -pyparsing = ">=2.0.2" +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "parso" -version = "0.8.2" +version = "0.8.3" description = "A Python Parser" category = "dev" optional = false @@ -592,7 +608,7 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.15.0" +version = "2.16.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -608,7 +624,7 @@ virtualenv = ">=20.0.8" [[package]] name = "prompt-toolkit" -version = "3.0.20" +version = "3.0.24" description = "Library for building powerful interactive command lines in Python" category = "dev" optional = false @@ -627,11 +643,11 @@ python-versions = "*" [[package]] name = "py" -version = "1.10.0" +version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pycodestyle" @@ -643,7 +659,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pycparser" -version = "2.20" +version = "2.21" description = "C parser in Python" category = "main" optional = false @@ -682,11 +698,14 @@ python-versions = ">=3.5" [[package]] name = "pyparsing" -version = "2.4.7" +version = "3.0.6" description = "Python parsing module" category = "dev" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" @@ -762,7 +781,7 @@ python-versions = ">=3.6" [[package]] name = "regex" -version = "2021.10.8" +version = "2021.11.10" description = "Alternative regular expression module, to replace re." category = "dev" optional = false @@ -868,7 +887,7 @@ test = ["coverage", "pytest", "pycodestyle", "pylint"] [[package]] name = "traitlets" -version = "5.1.0" +version = "5.1.1" description = "Traitlets Python configuration system" category = "dev" optional = false @@ -879,23 +898,23 @@ test = ["pytest"] [[package]] name = "typed-ast" -version = "1.4.3" +version = "1.5.1" description = "a fork of Python 2 and 3 ast modules with type comment support" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "typing-extensions" -version = "3.10.0.2" -description = "Backported and Experimental Type Hints for Python 3.5+" +version = "4.0.1" +description = "Backported and Experimental Type Hints for Python 3.6+" category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "uvicorn" -version = "0.15.0" +version = "0.16.0" description = "The lightning-fast ASGI server." category = "main" optional = false @@ -907,11 +926,11 @@ click = ">=7.0" h11 = ">=0.8" [package.extras] -standard = ["websockets (>=9.1)", "httptools (>=0.2.0,<0.3.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"] +standard = ["httptools (>=0.2.0,<0.4.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "websockets (>=9.1)", "websockets (>=10.0)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"] [[package]] name = "virtualenv" -version = "20.8.1" +version = "20.10.0" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -920,12 +939,12 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] "backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" -filelock = ">=3.0.0,<4" +filelock = ">=3.2,<4" platformdirs = ">=2,<3" six = ">=1.9.0,<2" [package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] [[package]] @@ -938,7 +957,7 @@ python-versions = "*" [[package]] name = "yarl" -version = "1.7.0" +version = "1.7.2" description = "Yet another URL library" category = "main" optional = false @@ -955,51 +974,90 @@ content-hash = "b5e7610f5450237209ade4beb3042ca5f453ae689f54adc2cb78e139cf457ce6 [metadata.files] aiofiles = [ - {file = "aiofiles-0.7.0-py3-none-any.whl", hash = "sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"}, - {file = "aiofiles-0.7.0.tar.gz", hash = "sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4"}, + {file = "aiofiles-0.8.0-py3-none-any.whl", hash = "sha256:7a973fc22b29e9962d0897805ace5856e6a566ab1f0c8e5c91ff6c866519c937"}, + {file = "aiofiles-0.8.0.tar.gz", hash = "sha256:8334f23235248a3b2e83b2c3a78a22674f39969b96397126cc93664d9a901e59"}, ] aiohttp = [ - {file = "aiohttp-3.7.4.post0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:3cf75f7cdc2397ed4442594b935a11ed5569961333d49b7539ea741be2cc79d5"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:4b302b45040890cea949ad092479e01ba25911a15e648429c7c5aae9650c67a8"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:393f389841e8f2dfc86f774ad22f00923fdee66d238af89b70ea314c4aefd290"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:c6e9dcb4cb338d91a73f178d866d051efe7c62a7166653a91e7d9fb18274058f"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:5df68496d19f849921f05f14f31bd6ef53ad4b00245da3195048c69934521809"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:0563c1b3826945eecd62186f3f5c7d31abb7391fedc893b7e2b26303b5a9f3fe"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-win32.whl", hash = "sha256:3d78619672183be860b96ed96f533046ec97ca067fd46ac1f6a09cd9b7484287"}, - {file = "aiohttp-3.7.4.post0-cp36-cp36m-win_amd64.whl", hash = "sha256:f705e12750171c0ab4ef2a3c76b9a4024a62c4103e3a55dd6f99265b9bc6fcfc"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:230a8f7e24298dea47659251abc0fd8b3c4e38a664c59d4b89cca7f6c09c9e87"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2e19413bf84934d651344783c9f5e22dee452e251cfd220ebadbed2d9931dbf0"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e4b2b334e68b18ac9817d828ba44d8fcb391f6acb398bcc5062b14b2cbeac970"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:d012ad7911653a906425d8473a1465caa9f8dea7fcf07b6d870397b774ea7c0f"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:40eced07f07a9e60e825554a31f923e8d3997cfc7fb31dbc1328c70826e04cde"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:209b4a8ee987eccc91e2bd3ac36adee0e53a5970b8ac52c273f7f8fd4872c94c"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:14762875b22d0055f05d12abc7f7d61d5fd4fe4642ce1a249abdf8c700bf1fd8"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-win32.whl", hash = "sha256:7615dab56bb07bff74bc865307aeb89a8bfd9941d2ef9d817b9436da3a0ea54f"}, - {file = "aiohttp-3.7.4.post0-cp37-cp37m-win_amd64.whl", hash = "sha256:d9e13b33afd39ddeb377eff2c1c4f00544e191e1d1dee5b6c51ddee8ea6f0cf5"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:547da6cacac20666422d4882cfcd51298d45f7ccb60a04ec27424d2f36ba3eaf"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:af9aa9ef5ba1fd5b8c948bb11f44891968ab30356d65fd0cc6707d989cd521df"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:64322071e046020e8797117b3658b9c2f80e3267daec409b350b6a7a05041213"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bb437315738aa441251214dad17428cafda9cdc9729499f1d6001748e1d432f4"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:e54962802d4b8b18b6207d4a927032826af39395a3bd9196a5af43fc4e60b009"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:a00bb73540af068ca7390e636c01cbc4f644961896fa9363154ff43fd37af2f5"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:79ebfc238612123a713a457d92afb4096e2148be17df6c50fb9bf7a81c2f8013"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-win32.whl", hash = "sha256:515dfef7f869a0feb2afee66b957cc7bbe9ad0cdee45aec7fdc623f4ecd4fb16"}, - {file = "aiohttp-3.7.4.post0-cp38-cp38-win_amd64.whl", hash = "sha256:114b281e4d68302a324dd33abb04778e8557d88947875cbf4e842c2c01a030c5"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:7b18b97cf8ee5452fa5f4e3af95d01d84d86d32c5e2bfa260cf041749d66360b"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:15492a6368d985b76a2a5fdd2166cddfea5d24e69eefed4630cbaae5c81d89bd"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bdb230b4943891321e06fc7def63c7aace16095be7d9cf3b1e01be2f10fba439"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:cffe3ab27871bc3ea47df5d8f7013945712c46a3cc5a95b6bee15887f1675c22"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:a5ca29ee66f8343ed336816c553e82d6cade48a3ad702b9ffa6125d187e2dedb"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:17c073de315745a1510393a96e680d20af8e67e324f70b42accbd4cb3315c9fb"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-win32.whl", hash = "sha256:932bb1ea39a54e9ea27fc9232163059a0b8855256f4052e776357ad9add6f1c9"}, - {file = "aiohttp-3.7.4.post0-cp39-cp39-win_amd64.whl", hash = "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe"}, - {file = "aiohttp-3.7.4.post0.tar.gz", hash = "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf"}, + {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8"}, + {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8"}, + {file = "aiohttp-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a79004bb58748f31ae1cbe9fa891054baaa46fb106c2dc7af9f8e3304dc30316"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12de6add4038df8f72fac606dff775791a60f113a725c960f2bab01d8b8e6b15"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f0d5f33feb5f69ddd57a4a4bd3d56c719a141080b445cbf18f238973c5c9923"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eaba923151d9deea315be1f3e2b31cc39a6d1d2f682f942905951f4e40200922"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:099ebd2c37ac74cce10a3527d2b49af80243e2a4fa39e7bce41617fbc35fa3c1"}, + {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"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fa0ffcace9b3aa34d205d8130f7873fcfefcb6a4dd3dd705b0dab69af6712642"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61bfc23df345d8c9716d03717c2ed5e27374e0fe6f659ea64edcd27b4b044cf7"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:31560d268ff62143e92423ef183680b9829b1b482c011713ae941997921eebc8"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97ef77eb6b044134c0b3a96e16abcb05ecce892965a2124c566af0fd60f717e2"}, + {file = "aiohttp-3.8.1-cp310-cp310-win32.whl", hash = "sha256:c2aef4703f1f2ddc6df17519885dbfa3514929149d3ff900b73f45998f2532fa"}, + {file = "aiohttp-3.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:713ac174a629d39b7c6a3aa757b337599798da4c1157114a314e4e391cd28e32"}, + {file = "aiohttp-3.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:473d93d4450880fe278696549f2e7aed8cd23708c3c1997981464475f32137db"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b5eeae8e019e7aad8af8bb314fb908dd2e028b3cdaad87ec05095394cce632"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af642b43ce56c24d063325dd2cf20ee012d2b9ba4c3c008755a301aaea720ad"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3630c3ef435c0a7c549ba170a0633a56e92629aeed0e707fec832dee313fb7a"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4a4a4e30bf1edcad13fb0804300557aedd07a92cabc74382fdd0ba6ca2661091"}, + {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"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a25fa703a527158aaf10dafd956f7d42ac6d30ec80e9a70846253dd13e2f067b"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5bfde62d1d2641a1f5173b8c8c2d96ceb4854f54a44c23102e2ccc7e02f003ec"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:51467000f3647d519272392f484126aa716f747859794ac9924a7aafa86cd411"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:03a6d5349c9ee8f79ab3ff3694d6ce1cfc3ced1c9d36200cb8f08ba06bd3b782"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:102e487eeb82afac440581e5d7f8f44560b36cf0bdd11abc51a46c1cd88914d4"}, + {file = "aiohttp-3.8.1-cp36-cp36m-win32.whl", hash = "sha256:4aed991a28ea3ce320dc8ce655875e1e00a11bdd29fe9444dd4f88c30d558602"}, + {file = "aiohttp-3.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b0e20cddbd676ab8a64c774fefa0ad787cc506afd844de95da56060348021e96"}, + {file = "aiohttp-3.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:37951ad2f4a6df6506750a23f7cbabad24c73c65f23f72e95897bb2cecbae676"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c23b1ad869653bc818e972b7a3a79852d0e494e9ab7e1a701a3decc49c20d51"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15b09b06dae900777833fe7fc4b4aa426556ce95847a3e8d7548e2d19e34edb8"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:477c3ea0ba410b2b56b7efb072c36fa91b1e6fc331761798fa3f28bb224830dd"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2f2f69dca064926e79997f45b2f34e202b320fd3782f17a91941f7eb85502ee2"}, + {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"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6d69f36d445c45cda7b3b26afef2fc34ef5ac0cdc75584a87ef307ee3c8c6d00"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:55c3d1072704d27401c92339144d199d9de7b52627f724a949fc7d5fc56d8b93"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b9d00268fcb9f66fbcc7cd9fe423741d90c75ee029a1d15c09b22d23253c0a44"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:07b05cd3305e8a73112103c834e91cd27ce5b4bd07850c4b4dbd1877d3f45be7"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c34dc4958b232ef6188c4318cb7b2c2d80521c9a56c52449f8f93ab7bc2a8a1c"}, + {file = "aiohttp-3.8.1-cp37-cp37m-win32.whl", hash = "sha256:d2f9b69293c33aaa53d923032fe227feac867f81682f002ce33ffae978f0a9a9"}, + {file = "aiohttp-3.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6ae828d3a003f03ae31915c31fa684b9890ea44c9c989056fea96e3d12a9fa17"}, + {file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0c7ebbbde809ff4e970824b2b6cb7e4222be6b95a296e46c03cf050878fc1785"}, + {file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b7ef7cbd4fec9a1e811a5de813311ed4f7ac7d93e0fda233c9b3e1428f7dd7b"}, + {file = "aiohttp-3.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c3d6a4d0619e09dcd61021debf7059955c2004fa29f48788a3dfaf9c9901a7cd"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:718626a174e7e467f0558954f94af117b7d4695d48eb980146016afa4b580b2e"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:589c72667a5febd36f1315aa6e5f56dd4aa4862df295cb51c769d16142ddd7cd"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ed076098b171573161eb146afcb9129b5ff63308960aeca4b676d9d3c35e700"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:086f92daf51a032d062ec5f58af5ca6a44d082c35299c96376a41cbb33034675"}, + {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"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:31d1e1c0dbf19ebccbfd62eff461518dcb1e307b195e93bba60c965a4dcf1ba0"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:11a67c0d562e07067c4e86bffc1553f2cf5b664d6111c894671b2b8712f3aba5"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:bb01ba6b0d3f6c68b89fce7305080145d4877ad3acaed424bae4d4ee75faa950"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:44db35a9e15d6fe5c40d74952e803b1d96e964f683b5a78c3cc64eb177878155"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:844a9b460871ee0a0b0b68a64890dae9c415e513db0f4a7e3cab41a0f2fedf33"}, + {file = "aiohttp-3.8.1-cp38-cp38-win32.whl", hash = "sha256:7d08744e9bae2ca9c382581f7dce1273fe3c9bae94ff572c3626e8da5b193c6a"}, + {file = "aiohttp-3.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:04d48b8ce6ab3cf2097b1855e1505181bdd05586ca275f2505514a6e274e8e75"}, + {file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5315a2eb0239185af1bddb1abf472d877fede3cc8d143c6cddad37678293237"}, + {file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a996d01ca39b8dfe77440f3cd600825d05841088fd6bc0144cc6c2ec14cc5f74"}, + {file = "aiohttp-3.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13487abd2f761d4be7c8ff9080de2671e53fff69711d46de703c310c4c9317ca"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea302f34477fda3f85560a06d9ebdc7fa41e82420e892fc50b577e35fc6a50b2"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2f635ce61a89c5732537a7896b6319a8fcfa23ba09bec36e1b1ac0ab31270d2"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e999f2d0e12eea01caeecb17b653f3713d758f6dcc770417cf29ef08d3931421"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0770e2806a30e744b4e21c9d73b7bee18a1cfa3c47991ee2e5a65b887c49d5cf"}, + {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"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c7cefb4b0640703eb1069835c02486669312bf2f12b48a748e0a7756d0de33d"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:71927042ed6365a09a98a6377501af5c9f0a4d38083652bcd2281a06a5976724"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:28d490af82bc6b7ce53ff31337a18a10498303fe66f701ab65ef27e143c3b0ef"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b6613280ccedf24354406caf785db748bebbddcf31408b20c0b48cb86af76866"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81e3d8c34c623ca4e36c46524a3530e99c0bc95ed068fd6e9b55cb721d408fb2"}, + {file = "aiohttp-3.8.1-cp39-cp39-win32.whl", hash = "sha256:7187a76598bdb895af0adbd2fb7474d7f6025d170bc0a1130242da817ce9e7d1"}, + {file = "aiohttp-3.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:1c182cb873bc91b411e184dab7a2b664d4fea2743df0e4d57402f7f3fa644bac"}, + {file = "aiohttp-3.8.1.tar.gz", hash = "sha256:fc5471e1a54de15ef71c1bc6ebe80d4dc681ea600e68bfd1cbce40427f0b7578"}, +] +aiosignal = [ + {file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"}, + {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"}, ] anyio = [ - {file = "anyio-3.3.4-py3-none-any.whl", hash = "sha256:4fd09a25ab7fa01d34512b7249e366cd10358cdafc95022c7ff8c8f8a5026d66"}, - {file = "anyio-3.3.4.tar.gz", hash = "sha256:67da67b5b21f96b9d3d65daa6ea99f5d5282cb09f50eb4456f8fb51dffefc3ff"}, + {file = "anyio-3.4.0-py3-none-any.whl", hash = "sha256:2855a9423524abcdd652d942f8932fda1735210f77a6b392eafd9ff34d3fe020"}, + {file = "anyio-3.4.0.tar.gz", hash = "sha256:24adc69309fb5779bc1e06158e143e0b6d2c56b302a3ac3de3083c705a6ed39d"}, ] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, @@ -1014,8 +1072,8 @@ asgiref = [ {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"}, ] async-timeout = [ - {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, - {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, + {file = "async-timeout-4.0.1.tar.gz", hash = "sha256:b930cb161a39042f9222f6efb7301399c87eeab394727ec5437924a36d6eef51"}, + {file = "async_timeout-4.0.1-py3-none-any.whl", hash = "sha256:a22c0b311af23337eb05fcf05a8b51c3ea53729d46fb5460af62bee033cec690"}, ] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, @@ -1030,8 +1088,8 @@ backcall = [ {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, ] "backports.entry-points-selectable" = [ - {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"}, - {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"}, + {file = "backports.entry_points_selectable-1.1.1-py2.py3-none-any.whl", hash = "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b"}, + {file = "backports.entry_points_selectable-1.1.1.tar.gz", hash = "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386"}, ] black = [ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, @@ -1096,13 +1154,9 @@ cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] -chardet = [ - {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, - {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, -] charset-normalizer = [ - {file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"}, - {file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"}, + {file = "charset-normalizer-2.0.9.tar.gz", hash = "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c"}, + {file = "charset_normalizer-2.0.9-py3-none-any.whl", hash = "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721"}, ] click = [ {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, @@ -1113,97 +1167,186 @@ colorama = [ {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] coverage = [ - {file = "coverage-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1549e1d08ce38259de2bc3e9a0d5f3642ff4a8f500ffc1b2df73fd621a6cdfc0"}, - {file = "coverage-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcae10fccb27ca2a5f456bf64d84110a5a74144be3136a5e598f9d9fb48c0caa"}, - {file = "coverage-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:53a294dc53cfb39c74758edaa6305193fb4258a30b1f6af24b360a6c8bd0ffa7"}, - {file = "coverage-6.0.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8251b37be1f2cd9c0e5ccd9ae0380909c24d2a5ed2162a41fcdbafaf59a85ebd"}, - {file = "coverage-6.0.2-cp310-cp310-win32.whl", hash = "sha256:db42baa892cba723326284490283a68d4de516bfb5aaba369b4e3b2787a778b7"}, - {file = "coverage-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:bbffde2a68398682623d9dd8c0ca3f46fda074709b26fcf08ae7a4c431a6ab2d"}, - {file = "coverage-6.0.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:60e51a3dd55540bec686d7fff61b05048ca31e804c1f32cbb44533e6372d9cc3"}, - {file = "coverage-6.0.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a6a9409223a27d5ef3cca57dd7cd4dfcb64aadf2fad5c3b787830ac9223e01a"}, - {file = "coverage-6.0.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4b34ae4f51bbfa5f96b758b55a163d502be3dcb24f505d0227858c2b3f94f5b9"}, - {file = "coverage-6.0.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3bbda1b550e70fa6ac40533d3f23acd4f4e9cb4e6e77251ce77fdf41b3309fb2"}, - {file = "coverage-6.0.2-cp36-cp36m-win32.whl", hash = "sha256:4e28d2a195c533b58fc94a12826f4431726d8eb029ac21d874345f943530c122"}, - {file = "coverage-6.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:a82d79586a0a4f5fd1cf153e647464ced402938fbccb3ffc358c7babd4da1dd9"}, - {file = "coverage-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3be1206dc09fb6298de3fce70593e27436862331a85daee36270b6d0e1c251c4"}, - {file = "coverage-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9cd3828bbe1a40070c11fe16a51df733fd2f0cb0d745fb83b7b5c1f05967df7"}, - {file = "coverage-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d036dc1ed8e1388e995833c62325df3f996675779541f682677efc6af71e96cc"}, - {file = "coverage-6.0.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04560539c19ec26995ecfb3d9307ff154fbb9a172cb57e3b3cfc4ced673103d1"}, - {file = "coverage-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:e4fb7ced4d9dec77d6cf533acfbf8e1415fe799430366affb18d69ee8a3c6330"}, - {file = "coverage-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:77b1da5767ed2f44611bc9bc019bc93c03fa495728ec389759b6e9e5039ac6b1"}, - {file = "coverage-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:61b598cbdbaae22d9e34e3f675997194342f866bb1d781da5d0be54783dce1ff"}, - {file = "coverage-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36e9040a43d2017f2787b28d365a4bb33fcd792c7ff46a047a04094dc0e2a30d"}, - {file = "coverage-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9f1627e162e3864a596486774876415a7410021f4b67fd2d9efdf93ade681afc"}, - {file = "coverage-6.0.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e7a0b42db2a47ecb488cde14e0f6c7679a2c5a9f44814393b162ff6397fcdfbb"}, - {file = "coverage-6.0.2-cp38-cp38-win32.whl", hash = "sha256:a1b73c7c4d2a42b9d37dd43199c5711d91424ff3c6c22681bc132db4a4afec6f"}, - {file = "coverage-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:1db67c497688fd4ba85b373b37cc52c50d437fd7267520ecd77bddbd89ea22c9"}, - {file = "coverage-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f2f184bf38e74f152eed7f87e345b51f3ab0b703842f447c22efe35e59942c24"}, - {file = "coverage-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd1cf1deb3d5544bd942356364a2fdc8959bad2b6cf6eb17f47d301ea34ae822"}, - {file = "coverage-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ad9b8c1206ae41d46ec7380b78ba735ebb77758a650643e841dd3894966c31d0"}, - {file = "coverage-6.0.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:381d773d896cc7f8ba4ff3b92dee4ed740fb88dfe33b6e42efc5e8ab6dfa1cfe"}, - {file = "coverage-6.0.2-cp39-cp39-win32.whl", hash = "sha256:424c44f65e8be58b54e2b0bd1515e434b940679624b1b72726147cfc6a9fc7ce"}, - {file = "coverage-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:abbff240f77347d17306d3201e14431519bf64495648ca5a49571f988f88dee9"}, - {file = "coverage-6.0.2-pp36-none-any.whl", hash = "sha256:7092eab374346121805fb637572483270324407bf150c30a3b161fc0c4ca5164"}, - {file = "coverage-6.0.2-pp37-none-any.whl", hash = "sha256:30922626ce6f7a5a30bdba984ad21021529d3d05a68b4f71ea3b16bda35b8895"}, - {file = "coverage-6.0.2.tar.gz", hash = "sha256:6807947a09510dc31fa86f43595bf3a14017cd60bf633cc746d52141bfa6b149"}, + {file = "coverage-6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b"}, + {file = "coverage-6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0"}, + {file = "coverage-6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da"}, + {file = "coverage-6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d"}, + {file = "coverage-6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739"}, + {file = "coverage-6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971"}, + {file = "coverage-6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840"}, + {file = "coverage-6.2-cp310-cp310-win32.whl", hash = "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c"}, + {file = "coverage-6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f"}, + {file = "coverage-6.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76"}, + {file = "coverage-6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47"}, + {file = "coverage-6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64"}, + {file = "coverage-6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9"}, + {file = "coverage-6.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d"}, + {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48"}, + {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e"}, + {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d"}, + {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17"}, + {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781"}, + {file = "coverage-6.2-cp36-cp36m-win32.whl", hash = "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a"}, + {file = "coverage-6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0"}, + {file = "coverage-6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49"}, + {file = "coverage-6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521"}, + {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884"}, + {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa"}, + {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64"}, + {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617"}, + {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8"}, + {file = "coverage-6.2-cp37-cp37m-win32.whl", hash = "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4"}, + {file = "coverage-6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74"}, + {file = "coverage-6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e"}, + {file = "coverage-6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58"}, + {file = "coverage-6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc"}, + {file = "coverage-6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd"}, + {file = "coverage-6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953"}, + {file = "coverage-6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475"}, + {file = "coverage-6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57"}, + {file = "coverage-6.2-cp38-cp38-win32.whl", hash = "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c"}, + {file = "coverage-6.2-cp38-cp38-win_amd64.whl", hash = "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2"}, + {file = "coverage-6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd"}, + {file = "coverage-6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685"}, + {file = "coverage-6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c"}, + {file = "coverage-6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3"}, + {file = "coverage-6.2-cp39-cp39-win32.whl", hash = "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282"}, + {file = "coverage-6.2-cp39-cp39-win_amd64.whl", hash = "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644"}, + {file = "coverage-6.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de"}, + {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"}, ] cryptography = [ - {file = "cryptography-35.0.0-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:d57e0cdc1b44b6cdf8af1d01807db06886f10177469312fbde8f44ccbb284bc9"}, - {file = "cryptography-35.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:ced40344e811d6abba00295ced98c01aecf0c2de39481792d87af4fa58b7b4d6"}, - {file = "cryptography-35.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:54b2605e5475944e2213258e0ab8696f4f357a31371e538ef21e8d61c843c28d"}, - {file = "cryptography-35.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:7b7ceeff114c31f285528ba8b390d3e9cfa2da17b56f11d366769a807f17cbaa"}, - {file = "cryptography-35.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d69645f535f4b2c722cfb07a8eab916265545b3475fdb34e0be2f4ee8b0b15e"}, - {file = "cryptography-35.0.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2d0e0acc20ede0f06ef7aa58546eee96d2592c00f450c9acb89c5879b61992"}, - {file = "cryptography-35.0.0-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:07bb7fbfb5de0980590ddfc7f13081520def06dc9ed214000ad4372fb4e3c7f6"}, - {file = "cryptography-35.0.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7eba2cebca600a7806b893cb1d541a6e910afa87e97acf2021a22b32da1df52d"}, - {file = "cryptography-35.0.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:18d90f4711bf63e2fb21e8c8e51ed8189438e6b35a6d996201ebd98a26abbbe6"}, - {file = "cryptography-35.0.0-cp36-abi3-win32.whl", hash = "sha256:c10c797ac89c746e488d2ee92bd4abd593615694ee17b2500578b63cad6b93a8"}, - {file = "cryptography-35.0.0-cp36-abi3-win_amd64.whl", hash = "sha256:7075b304cd567694dc692ffc9747f3e9cb393cc4aa4fb7b9f3abd6f5c4e43588"}, - {file = "cryptography-35.0.0-pp36-pypy36_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a688ebcd08250eab5bb5bca318cc05a8c66de5e4171a65ca51db6bd753ff8953"}, - {file = "cryptography-35.0.0-pp36-pypy36_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d99915d6ab265c22873f1b4d6ea5ef462ef797b4140be4c9d8b179915e0985c6"}, - {file = "cryptography-35.0.0-pp36-pypy36_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:928185a6d1ccdb816e883f56ebe92e975a262d31cc536429041921f8cb5a62fd"}, - {file = "cryptography-35.0.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:ebeddd119f526bcf323a89f853afb12e225902a24d29b55fe18dd6fcb2838a76"}, - {file = "cryptography-35.0.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:22a38e96118a4ce3b97509443feace1d1011d0571fae81fc3ad35f25ba3ea999"}, - {file = "cryptography-35.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb80e8a1f91e4b7ef8b33041591e6d89b2b8e122d787e87eeb2b08da71bb16ad"}, - {file = "cryptography-35.0.0-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:abb5a361d2585bb95012a19ed9b2c8f412c5d723a9836418fab7aaa0243e67d2"}, - {file = "cryptography-35.0.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:1ed82abf16df40a60942a8c211251ae72858b25b7421ce2497c2eb7a1cee817c"}, - {file = "cryptography-35.0.0.tar.gz", hash = "sha256:9933f28f70d0517686bd7de36166dda42094eac49415459d9bdf5e7df3e0086d"}, + {file = "cryptography-36.0.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:9511416e85e449fe1de73f7f99b21b3aa04fba4c4d335d30c486ba3756e3a2a6"}, + {file = "cryptography-36.0.0-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:97199a13b772e74cdcdb03760c32109c808aff7cd49c29e9cf4b7754bb725d1d"}, + {file = "cryptography-36.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:494106e9cd945c2cadfce5374fa44c94cfadf01d4566a3b13bb487d2e6c7959e"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6fbbbb8aab4053fa018984bb0e95a16faeb051dd8cca15add2a27e267ba02b58"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:684993ff6f67000a56454b41bdc7e015429732d65a52d06385b6e9de6181c71e"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c702855cd3174666ef0d2d13dcc879090aa9c6c38f5578896407a7028f75b9f"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d91bc9f535599bed58f6d2e21a2724cb0c3895bf41c6403fe881391d29096f1d"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:b17d83b3d1610e571fedac21b2eb36b816654d6f7496004d6a0d32f99d1d8120"}, + {file = "cryptography-36.0.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8982c19bb90a4fa2aad3d635c6d71814e38b643649b4000a8419f8691f20ac44"}, + {file = "cryptography-36.0.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:24469d9d33217ffd0ce4582dfcf2a76671af115663a95328f63c99ec7ece61a4"}, + {file = "cryptography-36.0.0-cp36-abi3-win32.whl", hash = "sha256:f6a5a85beb33e57998dc605b9dbe7deaa806385fdf5c4810fb849fcd04640c81"}, + {file = "cryptography-36.0.0-cp36-abi3-win_amd64.whl", hash = "sha256:2deab5ec05d83ddcf9b0916319674d3dae88b0e7ee18f8962642d3cde0496568"}, + {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2049f8b87f449fc6190350de443ee0c1dd631f2ce4fa99efad2984de81031681"}, + {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a776bae1629c8d7198396fd93ec0265f8dd2341c553dc32b976168aaf0e6a636"}, + {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:aa94d617a4cd4cdf4af9b5af65100c036bce22280ebb15d8b5262e8273ebc6ba"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:5c49c9e8fb26a567a2b3fa0343c89f5d325447956cc2fc7231c943b29a973712"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef216d13ac8d24d9cd851776662f75f8d29c9f2d05cdcc2d34a18d32463a9b0b"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:231c4a69b11f6af79c1495a0e5a85909686ea8db946935224b7825cfb53827ed"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:f92556f94e476c1b616e6daec5f7ddded2c082efa7cee7f31c7aeda615906ed8"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d73e3a96c38173e0aa5646c31bf8473bc3564837977dd480f5cbeacf1d7ef3a3"}, + {file = "cryptography-36.0.0.tar.gz", hash = "sha256:52f769ecb4ef39865719aedc67b4b7eae167bafa48dbc2a26dd36fa56460507f"}, ] decorator = [ {file = "decorator-5.1.0-py3-none-any.whl", hash = "sha256:7b12e7c3c6ab203a29e157335e9122cb03de9ab7264b137594103fd4a683b374"}, {file = "decorator-5.1.0.tar.gz", hash = "sha256:e59913af105b9860aa2c8d3272d9de5a56a4e608db9a2f167a8480b323d529a7"}, ] distlib = [ - {file = "distlib-0.3.3-py2.py3-none-any.whl", hash = "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31"}, - {file = "distlib-0.3.3.zip", hash = "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05"}, + {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, + {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, ] fastapi = [ - {file = "fastapi-0.70.0-py3-none-any.whl", hash = "sha256:a36d5f2fad931aa3575c07a3472c784e81f3e664e3bb5c8b9c88d0ec1104f59c"}, - {file = "fastapi-0.70.0.tar.gz", hash = "sha256:66da43cfe5185ea1df99552acffd201f1832c6b364e0f4136c0a99f933466ced"}, + {file = "fastapi-0.70.1-py3-none-any.whl", hash = "sha256:5367226c7bcd7bfb2e17edaf225fd9a983095b1372281e9a3eb661336fb93748"}, + {file = "fastapi-0.70.1.tar.gz", hash = "sha256:21d03979b5336375c66fa5d1f3126c6beca650d5d2166fbb78345a30d33c8d06"}, ] filelock = [ - {file = "filelock-3.3.1-py3-none-any.whl", hash = "sha256:2b5eb3589e7fdda14599e7eb1a50e09b4cc14f34ed98b8ba56d33bfaafcbef2f"}, - {file = "filelock-3.3.1.tar.gz", hash = "sha256:34a9f35f95c441e7b38209775d6e0337f9a3759f3565f6c5798f19618527c76f"}, + {file = "filelock-3.4.0-py3-none-any.whl", hash = "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8"}, + {file = "filelock-3.4.0.tar.gz", hash = "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4"}, ] flake8 = [ {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, ] +frozenlist = [ + {file = "frozenlist-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:977a1438d0e0d96573fd679d291a1542097ea9f4918a8b6494b06610dfeefbf9"}, + {file = "frozenlist-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a8d86547a5e98d9edd47c432f7a14b0c5592624b496ae9880fb6332f34af1edc"}, + {file = "frozenlist-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:181754275d5d32487431a0a29add4f897968b7157204bc1eaaf0a0ce80c5ba7d"}, + {file = "frozenlist-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5df31bb2b974f379d230a25943d9bf0d3bc666b4b0807394b131a28fca2b0e5f"}, + {file = "frozenlist-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4766632cd8a68e4f10f156a12c9acd7b1609941525569dd3636d859d79279ed3"}, + {file = "frozenlist-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16eef427c51cb1203a7c0ab59d1b8abccaba9a4f58c4bfca6ed278fc896dc193"}, + {file = "frozenlist-1.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:01d79515ed5aa3d699b05f6bdcf1fe9087d61d6b53882aa599a10853f0479c6c"}, + {file = "frozenlist-1.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:28e164722ea0df0cf6d48c4d5bdf3d19e87aaa6dfb39b0ba91153f224b912020"}, + {file = "frozenlist-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e63ad0beef6ece06475d29f47d1f2f29727805376e09850ebf64f90777962792"}, + {file = "frozenlist-1.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:41de4db9b9501679cf7cddc16d07ac0f10ef7eb58c525a1c8cbff43022bddca4"}, + {file = "frozenlist-1.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c6a9d84ee6427b65a81fc24e6ef589cb794009f5ca4150151251c062773e7ed2"}, + {file = "frozenlist-1.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:f5f3b2942c3b8b9bfe76b408bbaba3d3bb305ee3693e8b1d631fe0a0d4f93673"}, + {file = "frozenlist-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c98d3c04701773ad60d9545cd96df94d955329efc7743fdb96422c4b669c633b"}, + {file = "frozenlist-1.2.0-cp310-cp310-win32.whl", hash = "sha256:72cfbeab7a920ea9e74b19aa0afe3b4ad9c89471e3badc985d08756efa9b813b"}, + {file = "frozenlist-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:11ff401951b5ac8c0701a804f503d72c048173208490c54ebb8d7bb7c07a6d00"}, + {file = "frozenlist-1.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b46f997d5ed6d222a863b02cdc9c299101ee27974d9bbb2fd1b3c8441311c408"}, + {file = "frozenlist-1.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351686ca020d1bcd238596b1fa5c8efcbc21bffda9d0efe237aaa60348421e2a"}, + {file = "frozenlist-1.2.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfbaa08cf1452acad9cb1c1d7b89394a41e712f88df522cea1a0f296b57782a0"}, + {file = "frozenlist-1.2.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2ae2f5e9fa10805fb1c9adbfefaaecedd9e31849434be462c3960a0139ed729"}, + {file = "frozenlist-1.2.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6790b8d96bbb74b7a6f4594b6f131bd23056c25f2aa5d816bd177d95245a30e3"}, + {file = "frozenlist-1.2.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:41f62468af1bd4e4b42b5508a3fe8cc46a693f0cdd0ca2f443f51f207893d837"}, + {file = "frozenlist-1.2.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:ec6cf345771cdb00791d271af9a0a6fbfc2b6dd44cb753f1eeaa256e21622adb"}, + {file = "frozenlist-1.2.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:14a5cef795ae3e28fb504b73e797c1800e9249f950e1c964bb6bdc8d77871161"}, + {file = "frozenlist-1.2.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:8b54cdd2fda15467b9b0bfa78cee2ddf6dbb4585ef23a16e14926f4b076dfae4"}, + {file = "frozenlist-1.2.0-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:f025f1d6825725b09c0038775acab9ae94264453a696cc797ce20c0769a7b367"}, + {file = "frozenlist-1.2.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:84e97f59211b5b9083a2e7a45abf91cfb441369e8bb6d1f5287382c1c526def3"}, + {file = "frozenlist-1.2.0-cp36-cp36m-win32.whl", hash = "sha256:c5328ed53fdb0a73c8a50105306a3bc013e5ca36cca714ec4f7bd31d38d8a97f"}, + {file = "frozenlist-1.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:9ade70aea559ca98f4b1b1e5650c45678052e76a8ab2f76d90f2ac64180215a2"}, + {file = "frozenlist-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0d3ffa8772464441b52489b985d46001e2853a3b082c655ec5fad9fb6a3d618"}, + {file = "frozenlist-1.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3457f8cf86deb6ce1ba67e120f1b0128fcba1332a180722756597253c465fc1d"}, + {file = "frozenlist-1.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a72eecf37eface331636951249d878750db84034927c997d47f7f78a573b72b"}, + {file = "frozenlist-1.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:acc4614e8d1feb9f46dd829a8e771b8f5c4b1051365d02efb27a3229048ade8a"}, + {file = "frozenlist-1.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:87521e32e18a2223311afc2492ef2d99946337da0779ddcda77b82ee7319df59"}, + {file = "frozenlist-1.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8b4c7665a17c3a5430edb663e4ad4e1ad457614d1b2f2b7f87052e2ef4fa45ca"}, + {file = "frozenlist-1.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ed58803563a8c87cf4c0771366cf0ad1aa265b6b0ae54cbbb53013480c7ad74d"}, + {file = "frozenlist-1.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:aa44c4740b4e23fcfa259e9dd52315d2b1770064cde9507457e4c4a65a04c397"}, + {file = "frozenlist-1.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:2de5b931701257d50771a032bba4e448ff958076380b049fd36ed8738fdb375b"}, + {file = "frozenlist-1.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:6e105013fa84623c057a4381dc8ea0361f4d682c11f3816cc80f49a1f3bc17c6"}, + {file = "frozenlist-1.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:705c184b77565955a99dc360f359e8249580c6b7eaa4dc0227caa861ef46b27a"}, + {file = "frozenlist-1.2.0-cp37-cp37m-win32.whl", hash = "sha256:a37594ad6356e50073fe4f60aa4187b97d15329f2138124d252a5a19c8553ea4"}, + {file = "frozenlist-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:25b358aaa7dba5891b05968dd539f5856d69f522b6de0bf34e61f133e077c1a4"}, + {file = "frozenlist-1.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af2a51c8a381d76eabb76f228f565ed4c3701441ecec101dd18be70ebd483cfd"}, + {file = "frozenlist-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:82d22f6e6f2916e837c91c860140ef9947e31194c82aaeda843d6551cec92f19"}, + {file = "frozenlist-1.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1cfe6fef507f8bac40f009c85c7eddfed88c1c0d38c75e72fe10476cef94e10f"}, + {file = "frozenlist-1.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f602e380a5132880fa245c92030abb0fc6ff34e0c5500600366cedc6adb06a"}, + {file = "frozenlist-1.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ad065b2ebd09f32511ff2be35c5dfafee6192978b5a1e9d279a5c6e121e3b03"}, + {file = "frozenlist-1.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc93f5f62df3bdc1f677066327fc81f92b83644852a31c6aa9b32c2dde86ea7d"}, + {file = "frozenlist-1.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:89fdfc84c6bf0bff2ff3170bb34ecba8a6911b260d318d377171429c4be18c73"}, + {file = "frozenlist-1.2.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:47b2848e464883d0bbdcd9493c67443e5e695a84694efff0476f9059b4cb6257"}, + {file = "frozenlist-1.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4f52d0732e56906f8ddea4bd856192984650282424049c956857fed43697ea43"}, + {file = "frozenlist-1.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:16ef7dd5b7d17495404a2e7a49bac1bc13d6d20c16d11f4133c757dd94c4144c"}, + {file = "frozenlist-1.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:1cf63243bc5f5c19762943b0aa9e0d3fb3723d0c514d820a18a9b9a5ef864315"}, + {file = "frozenlist-1.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:54a1e09ab7a69f843cd28fefd2bcaf23edb9e3a8d7680032c8968b8ac934587d"}, + {file = "frozenlist-1.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:954b154a4533ef28bd3e83ffdf4eadf39deeda9e38fb8feaf066d6069885e034"}, + {file = "frozenlist-1.2.0-cp38-cp38-win32.whl", hash = "sha256:cb3957c39668d10e2b486acc85f94153520a23263b6401e8f59422ef65b9520d"}, + {file = "frozenlist-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:0a7c7cce70e41bc13d7d50f0e5dd175f14a4f1837a8549b0936ed0cbe6170bf9"}, + {file = "frozenlist-1.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4c457220468d734e3077580a3642b7f682f5fd9507f17ddf1029452450912cdc"}, + {file = "frozenlist-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e74f8b4d8677ebb4015ac01fcaf05f34e8a1f22775db1f304f497f2f88fdc697"}, + {file = "frozenlist-1.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fbd4844ff111449f3bbe20ba24fbb906b5b1c2384d0f3287c9f7da2354ce6d23"}, + {file = "frozenlist-1.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0081a623c886197ff8de9e635528fd7e6a387dccef432149e25c13946cb0cd0"}, + {file = "frozenlist-1.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b6e21e5770df2dea06cb7b6323fbc008b13c4a4e3b52cb54685276479ee7676"}, + {file = "frozenlist-1.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:406aeb340613b4b559db78d86864485f68919b7141dec82aba24d1477fd2976f"}, + {file = "frozenlist-1.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:878ebe074839d649a1cdb03a61077d05760624f36d196884a5cafb12290e187b"}, + {file = "frozenlist-1.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1fef737fd1388f9b93bba8808c5f63058113c10f4e3c0763ced68431773f72f9"}, + {file = "frozenlist-1.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4a495c3d513573b0b3f935bfa887a85d9ae09f0627cf47cad17d0cc9b9ba5c38"}, + {file = "frozenlist-1.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e7d0dd3e727c70c2680f5f09a0775525229809f1a35d8552b92ff10b2b14f2c2"}, + {file = "frozenlist-1.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:66a518731a21a55b7d3e087b430f1956a36793acc15912e2878431c7aec54210"}, + {file = "frozenlist-1.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:94728f97ddf603d23c8c3dd5cae2644fa12d33116e69f49b1644a71bb77b89ae"}, + {file = "frozenlist-1.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c1e8e9033d34c2c9e186e58279879d78c94dd365068a3607af33f2bc99357a53"}, + {file = "frozenlist-1.2.0-cp39-cp39-win32.whl", hash = "sha256:83334e84a290a158c0c4cc4d22e8c7cfe0bba5b76d37f1c2509dabd22acafe15"}, + {file = "frozenlist-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:735f386ec522e384f511614c01d2ef9cf799f051353876b4c6fb93ef67a6d1ee"}, + {file = "frozenlist-1.2.0.tar.gz", hash = "sha256:68201be60ac56aff972dc18085800b6ee07973c49103a8aba669dee3d71079de"}, +] h11 = [ {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, ] httpcore = [ - {file = "httpcore-0.13.7-py3-none-any.whl", hash = "sha256:369aa481b014cf046f7067fddd67d00560f2f00426e79569d99cb11245134af0"}, - {file = "httpcore-0.13.7.tar.gz", hash = "sha256:036f960468759e633574d7c121afba48af6419615d36ab8ede979f1ad6276fa3"}, + {file = "httpcore-0.14.3-py3-none-any.whl", hash = "sha256:9a98d2416b78976fc5396ff1f6b26ae9885efbb3105d24eed490f20ab4c95ec1"}, + {file = "httpcore-0.14.3.tar.gz", hash = "sha256:d10162a63265a0228d5807964bd964478cbdb5178f9a2eedfebb2faba27eef5d"}, ] httpx = [ - {file = "httpx-0.20.0-py3-none-any.whl", hash = "sha256:33af5aad9bdc82ef1fc89219c1e36f5693bf9cd0ebe330884df563445682c0f8"}, - {file = "httpx-0.20.0.tar.gz", hash = "sha256:09606d630f070d07f9ff28104fbcea429ea0014c1e89ac90b4d8de8286c40e7b"}, + {file = "httpx-0.21.1-py3-none-any.whl", hash = "sha256:208e5ef2ad4d105213463cfd541898ed9d11851b346473539a8425e644bb7c66"}, + {file = "httpx-0.21.1.tar.gz", hash = "sha256:02af20df486b78892a614a7ccd4e4e86a5409ec4981ab0e422c579a887acad83"}, ] identify = [ - {file = "identify-2.3.0-py2.py3-none-any.whl", hash = "sha256:d1e82c83d063571bb88087676f81261a4eae913c492dafde184067c584bc7c05"}, - {file = "identify-2.3.0.tar.gz", hash = "sha256:fd08c97f23ceee72784081f1ce5125c8f53a02d3f2716dde79a6ab8f1039fea5"}, + {file = "identify-2.4.0-py2.py3-none-any.whl", hash = "sha256:eba31ca80258de6bb51453084bff4a923187cd2193b9c13710f2516ab30732cc"}, + {file = "identify-2.4.0.tar.gz", hash = "sha256:a33ae873287e81651c7800ca309dc1f84679b763c9c8b30680e16fbfa82f0107"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, @@ -1217,16 +1360,16 @@ ipdb = [ {file = "ipdb-0.13.9.tar.gz", hash = "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5"}, ] ipython = [ - {file = "ipython-7.28.0-py3-none-any.whl", hash = "sha256:f16148f9163e1e526f1008d7c8d966d9c15600ca20d1a754287cf96d00ba6f1d"}, - {file = "ipython-7.28.0.tar.gz", hash = "sha256:2097be5c814d1b974aea57673176a924c4c8c9583890e7a5f082f547b9975b11"}, + {file = "ipython-7.30.1-py3-none-any.whl", hash = "sha256:fc60ef843e0863dd4e24ab2bb5698f071031332801ecf8d1aeb4fb622056545c"}, + {file = "ipython-7.30.1.tar.gz", hash = "sha256:cb6aef731bf708a7727ab6cde8df87f0281b1427d41e65d62d4b68934fa54e97"}, ] isort = [ - {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"}, - {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"}, + {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, + {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, ] jedi = [ - {file = "jedi-0.18.0-py2.py3-none-any.whl", hash = "sha256:18456d83f65f400ab0c2d3319e48520420ef43b23a086fdc05dff34132f0fb93"}, - {file = "jedi-0.18.0.tar.gz", hash = "sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707"}, + {file = "jedi-0.18.1-py2.py3-none-any.whl", hash = "sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d"}, + {file = "jedi-0.18.1.tar.gz", hash = "sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"}, ] jinja2 = [ {file = "Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419"}, @@ -1362,12 +1505,12 @@ nodeenv = [ {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, ] packaging = [ - {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, - {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] parso = [ - {file = "parso-0.8.2-py2.py3-none-any.whl", hash = "sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22"}, - {file = "parso-0.8.2.tar.gz", hash = "sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398"}, + {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, + {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, ] pathspec = [ {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, @@ -1390,28 +1533,28 @@ pluggy = [ {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] pre-commit = [ - {file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"}, - {file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"}, + {file = "pre_commit-2.16.0-py2.py3-none-any.whl", hash = "sha256:758d1dc9b62c2ed8881585c254976d66eae0889919ab9b859064fc2fe3c7743e"}, + {file = "pre_commit-2.16.0.tar.gz", hash = "sha256:fe9897cac830aa7164dbd02a4e7b90cae49630451ce88464bca73db486ba9f65"}, ] prompt-toolkit = [ - {file = "prompt_toolkit-3.0.20-py3-none-any.whl", hash = "sha256:6076e46efae19b1e0ca1ec003ed37a933dc94b4d20f486235d436e64771dcd5c"}, - {file = "prompt_toolkit-3.0.20.tar.gz", hash = "sha256:eb71d5a6b72ce6db177af4a7d4d7085b99756bf656d98ffcc4fecd36850eea6c"}, + {file = "prompt_toolkit-3.0.24-py3-none-any.whl", hash = "sha256:e56f2ff799bacecd3e88165b1e2f5ebf9bcd59e80e06d395fa0cc4b8bd7bb506"}, + {file = "prompt_toolkit-3.0.24.tar.gz", hash = "sha256:1bb05628c7d87b645974a1bad3f17612be0c29fa39af9f7688030163f680bad6"}, ] ptyprocess = [ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, ] py = [ - {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, - {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] pycodestyle = [ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] pycparser = [ - {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, - {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] pydantic = [ {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, @@ -1446,8 +1589,8 @@ pygments = [ {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, ] pyparsing = [ - {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, - {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, + {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, + {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, ] pytest = [ {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, @@ -1500,47 +1643,80 @@ pyyaml = [ {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] regex = [ - {file = "regex-2021.10.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:981c786293a3115bc14c103086ae54e5ee50ca57f4c02ce7cf1b60318d1e8072"}, - {file = "regex-2021.10.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51feefd58ac38eb91a21921b047da8644155e5678e9066af7bcb30ee0dca7361"}, - {file = "regex-2021.10.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea8de658d7db5987b11097445f2b1f134400e2232cb40e614e5f7b6f5428710e"}, - {file = "regex-2021.10.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1ce02f420a7ec3b2480fe6746d756530f69769292eca363218c2291d0b116a01"}, - {file = "regex-2021.10.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39079ebf54156be6e6902f5c70c078f453350616cfe7bfd2dd15bdb3eac20ccc"}, - {file = "regex-2021.10.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ff24897f6b2001c38a805d53b6ae72267025878d35ea225aa24675fbff2dba7f"}, - {file = "regex-2021.10.8-cp310-cp310-win32.whl", hash = "sha256:c6569ba7b948c3d61d27f04e2b08ebee24fec9ff8e9ea154d8d1e975b175bfa7"}, - {file = "regex-2021.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:45cb0f7ff782ef51bc79e227a87e4e8f24bc68192f8de4f18aae60b1d60bc152"}, - {file = "regex-2021.10.8-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:fab3ab8aedfb443abb36729410403f0fe7f60ad860c19a979d47fb3eb98ef820"}, - {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74e55f8d66f1b41d44bc44c891bcf2c7fad252f8f323ee86fba99d71fd1ad5e3"}, - {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d52c5e089edbdb6083391faffbe70329b804652a53c2fdca3533e99ab0580d9"}, - {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1abbd95cbe9e2467cac65c77b6abd9223df717c7ae91a628502de67c73bf6838"}, - {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9b5c215f3870aa9b011c00daeb7be7e1ae4ecd628e9beb6d7e6107e07d81287"}, - {file = "regex-2021.10.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f540f153c4f5617bc4ba6433534f8916d96366a08797cbbe4132c37b70403e92"}, - {file = "regex-2021.10.8-cp36-cp36m-win32.whl", hash = "sha256:1f51926db492440e66c89cd2be042f2396cf91e5b05383acd7372b8cb7da373f"}, - {file = "regex-2021.10.8-cp36-cp36m-win_amd64.whl", hash = "sha256:5f55c4804797ef7381518e683249310f7f9646da271b71cb6b3552416c7894ee"}, - {file = "regex-2021.10.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb2baff66b7d2267e07ef71e17d01283b55b3cc51a81b54cc385e721ae172ba4"}, - {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e527ab1c4c7cf2643d93406c04e1d289a9d12966529381ce8163c4d2abe4faf"}, - {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36c98b013273e9da5790ff6002ab326e3f81072b4616fd95f06c8fa733d2745f"}, - {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:55ef044899706c10bc0aa052f2fc2e58551e2510694d6aae13f37c50f3f6ff61"}, - {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0ab3530a279a3b7f50f852f1bab41bc304f098350b03e30a3876b7dd89840e"}, - {file = "regex-2021.10.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a37305eb3199d8f0d8125ec2fb143ba94ff6d6d92554c4b8d4a8435795a6eccd"}, - {file = "regex-2021.10.8-cp37-cp37m-win32.whl", hash = "sha256:2efd47704bbb016136fe34dfb74c805b1ef5c7313aef3ce6dcb5ff844299f432"}, - {file = "regex-2021.10.8-cp37-cp37m-win_amd64.whl", hash = "sha256:924079d5590979c0e961681507eb1773a142553564ccae18d36f1de7324e71ca"}, - {file = "regex-2021.10.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b09d3904bf312d11308d9a2867427479d277365b1617e48ad09696fa7dfcdf59"}, - {file = "regex-2021.10.8-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f125fce0a0ae4fd5c3388d369d7a7d78f185f904c90dd235f7ecf8fe13fa741"}, - {file = "regex-2021.10.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f199419a81c1016e0560c39773c12f0bd924c37715bffc64b97140d2c314354"}, - {file = "regex-2021.10.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:09e1031e2059abd91177c302da392a7b6859ceda038be9e015b522a182c89e4f"}, - {file = "regex-2021.10.8-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c070d5895ac6aeb665bd3cd79f673775caf8d33a0b569e98ac434617ecea57d"}, - {file = "regex-2021.10.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:176796cb7f82a7098b0c436d6daac82f57b9101bb17b8e8119c36eecf06a60a3"}, - {file = "regex-2021.10.8-cp38-cp38-win32.whl", hash = "sha256:5e5796d2f36d3c48875514c5cd9e4325a1ca172fc6c78b469faa8ddd3d770593"}, - {file = "regex-2021.10.8-cp38-cp38-win_amd64.whl", hash = "sha256:e4204708fa116dd03436a337e8e84261bc8051d058221ec63535c9403a1582a1"}, - {file = "regex-2021.10.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b8b6ee6555b6fbae578f1468b3f685cdfe7940a65675611365a7ea1f8d724991"}, - {file = "regex-2021.10.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:973499dac63625a5ef9dfa4c791aa33a502ddb7615d992bdc89cf2cc2285daa3"}, - {file = "regex-2021.10.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88dc3c1acd3f0ecfde5f95c32fcb9beda709dbdf5012acdcf66acbc4794468eb"}, - {file = "regex-2021.10.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4786dae85c1f0624ac77cb3813ed99267c9adb72e59fdc7297e1cf4d6036d493"}, - {file = "regex-2021.10.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe6ce4f3d3c48f9f402da1ceb571548133d3322003ce01b20d960a82251695d2"}, - {file = "regex-2021.10.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9e3e2cea8f1993f476a6833ef157f5d9e8c75a59a8d8b0395a9a6887a097243b"}, - {file = "regex-2021.10.8-cp39-cp39-win32.whl", hash = "sha256:82cfb97a36b1a53de32b642482c6c46b6ce80803854445e19bc49993655ebf3b"}, - {file = "regex-2021.10.8-cp39-cp39-win_amd64.whl", hash = "sha256:b04e512eb628ea82ed86eb31c0f7fc6842b46bf2601b66b1356a7008327f7700"}, - {file = "regex-2021.10.8.tar.gz", hash = "sha256:26895d7c9bbda5c52b3635ce5991caa90fbb1ddfac9c9ff1c7ce505e2282fb2a"}, + {file = "regex-2021.11.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9345b6f7ee578bad8e475129ed40123d265464c4cfead6c261fd60fc9de00bcf"}, + {file = "regex-2021.11.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:416c5f1a188c91e3eb41e9c8787288e707f7d2ebe66e0a6563af280d9b68478f"}, + {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0538c43565ee6e703d3a7c3bdfe4037a5209250e8502c98f20fea6f5fdf2965"}, + {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee1227cf08b6716c85504aebc49ac827eb88fcc6e51564f010f11a406c0a667"}, + {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6650f16365f1924d6014d2ea770bde8555b4a39dc9576abb95e3cd1ff0263b36"}, + {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30ab804ea73972049b7a2a5c62d97687d69b5a60a67adca07eb73a0ddbc9e29f"}, + {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68a067c11463de2a37157930d8b153005085e42bcb7ad9ca562d77ba7d1404e0"}, + {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:162abfd74e88001d20cb73ceaffbfe601469923e875caf9118333b1a4aaafdc4"}, + {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9ed0b1e5e0759d6b7f8e2f143894b2a7f3edd313f38cf44e1e15d360e11749b"}, + {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:473e67837f786404570eae33c3b64a4b9635ae9f00145250851a1292f484c063"}, + {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2fee3ed82a011184807d2127f1733b4f6b2ff6ec7151d83ef3477f3b96a13d03"}, + {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d5fd67df77bab0d3f4ea1d7afca9ef15c2ee35dfb348c7b57ffb9782a6e4db6e"}, + {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5d408a642a5484b9b4d11dea15a489ea0928c7e410c7525cd892f4d04f2f617b"}, + {file = "regex-2021.11.10-cp310-cp310-win32.whl", hash = "sha256:98ba568e8ae26beb726aeea2273053c717641933836568c2a0278a84987b2a1a"}, + {file = "regex-2021.11.10-cp310-cp310-win_amd64.whl", hash = "sha256:780b48456a0f0ba4d390e8b5f7c661fdd218934388cde1a974010a965e200e12"}, + {file = "regex-2021.11.10-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:dba70f30fd81f8ce6d32ddeef37d91c8948e5d5a4c63242d16a2b2df8143aafc"}, + {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1f54b9b4b6c53369f40028d2dd07a8c374583417ee6ec0ea304e710a20f80a0"}, + {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fbb9dc00e39f3e6c0ef48edee202f9520dafb233e8b51b06b8428cfcb92abd30"}, + {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666abff54e474d28ff42756d94544cdfd42e2ee97065857413b72e8a2d6a6345"}, + {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5537f71b6d646f7f5f340562ec4c77b6e1c915f8baae822ea0b7e46c1f09b733"}, + {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2e07c6a26ed4bea91b897ee2b0835c21716d9a469a96c3e878dc5f8c55bb23"}, + {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ca5f18a75e1256ce07494e245cdb146f5a9267d3c702ebf9b65c7f8bd843431e"}, + {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:74cbeac0451f27d4f50e6e8a8f3a52ca074b5e2da9f7b505c4201a57a8ed6286"}, + {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:3598893bde43091ee5ca0a6ad20f08a0435e93a69255eeb5f81b85e81e329264"}, + {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:50a7ddf3d131dc5633dccdb51417e2d1910d25cbcf842115a3a5893509140a3a"}, + {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:61600a7ca4bcf78a96a68a27c2ae9389763b5b94b63943d5158f2a377e09d29a"}, + {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:563d5f9354e15e048465061509403f68424fef37d5add3064038c2511c8f5e00"}, + {file = "regex-2021.11.10-cp36-cp36m-win32.whl", hash = "sha256:93a5051fcf5fad72de73b96f07d30bc29665697fb8ecdfbc474f3452c78adcf4"}, + {file = "regex-2021.11.10-cp36-cp36m-win_amd64.whl", hash = "sha256:b483c9d00a565633c87abd0aaf27eb5016de23fed952e054ecc19ce32f6a9e7e"}, + {file = "regex-2021.11.10-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fff55f3ce50a3ff63ec8e2a8d3dd924f1941b250b0aac3d3d42b687eeff07a8e"}, + {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e32d2a2b02ccbef10145df9135751abea1f9f076e67a4e261b05f24b94219e36"}, + {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53db2c6be8a2710b359bfd3d3aa17ba38f8aa72a82309a12ae99d3c0c3dcd74d"}, + {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2207ae4f64ad3af399e2d30dde66f0b36ae5c3129b52885f1bffc2f05ec505c8"}, + {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5ca078bb666c4a9d1287a379fe617a6dccd18c3e8a7e6c7e1eb8974330c626a"}, + {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd33eb9bdcfbabab3459c9ee651d94c842bc8a05fabc95edf4ee0c15a072495e"}, + {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:05b7d6d7e64efe309972adab77fc2af8907bb93217ec60aa9fe12a0dad35874f"}, + {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:42b50fa6666b0d50c30a990527127334d6b96dd969011e843e726a64011485da"}, + {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6e1d2cc79e8dae442b3fa4a26c5794428b98f81389af90623ffcc650ce9f6732"}, + {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:0416f7399e918c4b0e074a0f66e5191077ee2ca32a0f99d4c187a62beb47aa05"}, + {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:ce298e3d0c65bd03fa65ffcc6db0e2b578e8f626d468db64fdf8457731052942"}, + {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dc07f021ee80510f3cd3af2cad5b6a3b3a10b057521d9e6aaeb621730d320c5a"}, + {file = "regex-2021.11.10-cp37-cp37m-win32.whl", hash = "sha256:e71255ba42567d34a13c03968736c5d39bb4a97ce98188fafb27ce981115beec"}, + {file = "regex-2021.11.10-cp37-cp37m-win_amd64.whl", hash = "sha256:07856afef5ffcc052e7eccf3213317fbb94e4a5cd8177a2caa69c980657b3cb4"}, + {file = "regex-2021.11.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba05430e819e58544e840a68b03b28b6d328aff2e41579037e8bab7653b37d83"}, + {file = "regex-2021.11.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7f301b11b9d214f83ddaf689181051e7f48905568b0c7017c04c06dfd065e244"}, + {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aaa4e0705ef2b73dd8e36eeb4c868f80f8393f5f4d855e94025ce7ad8525f50"}, + {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:788aef3549f1924d5c38263104dae7395bf020a42776d5ec5ea2b0d3d85d6646"}, + {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8af619e3be812a2059b212064ea7a640aff0568d972cd1b9e920837469eb3cb"}, + {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85bfa6a5413be0ee6c5c4a663668a2cad2cbecdee367630d097d7823041bdeec"}, + {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f23222527b307970e383433daec128d769ff778d9b29343fb3496472dc20dabe"}, + {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:da1a90c1ddb7531b1d5ff1e171b4ee61f6345119be7351104b67ff413843fe94"}, + {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f5be7805e53dafe94d295399cfbe5227f39995a997f4fd8539bf3cbdc8f47ca8"}, + {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a955b747d620a50408b7fdf948e04359d6e762ff8a85f5775d907ceced715129"}, + {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:139a23d1f5d30db2cc6c7fd9c6d6497872a672db22c4ae1910be22d4f4b2068a"}, + {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ca49e1ab99593438b204e00f3970e7a5f70d045267051dfa6b5f4304fcfa1dbf"}, + {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:96fc32c16ea6d60d3ca7f63397bff5c75c5a562f7db6dec7d412f7c4d2e78ec0"}, + {file = "regex-2021.11.10-cp38-cp38-win32.whl", hash = "sha256:0617383e2fe465732af4509e61648b77cbe3aee68b6ac8c0b6fe934db90be5cc"}, + {file = "regex-2021.11.10-cp38-cp38-win_amd64.whl", hash = "sha256:a3feefd5e95871872673b08636f96b61ebef62971eab044f5124fb4dea39919d"}, + {file = "regex-2021.11.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7f325be2804246a75a4f45c72d4ce80d2443ab815063cdf70ee8fb2ca59ee1b"}, + {file = "regex-2021.11.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:537ca6a3586931b16a85ac38c08cc48f10fc870a5b25e51794c74df843e9966d"}, + {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eef2afb0fd1747f33f1ee3e209bce1ed582d1896b240ccc5e2697e3275f037c7"}, + {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:432bd15d40ed835a51617521d60d0125867f7b88acf653e4ed994a1f8e4995dc"}, + {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b43c2b8a330a490daaef5a47ab114935002b13b3f9dc5da56d5322ff218eeadb"}, + {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:962b9a917dd7ceacbe5cd424556914cb0d636001e393b43dc886ba31d2a1e449"}, + {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fa8c626d6441e2d04b6ee703ef2d1e17608ad44c7cb75258c09dd42bacdfc64b"}, + {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3c5fb32cc6077abad3bbf0323067636d93307c9fa93e072771cf9a64d1c0f3ef"}, + {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cd410a1cbb2d297c67d8521759ab2ee3f1d66206d2e4328502a487589a2cb21b"}, + {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e6096b0688e6e14af6a1b10eaad86b4ff17935c49aa774eac7c95a57a4e8c296"}, + {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:529801a0d58809b60b3531ee804d3e3be4b412c94b5d267daa3de7fadef00f49"}, + {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f594b96fe2e0821d026365f72ac7b4f0b487487fb3d4aaf10dd9d97d88a9737"}, + {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2409b5c9cef7054dde93a9803156b411b677affc84fca69e908b1cb2c540025d"}, + {file = "regex-2021.11.10-cp39-cp39-win32.whl", hash = "sha256:3b5df18db1fccd66de15aa59c41e4f853b5df7550723d26aa6cb7f40e5d9da5a"}, + {file = "regex-2021.11.10-cp39-cp39-win_amd64.whl", hash = "sha256:83ee89483672b11f8952b158640d0c0ff02dc43d9cb1b70c1564b49abe92ce29"}, + {file = "regex-2021.11.10.tar.gz", hash = "sha256:f341ee2df0999bfdf7a95e448075effe0db212a59387de1a70690e4acb03d4c6"}, ] rfc3986 = [ {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, @@ -1574,129 +1750,117 @@ tox-poetry = [ {file = "tox_poetry-0.4.1-py2.py3-none-any.whl", hash = "sha256:11d9cd4e51d4cd9484b3ba63f2650ab4cfb4096e5f0682ecf561ddfc3c8e8c92"}, ] traitlets = [ - {file = "traitlets-5.1.0-py3-none-any.whl", hash = "sha256:03f172516916220b58c9f19d7f854734136dd9528103d04e9bf139a92c9f54c4"}, - {file = "traitlets-5.1.0.tar.gz", hash = "sha256:bd382d7ea181fbbcce157c133db9a829ce06edffe097bcf3ab945b435452b46d"}, + {file = "traitlets-5.1.1-py3-none-any.whl", hash = "sha256:2d313cc50a42cd6c277e7d7dc8d4d7fedd06a2c215f78766ae7b1a66277e0033"}, + {file = "traitlets-5.1.1.tar.gz", hash = "sha256:059f456c5a7c1c82b98c2e8c799f39c9b8128f6d0d46941ee118daace9eb70c7"}, ] typed-ast = [ - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, - {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, - {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, - {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, - {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, - {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, - {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, - {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, - {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, - {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, - {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, - {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, - {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, - {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, - {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, - {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, + {file = "typed_ast-1.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d8314c92414ce7481eee7ad42b353943679cf6f30237b5ecbf7d835519e1212"}, + {file = "typed_ast-1.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b53ae5de5500529c76225d18eeb060efbcec90ad5e030713fe8dab0fb4531631"}, + {file = "typed_ast-1.5.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:24058827d8f5d633f97223f5148a7d22628099a3d2efe06654ce872f46f07cdb"}, + {file = "typed_ast-1.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:a6d495c1ef572519a7bac9534dbf6d94c40e5b6a608ef41136133377bba4aa08"}, + {file = "typed_ast-1.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:de4ecae89c7d8b56169473e08f6bfd2df7f95015591f43126e4ea7865928677e"}, + {file = "typed_ast-1.5.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:256115a5bc7ea9e665c6314ed6671ee2c08ca380f9d5f130bd4d2c1f5848d695"}, + {file = "typed_ast-1.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:7c42707ab981b6cf4b73490c16e9d17fcd5227039720ca14abe415d39a173a30"}, + {file = "typed_ast-1.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:71dcda943a471d826ea930dd449ac7e76db7be778fcd722deb63642bab32ea3f"}, + {file = "typed_ast-1.5.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4f30a2bcd8e68adbb791ce1567fdb897357506f7ea6716f6bbdd3053ac4d9471"}, + {file = "typed_ast-1.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ca9e8300d8ba0b66d140820cf463438c8e7b4cdc6fd710c059bfcfb1531d03fb"}, + {file = "typed_ast-1.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9caaf2b440efb39ecbc45e2fabde809cbe56272719131a6318fd9bf08b58e2cb"}, + {file = "typed_ast-1.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c9bcad65d66d594bffab8575f39420fe0ee96f66e23c4d927ebb4e24354ec1af"}, + {file = "typed_ast-1.5.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:591bc04e507595887160ed7aa8d6785867fb86c5793911be79ccede61ae96f4d"}, + {file = "typed_ast-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:a80d84f535642420dd17e16ae25bb46c7f4c16ee231105e7f3eb43976a89670a"}, + {file = "typed_ast-1.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:38cf5c642fa808300bae1281460d4f9b7617cf864d4e383054a5ef336e344d32"}, + {file = "typed_ast-1.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b6ab14c56bc9c7e3c30228a0a0b54b915b1579613f6e463ba6f4eb1382e7fd4"}, + {file = "typed_ast-1.5.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a2b8d7007f6280e36fa42652df47087ac7b0a7d7f09f9468f07792ba646aac2d"}, + {file = "typed_ast-1.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:b6d17f37f6edd879141e64a5db17b67488cfeffeedad8c5cec0392305e9bc775"}, + {file = "typed_ast-1.5.1.tar.gz", hash = "sha256:484137cab8ecf47e137260daa20bafbba5f4e3ec7fda1c1e69ab299b75fa81c5"}, ] typing-extensions = [ - {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, - {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, - {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, + {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, + {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, ] uvicorn = [ - {file = "uvicorn-0.15.0-py3-none-any.whl", hash = "sha256:17f898c64c71a2640514d4089da2689e5db1ce5d4086c2d53699bf99513421c1"}, - {file = "uvicorn-0.15.0.tar.gz", hash = "sha256:d9a3c0dd1ca86728d3e235182683b4cf94cd53a867c288eaeca80ee781b2caff"}, + {file = "uvicorn-0.16.0-py3-none-any.whl", hash = "sha256:d8c839231f270adaa6d338d525e2652a0b4a5f4c2430b5c4ef6ae4d11776b0d2"}, + {file = "uvicorn-0.16.0.tar.gz", hash = "sha256:eacb66afa65e0648fcbce5e746b135d09722231ffffc61883d4fac2b62fbea8d"}, ] virtualenv = [ - {file = "virtualenv-20.8.1-py2.py3-none-any.whl", hash = "sha256:10062e34c204b5e4ec5f62e6ef2473f8ba76513a9a617e873f1f8fb4a519d300"}, - {file = "virtualenv-20.8.1.tar.gz", hash = "sha256:bcc17f0b3a29670dd777d6f0755a4c04f28815395bca279cdcb213b97199a6b8"}, + {file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"}, + {file = "virtualenv-20.10.0.tar.gz", hash = "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, ] yarl = [ - {file = "yarl-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e35d8230e4b08d86ea65c32450533b906a8267a87b873f2954adeaecede85169"}, - {file = "yarl-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb4b3f277880c314e47720b4b6bb2c85114ab3c04c5442c9bc7006b3787904d8"}, - {file = "yarl-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7015dcedb91d90a138eebdc7e432aec8966e0147ab2a55f2df27b1904fa7291"}, - {file = "yarl-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb3e478175e15e00d659fb0354a6a8db71a7811a2a5052aed98048bc972e5d2b"}, - {file = "yarl-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8c409aa3a7966647e7c1c524846b362a6bcbbe120bf8a176431f940d2b9a2e"}, - {file = "yarl-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b22ea41c7e98170474a01e3eded1377d46b2dfaef45888a0005c683eaaa49285"}, - {file = "yarl-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a7dfc46add4cfe5578013dbc4127893edc69fe19132d2836ff2f6e49edc5ecd6"}, - {file = "yarl-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:82ff6f85f67500a4f74885d81659cd270eb24dfe692fe44e622b8a2fd57e7279"}, - {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f3cd2158b2ed0fb25c6811adfdcc47224efe075f2d68a750071dacc03a7a66e4"}, - {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:59c0f13f9592820c51280d1cf811294d753e4a18baf90f0139d1dc93d4b6fc5f"}, - {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7f7655ad83d1a8afa48435a449bf2f3009293da1604f5dd95b5ddcf5f673bd69"}, - {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aa9f0d9b62d15182341b3e9816582f46182cab91c1a57b2d308b9a3c4e2c4f78"}, - {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fdd1b90c225a653b1bd1c0cae8edf1957892b9a09c8bf7ee6321eeb8208eac0f"}, - {file = "yarl-1.7.0-cp310-cp310-win32.whl", hash = "sha256:7c8d0bb76eabc5299db203e952ec55f8f4c53f08e0df4285aac8c92bd9e12675"}, - {file = "yarl-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:622a36fa779efb4ff9eff5fe52730ff17521431379851a31e040958fc251670c"}, - {file = "yarl-1.7.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d461b7a8e139b9e4b41f62eb417ffa0b98d1c46d4caf14c845e6a3b349c0bb1"}, - {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81cfacdd1e40bc931b5519499342efa388d24d262c30a3d31187bfa04f4a7001"}, - {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:821b978f2152be7695d4331ef0621d207aedf9bbd591ba23a63412a3efc29a01"}, - {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b64bd24c8c9a487f4a12260dc26732bf41028816dbf0c458f17864fbebdb3131"}, - {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:98c9ddb92b60a83c21be42c776d3d9d5ec632a762a094c41bda37b7dfbd2cd83"}, - {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a532d75ca74431c053a88a802e161fb3d651b8bf5821a3440bc3616e38754583"}, - {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:053e09817eafb892e94e172d05406c1b3a22a93bc68f6eff5198363a3d764459"}, - {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:98c51f02d542945d306c8e934aa2c1e66ba5e9c1c86b5bf37f3a51c8a747067e"}, - {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:15ec41a5a5fdb7bace6d7b16701f9440007a82734f69127c0fbf6d87e10f4a1e"}, - {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:a7f08819dba1e1255d6991ed37448a1bf4b1352c004bcd899b9da0c47958513d"}, - {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8e3ffab21db0542ffd1887f3b9575ddd58961f2cf61429cb6458afc00c4581e0"}, - {file = "yarl-1.7.0-cp36-cp36m-win32.whl", hash = "sha256:50127634f519b2956005891507e3aa4ac345f66a7ea7bbc2d7dcba7401f41898"}, - {file = "yarl-1.7.0-cp36-cp36m-win_amd64.whl", hash = "sha256:36ec44f15193f6d5288d42ebb8e751b967ebdfb72d6830983838d45ab18edb4f"}, - {file = "yarl-1.7.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ec1b5a25a25c880c976d0bb3d107def085bb08dbb3db7f4442e0a2b980359d24"}, - {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b36f5a63c891f813c6f04ef19675b382efc190fd5ce7e10ab19386d2548bca06"}, - {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38173b8c3a29945e7ecade9a3f6ff39581eee8201338ee6a2c8882db5df3e806"}, - {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ba402f32184f0b405fb281b93bd0d8ab7e3257735b57b62a6ed2e94cdf4fe50"}, - {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:be52bc5208d767cdd8308a9e93059b3b36d1e048fecbea0e0346d0d24a76adc0"}, - {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:08c2044a956f4ef30405f2f433ce77f1f57c2c773bf81ae43201917831044d5a"}, - {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:484d61c047c45670ef5967653a1d0783e232c54bf9dd786a7737036828fa8d54"}, - {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b7de92a4af85cfcaf4081f8aa6165b1d63ee5de150af3ee85f954145f93105a7"}, - {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:376e41775aab79c5575534924a386c8e0f1a5d91db69fc6133fd27a489bcaf10"}, - {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:8a8b10d0e7bac154f959b709fcea593cda527b234119311eb950096653816a86"}, - {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:f46cd4c43e6175030e2a56def8f1d83b64e6706eeb2bb9ab0ef4756f65eab23f"}, - {file = "yarl-1.7.0-cp37-cp37m-win32.whl", hash = "sha256:b28cfb46140efe1a6092b8c5c4994a1fe70dc83c38fbcea4992401e0c6fb9cce"}, - {file = "yarl-1.7.0-cp37-cp37m-win_amd64.whl", hash = "sha256:9624154ec9c02a776802da1086eed7f5034bd1971977f5146233869c2ac80297"}, - {file = "yarl-1.7.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:69945d13e1bbf81784a9bc48824feb9cd66491e6a503d4e83f6cd7c7cc861361"}, - {file = "yarl-1.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:46a742ed9e363bd01be64160ce7520e92e11989bd4cb224403cfd31c101cc83d"}, - {file = "yarl-1.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb4ff1ac7cb4500f43581b3f4cbd627d702143aa6be1fdc1fa3ebffaf4dc1be5"}, - {file = "yarl-1.7.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ad51e17cd65ea3debb0e10f0120cf8dd987c741fe423ed2285087368090b33d"}, - {file = "yarl-1.7.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e37786ea89a5d3ffbbf318ea9790926f8dfda83858544f128553c347ad143c6"}, - {file = "yarl-1.7.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c63c1e208f800daad71715786bfeb1cecdc595d87e2e9b1cd234fd6e597fd71d"}, - {file = "yarl-1.7.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:91cbe24300c11835ef186436363352b3257db7af165e0a767f4f17aa25761388"}, - {file = "yarl-1.7.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e510dbec7c59d32eaa61ffa48173d5e3d7170a67f4a03e8f5e2e9e3971aca622"}, - {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3def6e681cc02397e5d8141ee97b41d02932b2bcf0fb34532ad62855eab7c60e"}, - {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:263c81b94e6431942b27f6f671fa62f430a0a5c14bb255f2ab69eeb9b2b66ff7"}, - {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:e78c91faefe88d601ddd16e3882918dbde20577a2438e2320f8239c8b7507b8f"}, - {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:22b2430c49713bfb2f0a0dd4a8d7aab218b28476ba86fd1c78ad8899462cbcf2"}, - {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2e7ad9db939082f5d0b9269cfd92c025cb8f2fbbb1f1b9dc5a393c639db5bd92"}, - {file = "yarl-1.7.0-cp38-cp38-win32.whl", hash = "sha256:3a31e4a8dcb1beaf167b7e7af61b88cb961b220db8d3ba1c839723630e57eef7"}, - {file = "yarl-1.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:d579957439933d752358c6a300c93110f84aae67b63dd0c19dde6ecbf4056f6b"}, - {file = "yarl-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:87721b549505a546eb003252185103b5ec8147de6d3ad3714d148a5a67b6fe53"}, - {file = "yarl-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1fa866fa24d9f4108f9e58ea8a2135655419885cdb443e36b39a346e1181532"}, - {file = "yarl-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1d3b8449dfedfe94eaff2b77954258b09b24949f6818dfa444b05dbb05ae1b7e"}, - {file = "yarl-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2372e350794ce8b9f810feb094c606b7e0e4aa6807141ac4fadfe5ddd75bb0"}, - {file = "yarl-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a06d9d0b9a97fa99b84fee71d9dd11e69e21ac8a27229089f07b5e5e50e8d63c"}, - {file = "yarl-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3455c2456d6307bcfa80bc1157b8603f7d93573291f5bdc7144489ca0df4628"}, - {file = "yarl-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d30d67e3486aea61bb2cbf7cf81385364c2e4f7ce7469a76ed72af76a5cdfe6b"}, - {file = "yarl-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c18a4b286e8d780c3a40c31d7b79836aa93b720f71d5743f20c08b7e049ca073"}, - {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d54c925396e7891666cabc0199366ca55b27d003393465acef63fd29b8b7aa92"}, - {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:64773840952de17851a1c7346ad7f71688c77e74248d1f0bc230e96680f84028"}, - {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:acbf1756d9dc7cd0ae943d883be72e84e04396f6c2ff93a6ddeca929d562039f"}, - {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:2e48f27936aa838939c798f466c851ba4ae79e347e8dfce43b009c64b930df12"}, - {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1beef4734ca1ad40a9d8c6b20a76ab46e3a2ed09f38561f01e4aa2ea82cafcef"}, - {file = "yarl-1.7.0-cp39-cp39-win32.whl", hash = "sha256:8ee78c9a5f3c642219d4607680a4693b59239c27a3aa608b64ef79ddc9698039"}, - {file = "yarl-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:d750503682605088a14d29a4701548c15c510da4f13c8b17409c4097d5b04c52"}, - {file = "yarl-1.7.0.tar.gz", hash = "sha256:8e7ebaf62e19c2feb097ffb7c94deb0f0c9fab52590784c8cd679d30ab009162"}, + {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2a8508f7350512434e41065684076f640ecce176d262a7d54f0da41d99c5a95"}, + {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da6df107b9ccfe52d3a48165e48d72db0eca3e3029b5b8cb4fe6ee3cb870ba8b"}, + {file = "yarl-1.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1d0894f238763717bdcfea74558c94e3bc34aeacd3351d769460c1a586a8b05"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfe4b95b7e00c6635a72e2d00b478e8a28bfb122dc76349a06e20792eb53a523"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c145ab54702334c42237a6c6c4cc08703b6aa9b94e2f227ceb3d477d20c36c63"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ca56f002eaf7998b5fcf73b2421790da9d2586331805f38acd9997743114e98"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d3d5ad8ea96bd6d643d80c7b8d5977b4e2fb1bab6c9da7322616fd26203d125"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:167ab7f64e409e9bdd99333fe8c67b5574a1f0495dcfd905bc7454e766729b9e"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:95a1873b6c0dd1c437fb3bb4a4aaa699a48c218ac7ca1e74b0bee0ab16c7d60d"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6152224d0a1eb254f97df3997d79dadd8bb2c1a02ef283dbb34b97d4f8492d23"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5bb7d54b8f61ba6eee541fba4b83d22b8a046b4ef4d8eb7f15a7e35db2e1e245"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:9c1f083e7e71b2dd01f7cd7434a5f88c15213194df38bc29b388ccdf1492b739"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f44477ae29025d8ea87ec308539f95963ffdc31a82f42ca9deecf2d505242e72"}, + {file = "yarl-1.7.2-cp310-cp310-win32.whl", hash = "sha256:cff3ba513db55cc6a35076f32c4cdc27032bd075c9faef31fec749e64b45d26c"}, + {file = "yarl-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:c9c6d927e098c2d360695f2e9d38870b2e92e0919be07dbe339aefa32a090265"}, + {file = "yarl-1.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9b4c77d92d56a4c5027572752aa35082e40c561eec776048330d2907aead891d"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c01a89a44bb672c38f42b49cdb0ad667b116d731b3f4c896f72302ff77d71656"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c19324a1c5399b602f3b6e7db9478e5b1adf5cf58901996fc973fe4fccd73eed"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3abddf0b8e41445426d29f955b24aeecc83fa1072be1be4e0d194134a7d9baee"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a1a9fe17621af43e9b9fcea8bd088ba682c8192d744b386ee3c47b56eaabb2c"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8b0915ee85150963a9504c10de4e4729ae700af11df0dc5550e6587ed7891e92"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:29e0656d5497733dcddc21797da5a2ab990c0cb9719f1f969e58a4abac66234d"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:bf19725fec28452474d9887a128e98dd67eee7b7d52e932e6949c532d820dc3b"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:d6f3d62e16c10e88d2168ba2d065aa374e3c538998ed04996cd373ff2036d64c"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ac10bbac36cd89eac19f4e51c032ba6b412b3892b685076f4acd2de18ca990aa"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aa32aaa97d8b2ed4e54dc65d241a0da1c627454950f7d7b1f95b13985afd6c5d"}, + {file = "yarl-1.7.2-cp36-cp36m-win32.whl", hash = "sha256:87f6e082bce21464857ba58b569370e7b547d239ca22248be68ea5d6b51464a1"}, + {file = "yarl-1.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:ac35ccde589ab6a1870a484ed136d49a26bcd06b6a1c6397b1967ca13ceb3913"}, + {file = "yarl-1.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a467a431a0817a292121c13cbe637348b546e6ef47ca14a790aa2fa8cc93df63"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ab0c3274d0a846840bf6c27d2c60ba771a12e4d7586bf550eefc2df0b56b3b4"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d260d4dc495c05d6600264a197d9d6f7fc9347f21d2594926202fd08cf89a8ba"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4dd8b01a8112809e6b636b00f487846956402834a7fd59d46d4f4267181c41"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c1164a2eac148d85bbdd23e07dfcc930f2e633220f3eb3c3e2a25f6148c2819e"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:67e94028817defe5e705079b10a8438b8cb56e7115fa01640e9c0bb3edf67332"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:89ccbf58e6a0ab89d487c92a490cb5660d06c3a47ca08872859672f9c511fc52"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8cce6f9fa3df25f55521fbb5c7e4a736683148bcc0c75b21863789e5185f9185"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:211fcd65c58bf250fb994b53bc45a442ddc9f441f6fec53e65de8cba48ded986"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c10ea1e80a697cf7d80d1ed414b5cb8f1eec07d618f54637067ae3c0334133c4"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:52690eb521d690ab041c3919666bea13ab9fbff80d615ec16fa81a297131276b"}, + {file = "yarl-1.7.2-cp37-cp37m-win32.whl", hash = "sha256:695ba021a9e04418507fa930d5f0704edbce47076bdcfeeaba1c83683e5649d1"}, + {file = "yarl-1.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:c17965ff3706beedafd458c452bf15bac693ecd146a60a06a214614dc097a271"}, + {file = "yarl-1.7.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fce78593346c014d0d986b7ebc80d782b7f5e19843ca798ed62f8e3ba8728576"}, + {file = "yarl-1.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c2a1ac41a6aa980db03d098a5531f13985edcb451bcd9d00670b03129922cd0d"}, + {file = "yarl-1.7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:39d5493c5ecd75c8093fa7700a2fb5c94fe28c839c8e40144b7ab7ccba6938c8"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eb6480ef366d75b54c68164094a6a560c247370a68c02dddb11f20c4c6d3c9d"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ba63585a89c9885f18331a55d25fe81dc2d82b71311ff8bd378fc8004202ff6"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e39378894ee6ae9f555ae2de332d513a5763276a9265f8e7cbaeb1b1ee74623a"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c0910c6b6c31359d2f6184828888c983d54d09d581a4a23547a35f1d0b9484b1"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6feca8b6bfb9eef6ee057628e71e1734caf520a907b6ec0d62839e8293e945c0"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8300401dc88cad23f5b4e4c1226f44a5aa696436a4026e456fe0e5d2f7f486e6"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:788713c2896f426a4e166b11f4ec538b5736294ebf7d5f654ae445fd44270832"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fd547ec596d90c8676e369dd8a581a21227fe9b4ad37d0dc7feb4ccf544c2d59"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:737e401cd0c493f7e3dd4db72aca11cfe069531c9761b8ea474926936b3c57c8"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baf81561f2972fb895e7844882898bda1eef4b07b5b385bcd308d2098f1a767b"}, + {file = "yarl-1.7.2-cp38-cp38-win32.whl", hash = "sha256:ede3b46cdb719c794427dcce9d8beb4abe8b9aa1e97526cc20de9bd6583ad1ef"}, + {file = "yarl-1.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:cc8b7a7254c0fc3187d43d6cb54b5032d2365efd1df0cd1749c0c4df5f0ad45f"}, + {file = "yarl-1.7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:580c1f15500e137a8c37053e4cbf6058944d4c114701fa59944607505c2fe3a0"}, + {file = "yarl-1.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ec1d9a0d7780416e657f1e405ba35ec1ba453a4f1511eb8b9fbab81cb8b3ce1"}, + {file = "yarl-1.7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3bf8cfe8856708ede6a73907bf0501f2dc4e104085e070a41f5d88e7faf237f3"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1be4bbb3d27a4e9aa5f3df2ab61e3701ce8fcbd3e9846dbce7c033a7e8136746"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534b047277a9a19d858cde163aba93f3e1677d5acd92f7d10ace419d478540de"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6ddcd80d79c96eb19c354d9dca95291589c5954099836b7c8d29278a7ec0bda"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9bfcd43c65fbb339dc7086b5315750efa42a34eefad0256ba114cd8ad3896f4b"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f64394bd7ceef1237cc604b5a89bf748c95982a84bcd3c4bbeb40f685c810794"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044daf3012e43d4b3538562da94a88fb12a6490652dbc29fb19adfa02cf72eac"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:368bcf400247318382cc150aaa632582d0780b28ee6053cd80268c7e72796dec"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:bab827163113177aee910adb1f48ff7af31ee0289f434f7e22d10baf624a6dfe"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0cba38120db72123db7c58322fa69e3c0efa933040ffb586c3a87c063ec7cae8"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:59218fef177296451b23214c91ea3aba7858b4ae3306dde120224cfe0f7a6ee8"}, + {file = "yarl-1.7.2-cp39-cp39-win32.whl", hash = "sha256:1edc172dcca3f11b38a9d5c7505c83c1913c0addc99cd28e993efeaafdfaa18d"}, + {file = "yarl-1.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:797c2c412b04403d2da075fb93c123df35239cd7b4cc4e0cd9e5839b73f52c58"}, + {file = "yarl-1.7.2.tar.gz", hash = "sha256:45399b46d60c253327a460e99856752009fcee5f5d3c80b2f7c0cae1c38d56dd"}, ] diff --git a/pyproject.toml b/pyproject.toml index 85f78a1..c93b17a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "saleor-app" -version = "0.1.1" +version = "0.2.1" description = "Saleor app framework" authors = [ "Saleor Commerce " ] diff --git a/samples/simple_app/app.py b/samples/simple_app/app.py index cd7f6cb..ae57d4c 100644 --- a/samples/simple_app/app.py +++ b/samples/simple_app/app.py @@ -1,40 +1,30 @@ +from logging import debug from pathlib import Path +from typing import List, Optional from fastapi.param_functions import Depends from fastapi.responses import HTMLResponse -from pydantic import BaseModel +from pydantic import BaseModel, BaseSettings from starlette.middleware.cors import CORSMiddleware -from starlette.staticfiles import StaticFiles from saleor_app.app import SaleorApp -from saleor_app.conf import Settings, SettingsManifest -from saleor_app.deps import ConfigurationDataDeps +from saleor_app.deps import ConfigurationDataDeps, saleor_domain_header from saleor_app.endpoints import get_public_form from saleor_app.schemas.core import DomainName, WebhookData -from saleor_app.schemas.handlers import Payload, WebhookHandlers +from saleor_app.schemas.handlers import WebhookHandlers +from saleor_app.schemas.manifest import Manifest +from saleor_app.schemas.webhook import Webhook PROJECT_DIR = Path(__file__).parent + +class Settings(BaseSettings): + debug: bool = False + dev_saleor_token: Optional[str] + + settings = Settings( - app_name="SimpleApp", - project_dir=PROJECT_DIR, - static_dir=PROJECT_DIR / "static", - templates_dir=PROJECT_DIR / "static", debug=True, - manifest=SettingsManifest( - name="Sample Saleor App", - version="0.1.0", - about="Sample Saleor App seving as an example.", - data_privacy="", - data_privacy_url="", - homepage_url="http://172.17.0.1:5000/homepageUrl", - support_url="http://172.17.0.1:5000/supportUrl", - id="saleor-simple-sample", - permissions=["MANAGE_PRODUCTS", "MANAGE_USERS"], - configuration_url_for="configuration-form", - extensions=[], - ), - dev_saleor_domain="127.0.0.1:5000", dev_saleor_token="test_token", ) @@ -61,34 +51,55 @@ async def get_webhook_details(domain_name: DomainName) -> WebhookData: webhook_secret_key="webhook-secret-key", ) +async def example_dependency(): + return "example" + -async def product_created(payload: Payload, saleor_domain: DomainName): +async def product_created(payload: List[Webhook], saleor_domain=Depends(saleor_domain_header), example = Depends(example_dependency)): + print(example) + print(saleor_domain) print("Product created!") print(payload) -async def product_updated(payload: Payload, saleor_domain: DomainName): +async def product_updated(payload: List[Webhook], saleor_domain=Depends(saleor_domain_header), example = Depends(example_dependency)): + print(example) + print(saleor_domain) print("Product updated!") print(payload) -async def product_deleted(payload: Payload, saleor_domain: DomainName): +async def product_deleted(payload: List[Webhook], saleor_domain=Depends(saleor_domain_header), example = Depends(example_dependency)): + print(example) + print(saleor_domain) print("Product deleted!") print(payload) -webhook_handlers = WebhookHandlers( - product_created=product_created, - product_updated=product_updated, - product_deleted=product_deleted, -) - - app = SaleorApp( + manifest=Manifest( + name="Sample Saleor App", + version="0.1.0", + about="Sample Saleor App seving as an example.", + data_privacy="", + data_privacy_url="http://172.17.0.1:5000/dataPrivacyUrl", + homepage_url="http://172.17.0.1:5000/homepageUrl", + support_url="http://172.17.0.1:5000/supportUrl", + id="saleor-simple-sample", + permissions=["MANAGE_PRODUCTS", "MANAGE_USERS"], + configuration_url=Manifest.url_for("configuration-form"), + extensions=[], + ), validate_domain=validate_domain, save_app_data=store_app_data, - webhook_handlers=webhook_handlers, + webhook_handlers=WebhookHandlers( + product_created=product_created, + product_updated=product_updated, + product_deleted=product_deleted, + ), get_webhook_details=get_webhook_details, + use_insecure_saleor_http=settings.debug, + development_auth_token=settings.dev_saleor_token ) app.configuration_router.get( "/", response_class=HTMLResponse, name="configuration-form" @@ -109,4 +120,3 @@ async def get_configuration_data(commons: ConfigurationDataDeps = Depends()): allow_headers=["*"], allow_methods=["OPTIONS", "GET", "POST"], ) -app.mount("/static", StaticFiles(directory=settings.static_dir), name="static") diff --git a/src/saleor_app/app.py b/src/saleor_app/app.py index 2c062ed..f89ef6f 100644 --- a/src/saleor_app/app.py +++ b/src/saleor_app/app.py @@ -1,8 +1,11 @@ -from typing import Awaitable, Callable +from typing import Awaitable, Callable, Optional -from fastapi import APIRouter, FastAPI +from fastapi import APIRouter, Depends, FastAPI +from fastapi.routing import APIRoute +from saleor_app.deps import verify_saleor_domain, verify_webhook_signature from saleor_app.endpoints import handle_webhook, install, manifest +from saleor_app.http import WebhookRoute from saleor_app.schemas.core import DomainName, WebhookData from saleor_app.schemas.handlers import WebhookHandlers from saleor_app.schemas.manifest import Manifest @@ -12,18 +15,25 @@ class SaleorApp(FastAPI): def __init__( self, *, + manifest: Manifest, validate_domain: Callable[[DomainName], Awaitable[bool]], save_app_data: Callable[[DomainName, WebhookData], Awaitable], webhook_handlers: WebhookHandlers, get_webhook_details: Callable[[DomainName], Awaitable[WebhookData]], + use_insecure_saleor_http: bool = False, + development_auth_token: Optional[str] = None, **kwargs, ): super().__init__(**kwargs) + self.manifest = manifest + self.webhook_handlers = webhook_handlers + self.use_insecure_saleor_http = use_insecure_saleor_http + self.development_auth_token = development_auth_token + self.extra["saleor"] = { "validate_domain": validate_domain, "save_app_data": save_app_data, - "webhook_handlers": webhook_handlers, "get_webhook_details": get_webhook_details, } self.configuration_router = APIRouter( @@ -53,6 +63,20 @@ def include_webhook_router(self): 401: {"description": "Incorrect signature"}, 404: {"description": "Incorrect saleor event"}, }, + dependencies=[ + Depends(verify_saleor_domain), + Depends(verify_webhook_signature), + ], + route_class=WebhookRoute, ) + router.post("", name="handle-webhook")(handle_webhook) + self.webhook_handler_routes = { + name: APIRoute( + "", + endpoint, + ) + for name, endpoint in self.webhook_handlers + if endpoint + } self.include_router(router) diff --git a/src/saleor_app/conf.py b/src/saleor_app/conf.py deleted file mode 100644 index 60fa12b..0000000 --- a/src/saleor_app/conf.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging -import os -from functools import lru_cache -from pathlib import Path -from typing import Optional - -from pydantic import BaseSettings - -from saleor_app.schemas import manifest -from saleor_app.utils import import_string - -SETTINGS_ENV_VARIABLE = "APP_SETTINGS" -logger = logging.getLogger(__name__) - - -class SettingsManifest(BaseSettings, manifest.SettingsManifest): - class Config: - allow_population_by_field_name = True - env_prefix = "manifest_" - - # Pydatnic raises a FutureWarning when we use alias parameter without env - # parameter. The reason for that is that they dropped support for assigning - # settings from ENV by alias name. The 'fields' is only required to - fields = { - "data_privacy": { - "env": "manifest_data_privacy", - }, - "data_privacy_url": { - "env": "manifest_data_privacy_url", - }, - "homepage_url": { - "env": "manifest_homepage_url", - }, - "support_url": { - "env": "manifest_support_url", - }, - "app_url": { - "env": "manifest_app_url", - }, - "configuration_url": { - "env": "manifest_configuration_url", - }, - } - - -class Settings(BaseSettings): - app_name: str = "App" - project_dir: Path - static_dir: Path - templates_dir: Path - debug: bool = True - manifest: SettingsManifest - dev_saleor_domain: Optional[str] = None - dev_saleor_token: Optional[str] = None - - -@lru_cache() -def get_settings() -> Settings: - settings_path = os.environ.get(SETTINGS_ENV_VARIABLE) - if not settings_path: - raise Exception( - f"Env {SETTINGS_ENV_VARIABLE} was not provided. Provide python path to " - f"project's settings class." - ) - settings = import_string(settings_path) - if not isinstance(settings, Settings): - logger.warning("Incorrect type of settings object.") - return settings diff --git a/src/saleor_app/deps.py b/src/saleor_app/deps.py index 05bd123..7e8e319 100644 --- a/src/saleor_app/deps.py +++ b/src/saleor_app/deps.py @@ -6,25 +6,21 @@ import jwt from fastapi import Depends, Header, HTTPException, Query, Request -from saleor_app.conf import Settings, get_settings +from saleor_app.saleor.exceptions import GraphQLError +from saleor_app.saleor.mutations import VERIFY_TOKEN +from saleor_app.saleor.utils import get_client_for_app from saleor_app.schemas.core import DomainName -from saleor_app.schemas.handlers import WebhookHandlers -from saleor_app.validators import verify_token logger = logging.getLogger(__name__) SALEOR_DOMAIN_HEADER = "x-saleor-domain" SALEOR_TOKEN_HEADER = "x-saleor-token" -SALEOR_EVENT_HEADER = "x-saleor-event" SALEOR_SIGNATURE_HEADER = "x-saleor-signature" async def saleor_domain_header( saleor_domain=Header(None, alias=SALEOR_DOMAIN_HEADER), - settings: Settings = Depends(get_settings), ) -> DomainName: - if settings.debug: - saleor_domain = saleor_domain or settings.dev_saleor_domain if not saleor_domain: logger.warning(f"Missing {SALEOR_DOMAIN_HEADER.upper()} header.") raise HTTPException( @@ -34,11 +30,11 @@ async def saleor_domain_header( async def saleor_token( + request: Request, token=Header(None, alias=SALEOR_TOKEN_HEADER), - settings: Settings = Depends(get_settings), ) -> str: - if settings.debug: - token = token or settings.dev_saleor_token + if request.app.development_auth_token: + token = token or request.app.development_auth_token if not token: logger.warning(f"Missing {SALEOR_TOKEN_HEADER.upper()} header.") raise HTTPException( @@ -48,9 +44,29 @@ async def saleor_token( async def verify_saleor_token( - domain=Depends(saleor_domain_header), token=Depends(saleor_token) + request: Request, + saleor_domain=Depends(saleor_domain_header), + token=Depends(saleor_token), ) -> bool: - is_valid = await verify_token(domain, token) + schema = "http" if request.app.use_insecure_saleor_http else "https" + async with get_client_for_app( + f"{schema}://{saleor_domain}", manifest=request.app.manifest + ) as saleor: + try: + response = saleor.execute( + VERIFY_TOKEN, + variables={ + "token": token, + }, + ) + except GraphQLError: + return False + + try: + is_valid = response["tokenVerify"]["isValid"] is True + except KeyError: + is_valid = False + if not is_valid: logger.warning( f"Provided {SALEOR_DOMAIN_HEADER.upper()} and " @@ -81,28 +97,6 @@ async def verify_saleor_domain( return True -async def webhook_event_type(event=Header(None, alias=SALEOR_EVENT_HEADER)) -> str: - if not event: - logger.warning(f"Missing {SALEOR_EVENT_HEADER.upper()} header.") - raise HTTPException( - status_code=400, detail=f"Missing {SALEOR_EVENT_HEADER.upper()} header." - ) - if event not in WebhookHandlers.__fields__: - logger.error( - "Event from %s header %s doesn't have own handler.", - SALEOR_EVENT_HEADER, - event, - ) - raise HTTPException( - status_code=404, - detail=( - f"Event from {SALEOR_EVENT_HEADER} header {event} doesn't have own " - "handler on the app side." - ), - ) - return event - - async def verify_webhook_signature( request: Request, signature=Header(None, alias=SALEOR_SIGNATURE_HEADER), @@ -133,18 +127,26 @@ async def verify_webhook_signature( def require_permission(permissions: List): + """ + Validates is the requesting principal is authorized for the specified action + + Usage: + + ``` + Depends(require_permission([SaleorPermissions.MANAGE_PRODUCTS])) + ``` + """ + async def func( saleor_domain=Depends(saleor_domain_header), saleor_token=Depends(saleor_token), - # TODO: this needs to happen but there's hope that Saleor will go with - # an RS JWT sign. - # _token_is_valid=Depends(verify_saleor_token), + _token_is_valid=Depends(verify_saleor_token), ): jwt_payload = jwt.decode(saleor_token, verify=False) user_permissions = set(jwt_payload.get("permissions", [])) if not set([p.value for p in permissions]) - user_permissions: return True - raise Exception("Unauthorized user") + raise HTTPException(status_code=403, detail="Unauthorized user") return func @@ -154,11 +156,9 @@ def __init__( self, request: Request, domain=Query(...), - settings: Settings = Depends(get_settings), ): self.request = request self.saleor_domain = domain - self.settings = settings class ConfigurationDataDeps: diff --git a/src/saleor_app/endpoints.py b/src/saleor_app/endpoints.py index 649eaaa..0b48492 100644 --- a/src/saleor_app/endpoints.py +++ b/src/saleor_app/endpoints.py @@ -1,35 +1,33 @@ -from typing import Any, List +import json +from typing import List -from fastapi import Request +from fastapi import Depends, Header, Request from fastapi.exceptions import HTTPException -from fastapi.param_functions import Depends -from fastapi.templating import Jinja2Templates +from fastapi.responses import PlainTextResponse from saleor_app.deps import ( ConfigurationFormDeps, - get_settings, saleor_domain_header, verify_saleor_domain, - verify_webhook_signature, - webhook_event_type, ) from saleor_app.errors import InstallAppError -from saleor_app.graphql import GraphQLError +from saleor_app.http import SALEOR_EVENT_HEADER from saleor_app.install import install_app +from saleor_app.saleor.exceptions import GraphQLError from saleor_app.schemas.core import InstallData -from saleor_app.schemas.manifest import Manifest +from saleor_app.schemas.webhook import Webhook -async def manifest(request: Request, settings=Depends(get_settings)): - manifest = settings.manifest.dict(by_alias=True) - manifest["appUrl"] = "" - manifest["tokenTargetUrl"] = request.url_for("app-install") - manifest["configurationUrl"] = request.url_for( - manifest.pop("configuration_url_for") - ) - for extension in manifest["extensions"]: - extension["url"] = request.url_for(extension.pop("url_for")) - return Manifest(**manifest) +async def manifest(request: Request): + manifest = request.app.manifest + for name, field in manifest: + if callable(field): + setattr(manifest, name, field(request)) + for extension in manifest.extensions: + if callable(extension.url): + extension.url = extension.url(request) + manifest.app_url = "http://127.0.0.1" + return manifest async def install( @@ -38,16 +36,15 @@ async def install( _domain_is_valid=Depends(verify_saleor_domain), saleor_domain=Depends(saleor_domain_header), ): - target_url = request.url_for("handle-webhook") - domain = saleor_domain - auth_token = data.auth_token try: await install_app( - domain, - auth_token, - request.app.extra["saleor"]["webhook_handlers"].get_assigned_events(), - target_url, - request.app.extra["saleor"]["save_app_data"], + saleor_domain=saleor_domain, + auth_token=data.auth_token, + manifest=request.app.manifest, + events=request.app.webhook_handlers.get_assigned_events(), + target_url=request.url_for("handle-webhook"), + save_app_data_callback=request.app.extra["saleor"]["save_app_data"], + use_insecure_saleor_http=request.app.use_insecure_saleor_http, ) except (InstallAppError, GraphQLError): raise HTTPException( @@ -59,25 +56,17 @@ async def install( async def handle_webhook( request: Request, - payload: List[Any], # FIXME provide a way to proper define payload types - _domain_is_valid=Depends(verify_saleor_domain), + payload: List[Webhook], # FIXME provide a way to proper define payload types saleor_domain=Depends(saleor_domain_header), - event_type=Depends(webhook_event_type), - _signature_is_valid=Depends(verify_webhook_signature), + _event_type=Header(None, alias=SALEOR_EVENT_HEADER), ): - response = {} - handler = request.app.extra["saleor"]["webhook_handlers"].get(event_type) - if handler is not None: - response = await handler(payload, saleor_domain) - return response or {} + return {} async def get_public_form(commons: ConfigurationFormDeps = Depends()): context = { - "request": commons.request, - "form_url": commons.request.url, - "domain": commons.saleor_domain, + "request": str(commons.request), + "form_url": str(commons.request.url), + "saleor_domain": commons.saleor_domain, } - return Jinja2Templates(directory=str(commons.settings.static_dir)).TemplateResponse( - "configuration/index.html", context - ) + return PlainTextResponse(json.dumps(context, indent=4)) diff --git a/src/saleor_app/errors.py b/src/saleor_app/errors.py index ed83904..ef9ce31 100644 --- a/src/saleor_app/errors.py +++ b/src/saleor_app/errors.py @@ -1,10 +1,2 @@ -class GraphQLBaseError(Exception): - pass - - -class GraphQLError(GraphQLBaseError): - """GraphQL Error""" - - -class InstallAppError(GraphQLBaseError): +class InstallAppError(Exception): """Install App error""" diff --git a/src/saleor_app/graphql.py b/src/saleor_app/graphql.py deleted file mode 100644 index 3acda45..0000000 --- a/src/saleor_app/graphql.py +++ /dev/null @@ -1,69 +0,0 @@ -import json -import logging - -from aiohttp import ClientError, ClientSession - -from saleor_app.conf import get_settings -from saleor_app.errors import GraphQLError - -DEFAULT_REQUEST_TIMEOUT = 10 - -logger = logging.getLogger("graphql") - - -def get_saleor_api_url(domain: str) -> str: - protocol = "https" - settings = get_settings() - if settings.debug: - logger.warning("Using non secured protocol") - protocol = "http" - url = f"{protocol}://{domain}" - return f"{url}/graphql/" - - -def get_executor(host, auth_token=None, timeout=DEFAULT_REQUEST_TIMEOUT): - async def _execute(query, variables=None): - return await _execute_query(host, auth_token, timeout, query, variables) - - return _execute - - -async def _execute_query(host, api_key, timeout, query, variables=None, file=None): - headers = {"Authorization": "Bearer " + api_key} if api_key else {} - - if not file: - data = {"query": query} - if variables is not None: - data["variables"] = variables - kwargs = {} - else: - variables.update({"file": None}) - data = { - "operations": json.dumps({"query": query, "variables": variables}), - "map": json.dumps({"0": ["variables.file"]}), - } - kwargs = {"files": {"0": (file.name, file.content)}} - - async with ClientSession() as client: - try: - response = await client.request( - "POST", - url=host, - json=data, - headers=headers, - timeout=timeout, - **kwargs, - ) - except ClientError as e: - logger.exception(msg="Connection error", exc_info=e) - raise GraphQLError(e) - - try: - result = await response.json() - errors = result.get("errors") - if errors: - logger.warning("Query to the server has returned an error.", extra=errors) - except json.JSONDecodeError as e: - logger.exception(msg=f"FAILED RESPONSE: {response}", exc_info=e) - raise GraphQLError(e) - return result, errors diff --git a/src/saleor_app/http.py b/src/saleor_app/http.py new file mode 100644 index 0000000..44f068b --- /dev/null +++ b/src/saleor_app/http.py @@ -0,0 +1,23 @@ +from typing import Callable + +from fastapi import HTTPException, Request +from fastapi.routing import APIRoute +from starlette.responses import Response + +SALEOR_EVENT_HEADER = "x-saleor-event" + + +class WebhookRoute(APIRoute): + def get_route_handler(self) -> Callable: + async def custom_route_handler(request: Request) -> Response: + if event_type := request.headers.get("x-saleor-event"): + route = request.app.webhook_handler_routes[event_type] + handler = route.get_route_handler() + response: Response = await handler(request) + return response + + raise HTTPException( + status_code=400, detail=f"Missing {SALEOR_EVENT_HEADER.upper()} header." + ) + + return custom_route_handler diff --git a/src/saleor_app/install.py b/src/saleor_app/install.py index 8bc3ea4..a3e5944 100644 --- a/src/saleor_app/install.py +++ b/src/saleor_app/install.py @@ -3,57 +3,60 @@ import string from typing import Awaitable, Callable, List -from saleor_app.conf import get_settings from saleor_app.errors import InstallAppError -from saleor_app.graphql import GraphQLError, get_executor, get_saleor_api_url -from saleor_app.mutations import CREATE_WEBHOOK +from saleor_app.saleor.exceptions import GraphQLError +from saleor_app.saleor.mutations import CREATE_WEBHOOK +from saleor_app.saleor.utils import get_client_for_app from saleor_app.schemas.core import AppToken, DomainName, Url, WebhookData +from saleor_app.schemas.manifest import Manifest logger = logging.getLogger(__name__) async def install_app( - domain: DomainName, - token: AppToken, + saleor_domain: DomainName, + auth_token: AppToken, + manifest: Manifest, events: List[str], target_url: Url, - save_app_data: Callable[[DomainName, WebhookData], Awaitable], + save_app_data_callback: Callable[[DomainName, WebhookData], Awaitable], + use_insecure_saleor_http: bool, ): alphabet = string.ascii_letters + string.digits secret_key = "".join(secrets.choice(alphabet) for _ in range(20)) - api_url = get_saleor_api_url(domain) - executor = get_executor(host=api_url, auth_token=token) - - settings = get_settings() - - response, errors = await executor( - CREATE_WEBHOOK, - variables={ - "input": { - "targetUrl": target_url, - "events": [event.upper() for event in events], - "name": settings.app_name, - "secretKey": secret_key, - } - }, - ) - - if errors: - logger.warning("Webhook create mutation raised an error") - raise GraphQLError("Webhook create mutation raised an error") - - webhook_error = response["data"]["webhookCreate"].get("errors") + schema = "http" if use_insecure_saleor_http else "https" + + async with get_client_for_app( + f"{schema}://{saleor_domain}", manifest=manifest, auth_token=auth_token + ) as saleor: + try: + response = await saleor.execute( + CREATE_WEBHOOK, + variables={ + "input": { + "targetUrl": target_url, + "events": [event.upper() for event in events], + "name": f"{manifest.name}-http", + "secretKey": secret_key, + } + }, + ) + except GraphQLError as exc: + logger.warning("Webhook create mutation raised an error: %s", exc) + raise + + webhook_error = response["webhookCreate"].get("errors") if webhook_error: logger.warning( "Unable to finish installation of app for %s. Received error: %s", - domain, + saleor_domain, webhook_error, ) - raise InstallAppError("Failed to create webhook for %s.", domain) + raise InstallAppError("Failed to create webhook for %s.", saleor_domain) - saleor_webhook_id = response["data"]["webhookCreate"]["webhook"]["id"] + saleor_webhook_id = response["webhookCreate"]["webhook"]["id"] install_app_data = WebhookData( - token=token, webhook_id=saleor_webhook_id, webhook_secret_key=secret_key + token=auth_token, webhook_id=saleor_webhook_id, webhook_secret_key=secret_key ) - await save_app_data(domain, install_app_data) + await save_app_data_callback(saleor_domain, install_app_data) diff --git a/src/saleor_app/saleor/client.py b/src/saleor_app/saleor/client.py new file mode 100644 index 0000000..3925448 --- /dev/null +++ b/src/saleor_app/saleor/client.py @@ -0,0 +1,47 @@ +import logging + +import aiohttp +from aiohttp.client import ClientTimeout + +from saleor_app.saleor.exceptions import GraphQLError + +logger = logging.getLogger("saleor.client") + + +class SaleorClient: + def __init__(self, saleor_url, user_agent, auth_token=None, timeout=15): + headers = {"User-Agent": user_agent} + if auth_token: + headers["Authorization"] = f"Bearer {auth_token}" + self.session = aiohttp.ClientSession( + base_url=saleor_url, + headers=headers, + timeout=ClientTimeout(total=timeout), + ) + + async def close(self): + await self.session.close() + + async def __aenter__(self) -> aiohttp.ClientSession: + return self + + async def __aexit__( + self, + exc_type, + exc_val, + exc_tb, + ) -> None: + await self.close() + + async def execute(self, query, variables=None): + async with self.session.post( + url="/graphql/", json={"query": query, "variables": variables} + ) as resp: + response_data = await resp.json() + if errors := response_data.get("errors"): + exc = GraphQLError( + errors=errors, response_data=response_data.get("data") + ) + logger.error(str(exc)) + raise exc + return response_data["data"] diff --git a/src/saleor_app/saleor/exceptions.py b/src/saleor_app/saleor/exceptions.py new file mode 100644 index 0000000..0b07224 --- /dev/null +++ b/src/saleor_app/saleor/exceptions.py @@ -0,0 +1,20 @@ +from typing import Any, Dict, Optional, Sequence + + +class GraphQLError(Exception): + """ + Raised on Saleor GraphQL errors + """ + + def __init__( + self, + errors: Sequence[Dict[str, Any]], + response_data: Optional[Dict[str, Any]] = None, + ): + self.errors = errors + self.response_data = response_data + + def __str__(self): + return ( + f"GraphQLError: {', '.join([error['message'] for error in self.errors])}." + ) diff --git a/src/saleor_app/mutations.py b/src/saleor_app/saleor/mutations.py similarity index 100% rename from src/saleor_app/mutations.py rename to src/saleor_app/saleor/mutations.py diff --git a/src/saleor_app/saleor/utils.py b/src/saleor_app/saleor/utils.py new file mode 100644 index 0000000..cb6113e --- /dev/null +++ b/src/saleor_app/saleor/utils.py @@ -0,0 +1,10 @@ +from saleor_app.saleor.client import SaleorClient +from saleor_app.schemas.manifest import Manifest + + +def get_client_for_app(saleor_url: str, manifest: Manifest, **kwargs) -> SaleorClient: + return SaleorClient( + saleor_url=saleor_url, + user_agent=f"saleor_client/{manifest.id}-{manifest.version}", + **kwargs, + ) diff --git a/src/saleor_app/schemas/handlers.py b/src/saleor_app/schemas/handlers.py index 70e1ed1..51d5a69 100644 --- a/src/saleor_app/schemas/handlers.py +++ b/src/saleor_app/schemas/handlers.py @@ -1,69 +1,96 @@ -from typing import Any, Awaitable, Callable, List, Optional, Union +from typing import Awaitable, Callable, List, Optional from pydantic import BaseModel from saleor_app.schemas.core import DomainName - -Payload = Union[BaseModel, Any] +from saleor_app.schemas.webhook import Webhook class WebhookHandlers(BaseModel): - order_created: Optional[Callable[[Payload, DomainName], Awaitable]] = None - order_confirmed: Optional[Callable[[Payload, DomainName], Awaitable]] = None - order_fully_paid: Optional[Callable[[Payload, DomainName], Awaitable]] = None - order_updated: Optional[Callable[[Payload, DomainName], Awaitable]] = None - order_cancelled: Optional[Callable[[Payload, DomainName], Awaitable]] = None - order_fulfilled: Optional[Callable[[Payload, DomainName], Awaitable]] = None - - draft_order_created: Optional[Callable[[Payload, DomainName], Awaitable]] = None - draft_order_updated: Optional[Callable[[Payload, DomainName], Awaitable]] = None - draft_order_deleted: Optional[Callable[[Payload, DomainName], Awaitable]] = None - - sale_created: Optional[Callable[[Payload, DomainName], Awaitable]] = None - sale_updated: Optional[Callable[[Payload, DomainName], Awaitable]] = None - sale_deleted: Optional[Callable[[Payload, DomainName], Awaitable]] = None - - invoice_requested: Optional[Callable[[Payload, DomainName], Awaitable]] = None - invoice_deleted: Optional[Callable[[Payload, DomainName], Awaitable]] = None - invoice_sent: Optional[Callable[[Payload, DomainName], Awaitable]] = None - - customer_created: Optional[Callable[[Payload, DomainName], Awaitable]] = None - customer_updated: Optional[Callable[[Payload, DomainName], Awaitable]] = None - - product_created: Optional[Callable[[Payload, DomainName], Awaitable]] = None - product_updated: Optional[Callable[[Payload, DomainName], Awaitable]] = None - product_deleted: Optional[Callable[[Payload, DomainName], Awaitable]] = None - - product_variant_created: Optional[Callable[[Payload, DomainName], Awaitable]] = None - product_variant_updated: Optional[Callable[[Payload, DomainName], Awaitable]] = None - product_variant_deleted: Optional[Callable[[Payload, DomainName], Awaitable]] = None - product_variant_out_of_stock: Optional[Callable[[Payload, DomainName], Awaitable]] = None - product_variant_back_in_stock: Optional[Callable[[Payload, DomainName], Awaitable]] = None - - checkout_created: Optional[Callable[[Payload, DomainName], Awaitable]] = None - checkout_updated: Optional[Callable[[Payload, DomainName], Awaitable]] = None - - fulfillment_created: Optional[Callable[[Payload, DomainName], Awaitable]] = None - fulfillment_canceled: Optional[Callable[[Payload, DomainName], Awaitable]] = None - - notify_user: Optional[Callable[[Payload, DomainName], Awaitable]] = None - - page_created: Optional[Callable[[Payload, DomainName], Awaitable]] = None - page_updated: Optional[Callable[[Payload, DomainName], Awaitable]] = None - page_deleted: Optional[Callable[[Payload, DomainName], Awaitable]] = None - - payment_authorize: Optional[Callable[[Payload, DomainName], Awaitable]] = None - payment_capture: Optional[Callable[[Payload, DomainName], Awaitable]] = None - payment_confirm: Optional[Callable[[Payload, DomainName], Awaitable]] = None - payment_list_gateways: Optional[Callable[[Payload, DomainName], Awaitable]] = None - payment_process: Optional[Callable[[Payload, DomainName], Awaitable]] = None - payment_refund: Optional[Callable[[Payload, DomainName], Awaitable]] = None - payment_void: Optional[Callable[[Payload, DomainName], Awaitable]] = None - - shipping_list_methods_for_checkout: Optional[Callable[[Payload, DomainName], Awaitable]] = None - - translation_created: Optional[Callable[[Payload, DomainName], Awaitable]] = None - translation_updated: Optional[Callable[[Payload, DomainName], Awaitable]] = None + order_created: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + order_confirmed: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + order_fully_paid: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + order_updated: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + order_cancelled: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + order_fulfilled: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + + draft_order_created: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None + draft_order_updated: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None + draft_order_deleted: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None + + sale_created: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + sale_updated: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + sale_deleted: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + + invoice_requested: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + invoice_deleted: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + invoice_sent: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + + customer_created: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + customer_updated: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + + product_created: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + product_updated: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + product_deleted: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + + product_variant_created: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None + product_variant_updated: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None + product_variant_deleted: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None + product_variant_out_of_stock: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None + product_variant_back_in_stock: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None + + checkout_created: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + checkout_updated: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + + fulfillment_created: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None + fulfillment_canceled: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None + + notify_user: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + + page_created: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + page_updated: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + page_deleted: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + + payment_authorize: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + payment_capture: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + payment_confirm: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + payment_list_gateways: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None + payment_process: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + payment_refund: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + payment_void: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None + + shipping_list_methods_for_checkout: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None + + translation_created: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None + translation_updated: Optional[ + Callable[[List[Webhook], DomainName], Awaitable] + ] = None def get(self, event_name) -> Optional[Callable[[BaseModel], Awaitable]]: return self.__dict__.get(event_name) diff --git a/src/saleor_app/schemas/manifest.py b/src/saleor_app/schemas/manifest.py index 3df6161..90f7a68 100644 --- a/src/saleor_app/schemas/manifest.py +++ b/src/saleor_app/schemas/manifest.py @@ -1,7 +1,8 @@ from enum import Enum -from typing import List, Optional +from typing import Callable, List, Union -from pydantic import AnyHttpUrl, BaseModel, Field +from pydantic import AnyHttpUrl, BaseModel, Field, root_validator +from starlette.requests import Request class ViewType(str, Enum): @@ -18,48 +19,55 @@ class TargetType(str, Enum): CREATE = "CREATE" -class BaseExtension(BaseModel): +class Extension(BaseModel): label: str view: ViewType type: ExtensionType target: TargetType permissions: List[str] + url: Union[AnyHttpUrl, Callable[[str], str]] class Config: allow_population_by_field_name = True -class SettingsExtension(BaseExtension): - url_for: str - - -class Extension(BaseExtension): - url: AnyHttpUrl - - -class BaseSettingsManifest(BaseModel): +class Manifest(BaseModel): + id: str + permissions: List[str] name: str version: str about: str + extensions: List[Extension] data_privacy: str = Field(..., alias="dataPrivacy") - data_privacy_url: str = Field(..., alias="dataPrivacyUrl") - homepage_url: str = Field(..., alias="homepageUrl") - support_url: str = Field(..., alias="supportUrl") - id: str - permissions: List[str] - app_url: Optional[str] = Field(default=None, alias="appUrl") - extensions: List[SettingsExtension] + data_privacy_url: Union[AnyHttpUrl, Callable[[str], str]] = Field( + ..., alias="dataPrivacyUrl" + ) + homepage_url: Union[AnyHttpUrl, Callable[[str], str]] = Field( + ..., alias="homepageUrl" + ) + support_url: Union[AnyHttpUrl, Callable[[str], str]] = Field( + ..., alias="supportUrl" + ) + configuration_url: Union[AnyHttpUrl, Callable[[str], str]] = Field( + ..., alias="configurationUrl" + ) + app_url: Union[AnyHttpUrl, Callable[[str], str]] = Field("", alias="appUrl") + token_target_url: Union[AnyHttpUrl, Callable[[str], str]] = Field( + ..., alias="tokenTargetUrl" + ) class Config: allow_population_by_field_name = True + @staticmethod + def url_for(name: str): + def resolve(request: Request): + return request.url_for(name=name) -class SettingsManifest(BaseSettingsManifest): - configuration_url_for: str + return resolve - -class Manifest(BaseSettingsManifest): - app_url: str = Field(..., alias="appUrl") - configuration_url: str = Field(..., alias="configurationUrl") - token_target_url: str = Field(..., alias="tokenTargetUrl") - extensions: List[Extension] + @root_validator(pre=True) + def default_token_target_url(cls, values): + if not values.get("token_target_url"): + values["token_target_url"] = cls.url_for("app-install") + return values diff --git a/src/saleor_app/schemas/webhook.py b/src/saleor_app/schemas/webhook.py new file mode 100644 index 0000000..23feecd --- /dev/null +++ b/src/saleor_app/schemas/webhook.py @@ -0,0 +1,50 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Optional, Union + +from pydantic import BaseModel +from pydantic.fields import Field +from pydantic.main import Extra + + +class OldStyleWebhook(BaseModel): + class Config: + extra = Extra.allow + allow_mutation = False + + +class PrincipalType(str, Enum): + app = "app" + user = "user" + + +class Principal(BaseModel): + id: str = Field(..., description="Unique identifier of the principal") + type: PrincipalType = Field(..., description="Defines the principal type") + + +class WebhookMeta(BaseModel): + issuing_principal: Principal + issued_at: datetime + cipher_spec: Optional[str] + format: Optional[str] + + +class WebhookWithMeta(BaseModel): + meta: WebhookMeta + + class Config: + extra = Extra.allow + allow_mutation = False + + +class NewStyleWebhook(BaseModel): + meta: WebhookMeta + payload: Any + + class Config: + extra = Extra.forbid + allow_mutation = False + + +Webhook = Union[NewStyleWebhook, WebhookWithMeta, OldStyleWebhook] diff --git a/src/saleor_app/utils.py b/src/saleor_app/utils.py deleted file mode 100644 index 764631d..0000000 --- a/src/saleor_app/utils.py +++ /dev/null @@ -1,22 +0,0 @@ -import importlib - - -def import_string(dotted_path): - """ - Import a dotted module path and return the attribute/class designated by the - last name in the path. Raise ImportError if the import failed. - """ - try: - module_path, class_name = dotted_path.rsplit(".", 1) - except ValueError as err: - raise ImportError("%s doesn't look like a module path" % dotted_path) from err - - module = importlib.import_module(module_path) - - try: - return getattr(module, class_name) - except AttributeError as err: - raise ImportError( - 'Module "%s" does not define a "%s" attribute/class' - % (module_path, class_name) - ) from err diff --git a/src/saleor_app/validators.py b/src/saleor_app/validators.py deleted file mode 100644 index 65bdb0b..0000000 --- a/src/saleor_app/validators.py +++ /dev/null @@ -1,19 +0,0 @@ -from saleor_app.graphql import get_executor, get_saleor_api_url -from saleor_app.mutations import VERIFY_TOKEN - - -async def verify_token(saleor_domain: str, token: str) -> bool: - api_url = get_saleor_api_url(saleor_domain) - executor = get_executor(host=api_url, auth_token=None) - response, errors = await executor( - VERIFY_TOKEN, - variables={ - "token": token, - }, - ) - if errors: - return False - try: - return response["data"]["tokenVerify"]["isValid"] is True - except KeyError: - return False From d0256318651e1d6dee512dd50a81cc5d390380ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kucmus?= Date: Tue, 14 Dec 2021 02:52:25 +0100 Subject: [PATCH 2/7] add settings, add experimental SQS worker --- poetry.lock | 109 ++++++++++++++++++++++++- pyproject.toml | 4 + samples/complex_app/app.py | 2 +- samples/complex_app/webhooks.py | 2 +- samples/simple_app/app.py | 18 ++-- src/saleor_app/app.py | 24 ++++-- src/saleor_app/endpoints.py | 2 +- src/saleor_app/errors.py | 4 + src/saleor_app/settings.py | 21 +++++ src/saleor_app/tests/sample_app.py | 4 +- src/saleor_app/tests/test_endpoints.py | 2 +- src/saleor_app/workers/base.py | 43 ++++++++++ src/saleor_app/workers/errors.py | 25 ++++++ src/saleor_app/workers/sqs.py | 94 +++++++++++++++++++++ 14 files changed, 331 insertions(+), 23 deletions(-) create mode 100644 src/saleor_app/settings.py create mode 100644 src/saleor_app/workers/base.py create mode 100644 src/saleor_app/workers/errors.py create mode 100644 src/saleor_app/workers/sqs.py diff --git a/poetry.lock b/poetry.lock index 294f103..fda6a72 100644 --- a/poetry.lock +++ b/poetry.lock @@ -156,6 +156,38 @@ typing-extensions = ">=3.7.4" colorama = ["colorama (>=0.4.3)"] d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] +[[package]] +name = "boto3" +version = "1.20.24" +description = "The AWS SDK for Python" +category = "main" +optional = true +python-versions = ">= 3.6" + +[package.dependencies] +botocore = ">=1.23.24,<1.24.0" +jmespath = ">=0.7.1,<1.0.0" +s3transfer = ">=0.5.0,<0.6.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.23.24" +description = "Low-level, data-driven core of boto 3." +category = "main" +optional = true +python-versions = ">= 3.6" + +[package.dependencies] +jmespath = ">=0.7.1,<1.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = ">=1.25.4,<1.27" + +[package.extras] +crt = ["awscrt (==0.12.5)"] + [[package]] name = "certifi" version = "2021.10.8" @@ -470,6 +502,14 @@ MarkupSafe = ">=0.23" [package.extras] i18n = ["Babel (>=0.8)"] +[[package]] +name = "jmespath" +version = "0.10.0" +description = "JSON Matching Expressions" +category = "main" +optional = true +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + [[package]] name = "jwt" version = "1.3.1" @@ -771,6 +811,17 @@ packaging = ">=14.1" pytest = ">=2.9" termcolor = ">=1.1.0" +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = true +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + [[package]] name = "pyyaml" version = "6.0" @@ -801,11 +852,25 @@ idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} [package.extras] idna2008 = ["idna"] +[[package]] +name = "s3transfer" +version = "0.5.0" +description = "An Amazon S3 Transfer Manager" +category = "main" +optional = true +python-versions = ">= 3.6" + +[package.dependencies] +botocore = ">=1.12.36,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] + [[package]] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" @@ -912,6 +977,19 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "urllib3" +version = "1.26.7" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + [[package]] name = "uvicorn" version = "0.16.0" @@ -967,10 +1045,13 @@ python-versions = ">=3.6" idna = ">=2.0" multidict = ">=4.0" +[extras] +sqs = ["boto3"] + [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "b5e7610f5450237209ade4beb3042ca5f453ae689f54adc2cb78e139cf457ce6" +content-hash = "1c22efc89a2b8be57592df22e8167c03b0096fecaac57b865d85550f8ece2540" [metadata.files] aiofiles = [ @@ -1094,6 +1175,14 @@ backcall = [ black = [ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] +boto3 = [ + {file = "boto3-1.20.24-py3-none-any.whl", hash = "sha256:8f08e8e94bf107c5e9866684e9aadf8d9f60abed0cfe5c1dba4e7328674a1986"}, + {file = "boto3-1.20.24.tar.gz", hash = "sha256:739705b28e6b2329ea3b481ba801d439c296aaf176f7850729147ba99bbf8a9a"}, +] +botocore = [ + {file = "botocore-1.23.24-py3-none-any.whl", hash = "sha256:e78d48c50c8c013fb9b362c6202fece2fe868edfd89b51968080180bdff41617"}, + {file = "botocore-1.23.24.tar.gz", hash = "sha256:43006b4f52d7bb655319d3da0f615cdbee7762853acc1ebcb1d49f962e6b4806"}, +] certifi = [ {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, @@ -1375,6 +1464,10 @@ jinja2 = [ {file = "Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419"}, {file = "Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"}, ] +jmespath = [ + {file = "jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"}, + {file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"}, +] jwt = [ {file = "jwt-1.3.1-py3-none-any.whl", hash = "sha256:61c9170f92e736b530655e75374681d4fcca9cfa8763ab42be57353b2b203494"}, ] @@ -1607,6 +1700,10 @@ pytest-cov = [ pytest-sugar = [ {file = "pytest-sugar-0.9.4.tar.gz", hash = "sha256:b1b2186b0a72aada6859bea2a5764145e3aaa2c1cfbb23c3a19b5f7b697563d3"}, ] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] pyyaml = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, @@ -1722,6 +1819,10 @@ rfc3986 = [ {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, ] +s3transfer = [ + {file = "s3transfer-0.5.0-py3-none-any.whl", hash = "sha256:9c1dc369814391a6bda20ebbf4b70a0f34630592c9aa520856bf384916af2803"}, + {file = "s3transfer-0.5.0.tar.gz", hash = "sha256:50ed823e1dc5868ad40c8dc92072f757aa0e653a192845c94a3b676f4a62da4c"}, +] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -1778,6 +1879,10 @@ typing-extensions = [ {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, ] +urllib3 = [ + {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, + {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, +] uvicorn = [ {file = "uvicorn-0.16.0-py3-none-any.whl", hash = "sha256:d8c839231f270adaa6d338d525e2652a0b4a5f4c2430b5c4ef6ae4d11776b0d2"}, {file = "uvicorn-0.16.0.tar.gz", hash = "sha256:eacb66afa65e0648fcbce5e746b135d09722231ffffc61883d4fac2b62fbea8d"}, diff --git a/pyproject.toml b/pyproject.toml index c93b17a..3ad9670 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ Jinja2 = "^2" aiofiles = "^0" aiohttp = "^3" jwt = "^1" +boto3 = {version = "^1.20.24", optional = true} [tool.poetry.dev-dependencies] ipython = "^7" @@ -28,6 +29,9 @@ tox-poetry = "^0" ipdb = "^0" httpx = "^0" +[tool.poetry.extras] +sqs = ["boto3"] + [tool.black] target_version = ['py38'] include = '\.pyi?$' diff --git a/samples/complex_app/app.py b/samples/complex_app/app.py index a4c6b7c..539abd8 100644 --- a/samples/complex_app/app.py +++ b/samples/complex_app/app.py @@ -39,7 +39,7 @@ async def get_webhook_details(domain_name: DomainName) -> WebhookData: app = SaleorApp( validate_domain=validate_domain, save_app_data=store_app_data, - webhook_handlers=webhook_handlers, + http_webhook_handlers=webhook_handlers, get_webhook_details=get_webhook_details, ) app.configuration_router.include_router(configuration_router) diff --git a/samples/complex_app/webhooks.py b/samples/complex_app/webhooks.py index 0e3cc51..002dcf8 100644 --- a/samples/complex_app/webhooks.py +++ b/samples/complex_app/webhooks.py @@ -17,7 +17,7 @@ async def product_deleted(payload: Payload, saleor_domain: DomainName): print(payload) -webhook_handlers = WebhookHandlers( +http_webhook_handlers = WebhookHandlers( product_created=product_created, product_updated=product_updated, product_deleted=product_deleted, diff --git a/samples/simple_app/app.py b/samples/simple_app/app.py index ae57d4c..1c0e7f7 100644 --- a/samples/simple_app/app.py +++ b/samples/simple_app/app.py @@ -1,10 +1,9 @@ -from logging import debug from pathlib import Path from typing import List, Optional from fastapi.param_functions import Depends from fastapi.responses import HTMLResponse -from pydantic import BaseModel, BaseSettings +from pydantic import BaseModel from starlette.middleware.cors import CORSMiddleware from saleor_app.app import SaleorApp @@ -14,11 +13,12 @@ from saleor_app.schemas.handlers import WebhookHandlers from saleor_app.schemas.manifest import Manifest from saleor_app.schemas.webhook import Webhook +from saleor_app.settings import SaleorAppSettings PROJECT_DIR = Path(__file__).parent -class Settings(BaseSettings): +class Settings(SaleorAppSettings): debug: bool = False dev_saleor_token: Optional[str] @@ -92,15 +92,21 @@ async def product_deleted(payload: List[Webhook], saleor_domain=Depends(saleor_d ), validate_domain=validate_domain, save_app_data=store_app_data, - webhook_handlers=WebhookHandlers( + # TODO: make it possible not to have any webhooks + http_webhook_handlers=WebhookHandlers( product_created=product_created, product_updated=product_updated, product_deleted=product_deleted, ), get_webhook_details=get_webhook_details, - use_insecure_saleor_http=settings.debug, - development_auth_token=settings.dev_saleor_token + app_settings=settings, ) + + + + + + app.configuration_router.get( "/", response_class=HTMLResponse, name="configuration-form" )(get_public_form) diff --git a/src/saleor_app/app.py b/src/saleor_app/app.py index f89ef6f..a51a74d 100644 --- a/src/saleor_app/app.py +++ b/src/saleor_app/app.py @@ -5,10 +5,12 @@ from saleor_app.deps import verify_saleor_domain, verify_webhook_signature from saleor_app.endpoints import handle_webhook, install, manifest +from saleor_app.errors import ConfigurationError from saleor_app.http import WebhookRoute from saleor_app.schemas.core import DomainName, WebhookData from saleor_app.schemas.handlers import WebhookHandlers from saleor_app.schemas.manifest import Manifest +from saleor_app.settings import SaleorAppSettings class SaleorApp(FastAPI): @@ -18,18 +20,22 @@ def __init__( manifest: Manifest, validate_domain: Callable[[DomainName], Awaitable[bool]], save_app_data: Callable[[DomainName, WebhookData], Awaitable], - webhook_handlers: WebhookHandlers, get_webhook_details: Callable[[DomainName], Awaitable[WebhookData]], - use_insecure_saleor_http: bool = False, - development_auth_token: Optional[str] = None, + app_settings: SaleorAppSettings, + http_webhook_handlers: Optional[WebhookHandlers] = None, + sqs_webhook_handlers: Optional[WebhookHandlers] = None, **kwargs, ): super().__init__(**kwargs) self.manifest = manifest - self.webhook_handlers = webhook_handlers - self.use_insecure_saleor_http = use_insecure_saleor_http - self.development_auth_token = development_auth_token + self.http_webhook_handlers = http_webhook_handlers + self.sqs_webhook_handlers = sqs_webhook_handlers + self.app_settings = app_settings + if self.sqs_webhook_handlers and not app_settings.aws: + raise ConfigurationError( + "To leverage SQS webhook handlers you must provide settings.aws" + ) self.extra["saleor"] = { "validate_domain": validate_domain, @@ -74,9 +80,9 @@ def include_webhook_router(self): self.webhook_handler_routes = { name: APIRoute( "", - endpoint, + handler, ) - for name, endpoint in self.webhook_handlers - if endpoint + for name, handler in self.http_webhook_handlers + if handler } self.include_router(router) diff --git a/src/saleor_app/endpoints.py b/src/saleor_app/endpoints.py index 0b48492..dfb5314 100644 --- a/src/saleor_app/endpoints.py +++ b/src/saleor_app/endpoints.py @@ -41,7 +41,7 @@ async def install( saleor_domain=saleor_domain, auth_token=data.auth_token, manifest=request.app.manifest, - events=request.app.webhook_handlers.get_assigned_events(), + events=request.app.http_webhook_handlers.get_assigned_events(), target_url=request.url_for("handle-webhook"), save_app_data_callback=request.app.extra["saleor"]["save_app_data"], use_insecure_saleor_http=request.app.use_insecure_saleor_http, diff --git a/src/saleor_app/errors.py b/src/saleor_app/errors.py index ef9ce31..b1c55e4 100644 --- a/src/saleor_app/errors.py +++ b/src/saleor_app/errors.py @@ -1,2 +1,6 @@ class InstallAppError(Exception): """Install App error""" + + +class ConfigurationError(Exception): + """App is misconfigured""" diff --git a/src/saleor_app/settings.py b/src/saleor_app/settings.py new file mode 100644 index 0000000..5e80be1 --- /dev/null +++ b/src/saleor_app/settings.py @@ -0,0 +1,21 @@ +from typing import Optional + +from pydantic import BaseSettings + + +class AWSSettings(BaseSettings): + account_id: str + access_key_id: str + secret_access_key: str + region: str + endpoint_url: Optional[str] + + class Config: + env_prefix = "AWS_" + + +class SaleorAppSettings(BaseSettings): + debug: bool = False + use_insecure_saleor_http: bool = False + development_auth_token: Optional[str] = None + aws: Optional[AWSSettings] = None diff --git a/src/saleor_app/tests/sample_app.py b/src/saleor_app/tests/sample_app.py index cf7d03d..1806828 100644 --- a/src/saleor_app/tests/sample_app.py +++ b/src/saleor_app/tests/sample_app.py @@ -52,7 +52,7 @@ async def extension(): def get_app(): - webhook_handlers = WebhookHandlers( + http_webhook_handlers = WebhookHandlers( product_created=product_created, product_updated=product_updated, product_deleted=product_deleted, @@ -62,7 +62,7 @@ def get_app(): app = SaleorApp( validate_domain=validate_domain, save_app_data=store_app_data, - webhook_handlers=webhook_handlers, + http_webhook_handlers=http_webhook_handlers, get_webhook_details=get_webhook_details, ) app.configuration_router.get( diff --git a/src/saleor_app/tests/test_endpoints.py b/src/saleor_app/tests/test_endpoints.py index 4054612..b52f867 100644 --- a/src/saleor_app/tests/test_endpoints.py +++ b/src/saleor_app/tests/test_endpoints.py @@ -281,7 +281,7 @@ async def test_handle_webhook(app): validate_domain_mock = AsyncMock(return_value=True) app.extra["saleor"]["validate_domain"] = validate_domain_mock - app.extra["saleor"]["webhook_handlers"] = WebhookHandlers( + app.extra["saleor"]["http_webhook_handlers"] = WebhookHandlers( product_created=product_created_mock, product_updated=product_updated_mock, ) diff --git a/src/saleor_app/workers/base.py b/src/saleor_app/workers/base.py new file mode 100644 index 0000000..54b2d45 --- /dev/null +++ b/src/saleor_app/workers/base.py @@ -0,0 +1,43 @@ +import asyncio +import logging +from signal import signal, SIGINT, SIGTERM + +from saleor_app.app import SaleorApp +from saleor_app.schemas.webhook import Webhook + +logger = logging.getLogger(__name__) + + +class SignalHandler: + def __init__(self): + self.received_signal = False + signal(SIGINT, self._signal_handler) + signal(SIGTERM, self._signal_handler) + + def _signal_handler(self, signal, frame): + self.received_signal = True + + +class SaleorAppWorker: + def __init__(self, app: SaleorApp): + self.app = app + self.handlers = { + name: handler for name, handler in self.app.sqs_webhook_handlers if handler + } + + async def parse_webhook_payload(self, message_body): + return Webhook.parse_raw(message_body) + + async def loop(self, queue_name: str): + raise NotImplementedError() + + async def run(self, queue_name: str): + signal_handler = SignalHandler() + while not signal_handler.received_signal: + logger.info("Starting the loop") + try: + await self.loop(queue_name=queue_name) + except Exception as exc: + logger.critical("The loop exited with an error, will run the loop again in 60 seconds", exc_info=exc) + asyncio.sleep(60) + logger.info("Stopping the loop") diff --git a/src/saleor_app/workers/errors.py b/src/saleor_app/workers/errors.py new file mode 100644 index 0000000..563ebb6 --- /dev/null +++ b/src/saleor_app/workers/errors.py @@ -0,0 +1,25 @@ +class BaseWorkerError(Exception): + + def __init__(self, detail, message): + self.detail = detail + self.mesage = message + + +class TransientError(BaseWorkerError): + """ + Error that can be solved in time, like any connectivity errors, in case + of a transient error the worker will hald and retry to handle the failing + event + """ + + +class NonTransientError(BaseWorkerError): + """ + Error that will never be solved, like a malformet payload error, those errors + can be handled by a deadletter callback. The worker will not be halted in + the case of a non-transient error. + """ + + +class UnrecognizedEventPayload(NonTransientError): + pass diff --git a/src/saleor_app/workers/sqs.py b/src/saleor_app/workers/sqs.py new file mode 100644 index 0000000..a8a3d3e --- /dev/null +++ b/src/saleor_app/workers/sqs.py @@ -0,0 +1,94 @@ +import asyncio +import logging +from typing import Any, List, Union + +import boto3 +from pydantic import ValidationError + +from saleor_app.settings import AWSSettings +from saleor_app.workers.base import SaleorAppWorker +from saleor_app.workers.errors import NonTransientError, TransientError, UnrecognizedEventPayload + + +logger = logging.getLogger(__name__) + + +class SaleorAppSQSWorker(SaleorAppWorker): + + def __init__(self, queue_name: str, max_number_of_messages: int = 1, wait_time_seconds: int = 0, *args, **kwargs): + super().__init__(*args, **kwargs) + self.transient_error_wait_seconds = 5 + self.queue_name = queue_name + self.max_number_of_messages = max_number_of_messages + self.wait_time_seconds = wait_time_seconds + + async def process_message(self, message): # TODO: figure out the type of a message and mark that as the return type + try: + webhook = self.parse_webhook_payload(message_body=message.body) + except ValidationError: + raise UnrecognizedEventPayload("The message body was not recognized", message=message) + + # TODO: allow to decide if not recognized events should be ignored or dead lettered + handler = self.handlers.get() # TODO: figure out how to get an event_type from a message, is it in the message? if not add to webhook meta? + await handler(webhook, message) + return message + + async def handle_non_transient_error(self, message, exc): + """ + This handler will do nothing, thus allowing the loop to exhaust the + attempts of a message retrieval which will result in a SQS dead letter + """ + return + + async def handle_transient_error(self, message, exc): + while True: + logger.warning("Transient error occured halting, will retry again in %s seconds", self.transient_error_wait_seconds, exc_info=exc) + asyncio.sleep(self.transient_error_wait_seconds) + try: + await self.process_message(message) + except TransientError as new_exc: + exc = new_exc + else: + break + + async def handle_unhandled_exceptions(self, exc): + """ + This handler will do nothing, thus allowing the loop to exhaust the + attempts of a message retrieval which will result in a SQS dead letter + """ + # This should not happen, that's why it's critical + logger.critical("Failed to handle a message, message will be put in SQS deadletter", exc_info=exc) + return + + + async def loop(self): + aws_settings: AWSSettings = self.app.settings.aws + sqs = boto3.resource( + "sqs", + region_name=aws_settings.region, + endpoint_url=aws_settings.endpoint_url, + aws_access_key_id=aws_settings.access_key_id, + aws_secret_access_key=aws_settings.secret_access_key, + ) + sqs_queue = sqs.get_queue_by_name(QueueName=self.queue_name) + + while True: + messages = sqs_queue.receive_messages( + MaxNumberOfMessages=self.max_number_of_messages, + WaitTimeSeconds=self.wait_time_seconds, + ) + coros = [self.process_message(message) for message in messages] + results: List[Union[Any, Exception]] = asyncio.gather(*coros, return_exceptions=True) # TODO: figure out the type of a message and put it instead of Any + successful_results = [] + for result in results: + if isinstance(result, TransientError): + await self.handle_transient_error(result.message, result) + elif isinstance(result, NonTransientError): + await self.handle_non_transient_error(result.message, result) + elif isinstance(result, Exception): + await self.handle_unhandled_exceptions(result) + else: + successful_results.append(result) + + for message in successful_results: + message.delete() From 129e6381416527459fafb46d17d4f758d4ef7025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kucmus?= Date: Sun, 16 Jan 2022 01:26:43 +0100 Subject: [PATCH 3/7] Add experimental SQS support, imporved resiency, add test coverage - add SQS targetUrl handling on installation - add the ability to not provide any handlers - stop using FastAPI app.extra - add lazy url loading on manifest definition - hide potentially sensitive logs to debug level - add dynamic webhook handler model definition (for easier maintenence) - add EXPERIMENTAL SQS worker (untested) --- .flake8 | 2 +- poetry.lock | 298 ++++++++++---------- pyproject.toml | 10 +- samples/simple_app/app.py | 60 ++-- src/saleor_app/app.py | 45 ++- src/saleor_app/deps.py | 23 +- src/saleor_app/endpoints.py | 67 ++--- src/saleor_app/install.py | 59 ++-- src/saleor_app/saleor/client.py | 3 +- src/saleor_app/schemas/core.py | 1 - src/saleor_app/schemas/handlers.py | 176 ++++++------ src/saleor_app/schemas/manifest.py | 38 +-- src/saleor_app/schemas/utils.py | 24 ++ src/saleor_app/schemas/webhook.py | 8 +- src/saleor_app/tests/conftest.py | 111 +++++++- src/saleor_app/tests/saleor/test_client.py | 86 ++++++ src/saleor_app/tests/sample_app.py | 80 ------ src/saleor_app/tests/sample_settings.py | 35 --- src/saleor_app/tests/test_app.py | 49 ++++ src/saleor_app/tests/test_conf.py | 33 --- src/saleor_app/tests/test_deps.py | 117 ++++++++ src/saleor_app/tests/test_endpoints.py | 311 +++------------------ src/saleor_app/tests/test_graphql.py | 87 ------ src/saleor_app/tests/test_install.py | 219 ++++----------- src/saleor_app/workers/base.py | 7 +- src/saleor_app/workers/errors.py | 5 +- src/saleor_app/workers/sqs.py | 55 ++-- 27 files changed, 933 insertions(+), 1076 deletions(-) create mode 100644 src/saleor_app/schemas/utils.py create mode 100644 src/saleor_app/tests/saleor/test_client.py delete mode 100644 src/saleor_app/tests/sample_app.py delete mode 100644 src/saleor_app/tests/sample_settings.py create mode 100644 src/saleor_app/tests/test_app.py delete mode 100644 src/saleor_app/tests/test_conf.py create mode 100644 src/saleor_app/tests/test_deps.py delete mode 100644 src/saleor_app/tests/test_graphql.py diff --git a/.flake8 b/.flake8 index b7102aa..f99cf8d 100644 --- a/.flake8 +++ b/.flake8 @@ -5,5 +5,5 @@ exclude = docs/, node_modules/, */versions/ -ignore = H101,H238,H301,H306,W503 +ignore = H101,H238,H301,H306,W503,E501 max-line-length = 88 diff --git a/poetry.lock b/poetry.lock index fda6a72..45a671d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -39,7 +39,7 @@ frozenlist = ">=1.1.0" [[package]] name = "anyio" -version = "3.4.0" +version = "3.5.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" category = "main" optional = false @@ -50,7 +50,7 @@ idna = ">=2.8" sniffio = ">=1.1" [package.extras] -doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] +doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] trio = ["trio (>=0.16)"] @@ -83,15 +83,12 @@ tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] [[package]] name = "async-timeout" -version = "4.0.1" +version = "4.0.2" description = "Timeout context manager for asyncio programs" category = "main" optional = false python-versions = ">=3.6" -[package.dependencies] -typing-extensions = ">=3.6.5" - [[package]] name = "atomicwrites" version = "1.4.0" @@ -102,17 +99,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "21.2.0" +version = "21.4.0" description = "Classes Without Boilerplate" category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] [[package]] name = "backcall" @@ -122,18 +119,6 @@ category = "dev" optional = false python-versions = "*" -[[package]] -name = "backports.entry-points-selectable" -version = "1.1.1" -description = "Compatibility shim providing selectable entry points for older implementations" -category = "dev" -optional = false -python-versions = ">=2.7" - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] - [[package]] name = "black" version = "20.8b1" @@ -158,14 +143,14 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] name = "boto3" -version = "1.20.24" +version = "1.20.36" description = "The AWS SDK for Python" category = "main" optional = true python-versions = ">= 3.6" [package.dependencies] -botocore = ">=1.23.24,<1.24.0" +botocore = ">=1.23.36,<1.24.0" jmespath = ">=0.7.1,<1.0.0" s3transfer = ">=0.5.0,<0.6.0" @@ -174,7 +159,7 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.23.24" +version = "1.23.36" description = "Low-level, data-driven core of boto 3." category = "main" optional = true @@ -217,7 +202,7 @@ python-versions = ">=3.6.1" [[package]] name = "charset-normalizer" -version = "2.0.9" +version = "2.0.10" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false @@ -258,7 +243,7 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "36.0.0" +version = "36.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." category = "main" optional = false @@ -277,7 +262,7 @@ test = ["pytest (>=6.2.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pr [[package]] name = "decorator" -version = "5.1.0" +version = "5.1.1" description = "Decorators for Humans" category = "dev" optional = false @@ -293,7 +278,7 @@ python-versions = "*" [[package]] name = "fastapi" -version = "0.70.1" +version = "0.71.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" category = "main" optional = false @@ -301,21 +286,21 @@ python-versions = ">=3.6.1" [package.dependencies] 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" -starlette = "0.16.0" +starlette = "0.17.1" [package.extras] all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] -doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=7.1.9,<8.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"] +doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"] test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"] [[package]] name = "filelock" -version = "3.4.0" +version = "3.4.2" description = "A platform independent file lock." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] @@ -352,7 +337,7 @@ python-versions = ">=3.6" [[package]] name = "httpcore" -version = "0.14.3" +version = "0.14.4" description = "A minimal low-level HTTP client." category = "dev" optional = false @@ -369,7 +354,7 @@ http2 = ["h2 (>=3,<5)"] [[package]] name = "httpx" -version = "0.21.1" +version = "0.21.3" description = "The next generation HTTP client." category = "dev" optional = false @@ -389,7 +374,7 @@ http2 = ["h2 (>=3,<5)"] [[package]] name = "identify" -version = "2.4.0" +version = "2.4.4" description = "File identification library for Python" category = "dev" optional = false @@ -429,7 +414,7 @@ toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""} [[package]] name = "ipython" -version = "7.30.1" +version = "7.31.0" description = "IPython: Productive Interactive Computing" category = "dev" optional = false @@ -624,11 +609,11 @@ python-versions = "*" [[package]] name = "platformdirs" -version = "2.4.0" +version = "2.4.1" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] @@ -707,7 +692,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pydantic" -version = "1.8.2" +version = "1.9.0" description = "Data validation and settings management using python 3.6 type hinting" category = "main" optional = false @@ -730,7 +715,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.10.0" +version = "2.11.2" description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false @@ -770,17 +755,17 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm [[package]] name = "pytest-asyncio" -version = "0.16.0" -description = "Pytest support for asyncio." +version = "0.17.0" +description = "Pytest support for asyncio" category = "dev" optional = false -python-versions = ">= 3.6" +python-versions = ">=3.7" [package.dependencies] pytest = ">=5.4.0" [package.extras] -testing = ["coverage", "hypothesis (>=5.7.1)"] +testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)"] [[package]] name = "pytest-cov" @@ -798,6 +783,20 @@ toml = "*" [package.extras] testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] +[[package]] +name = "pytest-mock" +version = "3.6.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "tox", "pytest-asyncio"] + [[package]] name = "pytest-sugar" version = "0.9.4" @@ -884,7 +883,7 @@ python-versions = ">=3.5" [[package]] name = "starlette" -version = "0.16.0" +version = "0.17.1" description = "The little ASGI library that shines." category = "main" optional = false @@ -894,7 +893,7 @@ python-versions = ">=3.6" anyio = ">=3.0.0,<4" [package.extras] -full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "graphene"] +full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] [[package]] name = "termcolor" @@ -914,7 +913,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "tox" -version = "3.24.4" +version = "3.24.5" description = "tox is a generic virtualenv management and test command line tool" category = "dev" optional = false @@ -932,7 +931,7 @@ virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2, [package.extras] docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] -testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)", "pathlib2 (>=2.3.3)"] +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)"] [[package]] name = "tox-poetry" @@ -979,7 +978,7 @@ python-versions = ">=3.6" [[package]] name = "urllib3" -version = "1.26.7" +version = "1.26.8" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = true @@ -992,7 +991,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "uvicorn" -version = "0.16.0" +version = "0.17.0" description = "The lightning-fast ASGI server." category = "main" optional = false @@ -1004,18 +1003,17 @@ click = ">=7.0" h11 = ">=0.8" [package.extras] -standard = ["httptools (>=0.2.0,<0.4.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "websockets (>=9.1)", "websockets (>=10.0)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"] +standard = ["websockets (>=10.0)", "httptools (>=0.2.0,<0.4.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"] [[package]] name = "virtualenv" -version = "20.10.0" +version = "20.13.0" description = "Virtual Python Environment builder" category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -"backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" filelock = ">=3.2,<4" platformdirs = ">=2,<3" @@ -1051,7 +1049,7 @@ sqs = ["boto3"] [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "1c22efc89a2b8be57592df22e8167c03b0096fecaac57b865d85550f8ece2540" +content-hash = "62216fadc2823835780f7431eee41cac6097e85e4e069d03505ecef853fa20ac" [metadata.files] aiofiles = [ @@ -1137,8 +1135,8 @@ aiosignal = [ {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"}, ] anyio = [ - {file = "anyio-3.4.0-py3-none-any.whl", hash = "sha256:2855a9423524abcdd652d942f8932fda1735210f77a6b392eafd9ff34d3fe020"}, - {file = "anyio-3.4.0.tar.gz", hash = "sha256:24adc69309fb5779bc1e06158e143e0b6d2c56b302a3ac3de3083c705a6ed39d"}, + {file = "anyio-3.5.0-py3-none-any.whl", hash = "sha256:b5fa16c5ff93fa1046f2eeb5bbff2dad4d3514d6cda61d02816dba34fa8c3c2e"}, + {file = "anyio-3.5.0.tar.gz", hash = "sha256:a0aeffe2fb1fdf374a8e4b471444f0f3ac4fb9f5a5b542b48824475e0042a5a6"}, ] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, @@ -1153,35 +1151,31 @@ asgiref = [ {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"}, ] async-timeout = [ - {file = "async-timeout-4.0.1.tar.gz", hash = "sha256:b930cb161a39042f9222f6efb7301399c87eeab394727ec5437924a36d6eef51"}, - {file = "async_timeout-4.0.1-py3-none-any.whl", hash = "sha256:a22c0b311af23337eb05fcf05a8b51c3ea53729d46fb5460af62bee033cec690"}, + {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, + {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, ] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, - {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] backcall = [ {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, ] -"backports.entry-points-selectable" = [ - {file = "backports.entry_points_selectable-1.1.1-py2.py3-none-any.whl", hash = "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b"}, - {file = "backports.entry_points_selectable-1.1.1.tar.gz", hash = "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386"}, -] black = [ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] boto3 = [ - {file = "boto3-1.20.24-py3-none-any.whl", hash = "sha256:8f08e8e94bf107c5e9866684e9aadf8d9f60abed0cfe5c1dba4e7328674a1986"}, - {file = "boto3-1.20.24.tar.gz", hash = "sha256:739705b28e6b2329ea3b481ba801d439c296aaf176f7850729147ba99bbf8a9a"}, + {file = "boto3-1.20.36-py3-none-any.whl", hash = "sha256:a46e78108aa9ead583f2ae9e2c769c2865f0ed8567da1bc0568fbb4809932807"}, + {file = "boto3-1.20.36.tar.gz", hash = "sha256:d2df801e368ec0782c6ed50232dd76bf0ae38ef63056d8d363a39bad62947166"}, ] botocore = [ - {file = "botocore-1.23.24-py3-none-any.whl", hash = "sha256:e78d48c50c8c013fb9b362c6202fece2fe868edfd89b51968080180bdff41617"}, - {file = "botocore-1.23.24.tar.gz", hash = "sha256:43006b4f52d7bb655319d3da0f615cdbee7762853acc1ebcb1d49f962e6b4806"}, + {file = "botocore-1.23.36-py3-none-any.whl", hash = "sha256:eb69259ee19b36073c899f4cd75af8cef5bc042a9cf053f2fcbb4f544da751b2"}, + {file = "botocore-1.23.36.tar.gz", hash = "sha256:3af8fef31baae5cc4016dadcb4cf7848a0bf1efb74bcdb7d1dc6b8d0910e1898"}, ] certifi = [ {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, @@ -1244,8 +1238,8 @@ cfgv = [ {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.9.tar.gz", hash = "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c"}, - {file = "charset_normalizer-2.0.9-py3-none-any.whl", hash = "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721"}, + {file = "charset-normalizer-2.0.10.tar.gz", hash = "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd"}, + {file = "charset_normalizer-2.0.10-py3-none-any.whl", hash = "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"}, ] click = [ {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, @@ -1305,43 +1299,42 @@ coverage = [ {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"}, ] cryptography = [ - {file = "cryptography-36.0.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:9511416e85e449fe1de73f7f99b21b3aa04fba4c4d335d30c486ba3756e3a2a6"}, - {file = "cryptography-36.0.0-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:97199a13b772e74cdcdb03760c32109c808aff7cd49c29e9cf4b7754bb725d1d"}, - {file = "cryptography-36.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:494106e9cd945c2cadfce5374fa44c94cfadf01d4566a3b13bb487d2e6c7959e"}, - {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6fbbbb8aab4053fa018984bb0e95a16faeb051dd8cca15add2a27e267ba02b58"}, - {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:684993ff6f67000a56454b41bdc7e015429732d65a52d06385b6e9de6181c71e"}, - {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c702855cd3174666ef0d2d13dcc879090aa9c6c38f5578896407a7028f75b9f"}, - {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d91bc9f535599bed58f6d2e21a2724cb0c3895bf41c6403fe881391d29096f1d"}, - {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:b17d83b3d1610e571fedac21b2eb36b816654d6f7496004d6a0d32f99d1d8120"}, - {file = "cryptography-36.0.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8982c19bb90a4fa2aad3d635c6d71814e38b643649b4000a8419f8691f20ac44"}, - {file = "cryptography-36.0.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:24469d9d33217ffd0ce4582dfcf2a76671af115663a95328f63c99ec7ece61a4"}, - {file = "cryptography-36.0.0-cp36-abi3-win32.whl", hash = "sha256:f6a5a85beb33e57998dc605b9dbe7deaa806385fdf5c4810fb849fcd04640c81"}, - {file = "cryptography-36.0.0-cp36-abi3-win_amd64.whl", hash = "sha256:2deab5ec05d83ddcf9b0916319674d3dae88b0e7ee18f8962642d3cde0496568"}, - {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2049f8b87f449fc6190350de443ee0c1dd631f2ce4fa99efad2984de81031681"}, - {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a776bae1629c8d7198396fd93ec0265f8dd2341c553dc32b976168aaf0e6a636"}, - {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:aa94d617a4cd4cdf4af9b5af65100c036bce22280ebb15d8b5262e8273ebc6ba"}, - {file = "cryptography-36.0.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:5c49c9e8fb26a567a2b3fa0343c89f5d325447956cc2fc7231c943b29a973712"}, - {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef216d13ac8d24d9cd851776662f75f8d29c9f2d05cdcc2d34a18d32463a9b0b"}, - {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:231c4a69b11f6af79c1495a0e5a85909686ea8db946935224b7825cfb53827ed"}, - {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:f92556f94e476c1b616e6daec5f7ddded2c082efa7cee7f31c7aeda615906ed8"}, - {file = "cryptography-36.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d73e3a96c38173e0aa5646c31bf8473bc3564837977dd480f5cbeacf1d7ef3a3"}, - {file = "cryptography-36.0.0.tar.gz", hash = "sha256:52f769ecb4ef39865719aedc67b4b7eae167bafa48dbc2a26dd36fa56460507f"}, + {file = "cryptography-36.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:73bc2d3f2444bcfeac67dd130ff2ea598ea5f20b40e36d19821b4df8c9c5037b"}, + {file = "cryptography-36.0.1-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:2d87cdcb378d3cfed944dac30596da1968f88fb96d7fc34fdae30a99054b2e31"}, + {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74d6c7e80609c0f4c2434b97b80c7f8fdfaa072ca4baab7e239a15d6d70ed73a"}, + {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:6c0c021f35b421ebf5976abf2daacc47e235f8b6082d3396a2fe3ccd537ab173"}, + {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d59a9d55027a8b88fd9fd2826c4392bd487d74bf628bb9d39beecc62a644c12"}, + {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a817b961b46894c5ca8a66b599c745b9a3d9f822725221f0e0fe49dc043a3a3"}, + {file = "cryptography-36.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:94ae132f0e40fe48f310bba63f477f14a43116f05ddb69d6fa31e93f05848ae2"}, + {file = "cryptography-36.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7be0eec337359c155df191d6ae00a5e8bbb63933883f4f5dffc439dac5348c3f"}, + {file = "cryptography-36.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e0344c14c9cb89e76eb6a060e67980c9e35b3f36691e15e1b7a9e58a0a6c6dc3"}, + {file = "cryptography-36.0.1-cp36-abi3-win32.whl", hash = "sha256:4caa4b893d8fad33cf1964d3e51842cd78ba87401ab1d2e44556826df849a8ca"}, + {file = "cryptography-36.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:391432971a66cfaf94b21c24ab465a4cc3e8bf4a939c1ca5c3e3a6e0abebdbcf"}, + {file = "cryptography-36.0.1-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bb5829d027ff82aa872d76158919045a7c1e91fbf241aec32cb07956e9ebd3c9"}, + {file = "cryptography-36.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebc15b1c22e55c4d5566e3ca4db8689470a0ca2babef8e3a9ee057a8b82ce4b1"}, + {file = "cryptography-36.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:596f3cd67e1b950bc372c33f1a28a0692080625592ea6392987dba7f09f17a94"}, + {file = "cryptography-36.0.1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:30ee1eb3ebe1644d1c3f183d115a8c04e4e603ed6ce8e394ed39eea4a98469ac"}, + {file = "cryptography-36.0.1-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ec63da4e7e4a5f924b90af42eddf20b698a70e58d86a72d943857c4c6045b3ee"}, + {file = "cryptography-36.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca238ceb7ba0bdf6ce88c1b74a87bffcee5afbfa1e41e173b1ceb095b39add46"}, + {file = "cryptography-36.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:ca28641954f767f9822c24e927ad894d45d5a1e501767599647259cbf030b903"}, + {file = "cryptography-36.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:39bdf8e70eee6b1c7b289ec6e5d84d49a6bfa11f8b8646b5b3dfe41219153316"}, + {file = "cryptography-36.0.1.tar.gz", hash = "sha256:53e5c1dc3d7a953de055d77bef2ff607ceef7a2aac0353b5d630ab67f7423638"}, ] decorator = [ - {file = "decorator-5.1.0-py3-none-any.whl", hash = "sha256:7b12e7c3c6ab203a29e157335e9122cb03de9ab7264b137594103fd4a683b374"}, - {file = "decorator-5.1.0.tar.gz", hash = "sha256:e59913af105b9860aa2c8d3272d9de5a56a4e608db9a2f167a8480b323d529a7"}, + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, ] distlib = [ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, ] fastapi = [ - {file = "fastapi-0.70.1-py3-none-any.whl", hash = "sha256:5367226c7bcd7bfb2e17edaf225fd9a983095b1372281e9a3eb661336fb93748"}, - {file = "fastapi-0.70.1.tar.gz", hash = "sha256:21d03979b5336375c66fa5d1f3126c6beca650d5d2166fbb78345a30d33c8d06"}, + {file = "fastapi-0.71.0-py3-none-any.whl", hash = "sha256:a78eca6b084de9667f2d5f37e2ae297270e5a119cd01c2f04815795da92fc87f"}, + {file = "fastapi-0.71.0.tar.gz", hash = "sha256:2b5ac0ae89c80b40d1dd4b2ea0bb1f78d7c4affd3644d080bf050f084759fff2"}, ] filelock = [ - {file = "filelock-3.4.0-py3-none-any.whl", hash = "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8"}, - {file = "filelock-3.4.0.tar.gz", hash = "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4"}, + {file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"}, + {file = "filelock-3.4.2.tar.gz", hash = "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80"}, ] flake8 = [ {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, @@ -1426,16 +1419,16 @@ h11 = [ {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, ] httpcore = [ - {file = "httpcore-0.14.3-py3-none-any.whl", hash = "sha256:9a98d2416b78976fc5396ff1f6b26ae9885efbb3105d24eed490f20ab4c95ec1"}, - {file = "httpcore-0.14.3.tar.gz", hash = "sha256:d10162a63265a0228d5807964bd964478cbdb5178f9a2eedfebb2faba27eef5d"}, + {file = "httpcore-0.14.4-py3-none-any.whl", hash = "sha256:9410fe352bea732311f2b2bee0555c8cc5e62b9a73b9d3272fe125a2aa6eb28e"}, + {file = "httpcore-0.14.4.tar.gz", hash = "sha256:d4305811f604d3c2e22869147392f134796976ff946c96a8cfba87f4e0171d83"}, ] httpx = [ - {file = "httpx-0.21.1-py3-none-any.whl", hash = "sha256:208e5ef2ad4d105213463cfd541898ed9d11851b346473539a8425e644bb7c66"}, - {file = "httpx-0.21.1.tar.gz", hash = "sha256:02af20df486b78892a614a7ccd4e4e86a5409ec4981ab0e422c579a887acad83"}, + {file = "httpx-0.21.3-py3-none-any.whl", hash = "sha256:df9a0fd43fa79dbab411d83eb1ea6f7a525c96ad92e60c2d7f40388971b25777"}, + {file = "httpx-0.21.3.tar.gz", hash = "sha256:7a3eb67ef0b8abbd6d9402248ef2f84a76080fa1c839f8662e6eb385640e445a"}, ] identify = [ - {file = "identify-2.4.0-py2.py3-none-any.whl", hash = "sha256:eba31ca80258de6bb51453084bff4a923187cd2193b9c13710f2516ab30732cc"}, - {file = "identify-2.4.0.tar.gz", hash = "sha256:a33ae873287e81651c7800ca309dc1f84679b763c9c8b30680e16fbfa82f0107"}, + {file = "identify-2.4.4-py2.py3-none-any.whl", hash = "sha256:aa68609c7454dbcaae60a01ff6b8df1de9b39fe6e50b1f6107ec81dcda624aa6"}, + {file = "identify-2.4.4.tar.gz", hash = "sha256:6b4b5031f69c48bf93a646b90de9b381c6b5f560df4cbe0ed3cf7650ae741e4d"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, @@ -1449,8 +1442,8 @@ ipdb = [ {file = "ipdb-0.13.9.tar.gz", hash = "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5"}, ] ipython = [ - {file = "ipython-7.30.1-py3-none-any.whl", hash = "sha256:fc60ef843e0863dd4e24ab2bb5698f071031332801ecf8d1aeb4fb622056545c"}, - {file = "ipython-7.30.1.tar.gz", hash = "sha256:cb6aef731bf708a7727ab6cde8df87f0281b1427d41e65d62d4b68934fa54e97"}, + {file = "ipython-7.31.0-py3-none-any.whl", hash = "sha256:4c4234cdcc6b8f87c5b5c7af9899aa696ac5cfcf0e9f6d0688018bbee5c73bce"}, + {file = "ipython-7.31.0.tar.gz", hash = "sha256:346c74db7312c41fa566d3be45d2e759a528dcc2994fe48aac1a03a70cd668a3"}, ] isort = [ {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, @@ -1618,8 +1611,8 @@ pickleshare = [ {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, ] platformdirs = [ - {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, - {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, + {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, + {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, @@ -1650,36 +1643,49 @@ pycparser = [ {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] pydantic = [ - {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, - {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"}, - {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"}, - {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"}, - {file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"}, - {file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"}, - {file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"}, - {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"}, - {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"}, - {file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"}, - {file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"}, - {file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"}, - {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"}, - {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"}, - {file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"}, - {file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"}, - {file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"}, - {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"}, - {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"}, - {file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"}, - {file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"}, - {file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"}, + {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"}, + {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"}, + {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"}, + {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"}, + {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"}, + {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"}, + {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"}, + {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"}, + {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"}, + {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"}, + {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, ] pyflakes = [ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pygments = [ - {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, - {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, + {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, + {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, ] pyparsing = [ {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, @@ -1690,13 +1696,17 @@ pytest = [ {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.16.0.tar.gz", hash = "sha256:7496c5977ce88c34379df64a66459fe395cd05543f0a2f837016e7144391fcfb"}, - {file = "pytest_asyncio-0.16.0-py3-none-any.whl", hash = "sha256:5f2a21273c47b331ae6aa5b36087047b4899e40f03f18397c0e65fa5cca54e9b"}, + {file = "pytest-asyncio-0.17.0.tar.gz", hash = "sha256:c98e0e04ae5910bbcc219f52bdf871bd1d392f624ef77c49c236613c0b6d8ee1"}, + {file = "pytest_asyncio-0.17.0-py3-none-any.whl", hash = "sha256:b41c3ff0ec5b5b144459aa1c53a866f67278177f6d4f3ef6874bd864fc82834d"}, ] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, ] +pytest-mock = [ + {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, + {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, +] pytest-sugar = [ {file = "pytest-sugar-0.9.4.tar.gz", hash = "sha256:b1b2186b0a72aada6859bea2a5764145e3aaa2c1cfbb23c3a19b5f7b697563d3"}, ] @@ -1832,8 +1842,8 @@ sniffio = [ {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, ] starlette = [ - {file = "starlette-0.16.0-py3-none-any.whl", hash = "sha256:38eb24bf705a2c317e15868e384c1b8a12ca396e5a3c3a003db7e667c43f939f"}, - {file = "starlette-0.16.0.tar.gz", hash = "sha256:e1904b5d0007aee24bdd3c43994be9b3b729f4f58e740200de1d623f8c3a8870"}, + {file = "starlette-0.17.1-py3-none-any.whl", hash = "sha256:26a18cbda5e6b651c964c12c88b36d9898481cd428ed6e063f5f29c418f73050"}, + {file = "starlette-0.17.1.tar.gz", hash = "sha256:57eab3cc975a28af62f6faec94d355a410634940f10b30d68d31cb5ec1b44ae8"}, ] termcolor = [ {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"}, @@ -1843,8 +1853,8 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] tox = [ - {file = "tox-3.24.4-py2.py3-none-any.whl", hash = "sha256:5e274227a53dc9ef856767c21867377ba395992549f02ce55eb549f9fb9a8d10"}, - {file = "tox-3.24.4.tar.gz", hash = "sha256:c30b57fa2477f1fb7c36aa1d83292d5c2336cd0018119e1b1c17340e2c2708ca"}, + {file = "tox-3.24.5-py2.py3-none-any.whl", hash = "sha256:be3362472a33094bce26727f5f771ca0facf6dafa217f65875314e9a6600c95c"}, + {file = "tox-3.24.5.tar.gz", hash = "sha256:67e0e32c90e278251fea45b696d0fef3879089ccbe979b0c556d35d5a70e2993"}, ] tox-poetry = [ {file = "tox-poetry-0.4.1.tar.gz", hash = "sha256:2395808e1ce487b5894c10f2202e14702bfa6d6909c0d1e525170d14809ac7ef"}, @@ -1880,16 +1890,16 @@ typing-extensions = [ {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, ] urllib3 = [ - {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, - {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, + {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, + {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, ] uvicorn = [ - {file = "uvicorn-0.16.0-py3-none-any.whl", hash = "sha256:d8c839231f270adaa6d338d525e2652a0b4a5f4c2430b5c4ef6ae4d11776b0d2"}, - {file = "uvicorn-0.16.0.tar.gz", hash = "sha256:eacb66afa65e0648fcbce5e746b135d09722231ffffc61883d4fac2b62fbea8d"}, + {file = "uvicorn-0.17.0-py3-none-any.whl", hash = "sha256:0b89c91bb8fe84c4bded9996af13c4b8c0de799d29bffeaa0c8ad298f2be0934"}, + {file = "uvicorn-0.17.0.tar.gz", hash = "sha256:192c2422b056a3beb512c6c260bf77a7a884204a4ae41856719c1913ead63bbb"}, ] virtualenv = [ - {file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"}, - {file = "virtualenv-20.10.0.tar.gz", hash = "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"}, + {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"}, + {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, diff --git a/pyproject.toml b/pyproject.toml index 3ad9670..971d5a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ fastapi = "^0" uvicorn = "^0" Jinja2 = "^2" aiofiles = "^0" -aiohttp = "^3" +aiohttp = "^3.8" jwt = "^1" boto3 = {version = "^1.20.24", optional = true} @@ -28,10 +28,14 @@ tox = "^3" tox-poetry = "^0" ipdb = "^0" httpx = "^0" +pytest-mock = "^3.6.1" [tool.poetry.extras] sqs = ["boto3"] +[tool.pytest.ini_options] +asyncio_mode = "auto" + [tool.black] target_version = ['py38'] include = '\.pyi?$' @@ -74,7 +78,7 @@ build-backend = "poetry.core.masonry.api" [tool.tox] legacy_tox_ini = """ [tox] -envlist = py38, py39, lint, coverage +envlist = py38, py39, py310, lint, coverage [testenv] description = run the test driver with {basepython} @@ -84,7 +88,7 @@ commands = [testenv:lint] description = check the code style -basepython = python3.9 +basepython = python3.10 commands = black --diff --check src/saleor_app isort -c -rc --diff src/saleor_app diff --git a/samples/simple_app/app.py b/samples/simple_app/app.py index 1c0e7f7..82e2fe7 100644 --- a/samples/simple_app/app.py +++ b/samples/simple_app/app.py @@ -1,31 +1,37 @@ +import json from pathlib import Path from typing import List, Optional from fastapi.param_functions import Depends -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, PlainTextResponse from pydantic import BaseModel from starlette.middleware.cors import CORSMiddleware from saleor_app.app import SaleorApp -from saleor_app.deps import ConfigurationDataDeps, saleor_domain_header -from saleor_app.endpoints import get_public_form +from saleor_app.deps import ConfigurationDataDeps, ConfigurationFormDeps, saleor_domain_header from saleor_app.schemas.core import DomainName, WebhookData -from saleor_app.schemas.handlers import WebhookHandlers +from saleor_app.schemas.handlers import SQSHandlers, WebhookHandlers from saleor_app.schemas.manifest import Manifest +from saleor_app.schemas.utils import LazyUrl from saleor_app.schemas.webhook import Webhook -from saleor_app.settings import SaleorAppSettings +from saleor_app.settings import AWSSettings, SaleorAppSettings PROJECT_DIR = Path(__file__).parent class Settings(SaleorAppSettings): debug: bool = False - dev_saleor_token: Optional[str] settings = Settings( debug=True, - dev_saleor_token="test_token", + development_auth_token="test_token", + aws=AWSSettings( + account_id="", + access_key_id="", + secret_access_key="", + region="", + ) ) @@ -34,17 +40,18 @@ class ConfigurationData(BaseModel): private_api_key: int -async def validate_domain(domain_name: DomainName) -> bool: - return domain_name == "172.17.0.1:8000" +async def validate_domain(saleor_domain: DomainName) -> bool: + return saleor_domain == "172.17.0.1:8000" -async def store_app_data(domain_name: DomainName, app_data: WebhookData): +async def store_app_data(saleor_domain: DomainName, auth_token: str, webhook_data: WebhookData): print("Called store_app_data") - print(domain_name) - print(app_data) + print(saleor_domain) + print(auth_token) + print(webhook_data) -async def get_webhook_details(domain_name: DomainName) -> WebhookData: +async def get_webhook_details(saleor_domain: DomainName) -> WebhookData: return WebhookData( token="auth-token", webhook_id="webhook-id", @@ -87,24 +94,43 @@ async def product_deleted(payload: List[Webhook], saleor_domain=Depends(saleor_d support_url="http://172.17.0.1:5000/supportUrl", id="saleor-simple-sample", permissions=["MANAGE_PRODUCTS", "MANAGE_USERS"], - configuration_url=Manifest.url_for("configuration-form"), + configuration_url=LazyUrl("configuration-form"), extensions=[], ), validate_domain=validate_domain, save_app_data=store_app_data, - # TODO: make it possible not to have any webhooks http_webhook_handlers=WebhookHandlers( product_created=product_created, product_updated=product_updated, product_deleted=product_deleted, ), + # sqs_handlers=SQSHandlers( + # product_created={ + # "queue_url": "awssqs://test_user:test_password@localstack:4566/000000000000/product_created", + # "handler": lambda x: print(x) + # }, + # product_updated={ + # "queue_url": "awssqs://test_user:test_password@localstack:4566/000000000000/product_created", + # "handler": lambda x: print(x) + # }, + # product_deleted={ + # "queue_url": "awssqs://test_user:test_password@localstack:4566/000000000000/product_deleted", + # "handler": lambda x: print(x) + # } + # ), get_webhook_details=get_webhook_details, + use_insecure_saleor_http=settings.debug, app_settings=settings, ) - - +async def get_public_form(commons: ConfigurationFormDeps = Depends()): + context = { + "request": str(commons.request), + "form_url": str(commons.request.url), + "saleor_domain": commons.saleor_domain, + } + return PlainTextResponse(json.dumps(context, indent=4)) app.configuration_router.get( diff --git a/src/saleor_app/app.py b/src/saleor_app/app.py index a51a74d..d6c6044 100644 --- a/src/saleor_app/app.py +++ b/src/saleor_app/app.py @@ -1,3 +1,4 @@ +import warnings from typing import Awaitable, Callable, Optional from fastapi import APIRouter, Depends, FastAPI @@ -8,7 +9,7 @@ from saleor_app.errors import ConfigurationError from saleor_app.http import WebhookRoute from saleor_app.schemas.core import DomainName, WebhookData -from saleor_app.schemas.handlers import WebhookHandlers +from saleor_app.schemas.handlers import SQSHandlers, WebhookHandlers from saleor_app.schemas.manifest import Manifest from saleor_app.settings import SaleorAppSettings @@ -23,33 +24,49 @@ def __init__( get_webhook_details: Callable[[DomainName], Awaitable[WebhookData]], app_settings: SaleorAppSettings, http_webhook_handlers: Optional[WebhookHandlers] = None, - sqs_webhook_handlers: Optional[WebhookHandlers] = None, + sqs_handlers: Optional[SQSHandlers] = None, + use_insecure_saleor_http: bool = False, **kwargs, ): super().__init__(**kwargs) self.manifest = manifest self.http_webhook_handlers = http_webhook_handlers - self.sqs_webhook_handlers = sqs_webhook_handlers - self.app_settings = app_settings - if self.sqs_webhook_handlers and not app_settings.aws: + self.sqs_handlers = sqs_handlers + + if not isinstance(app_settings, SaleorAppSettings): raise ConfigurationError( - "To leverage SQS webhook handlers you must provide settings.aws" + "app_settings must inherit from saleor_app.settings.SaleorAppSettings" ) - self.extra["saleor"] = { - "validate_domain": validate_domain, - "save_app_data": save_app_data, - "get_webhook_details": get_webhook_details, - } + self.app_settings = app_settings + if self.sqs_handlers: + warnings.simplefilter("always", RuntimeWarning) + warnings.warn( + "SQS support is highly experimental, be warned!", + category=RuntimeWarning, + ) + if not app_settings.aws: + raise ConfigurationError( + "To leverage SQS webhook handlers you must provide settings.aws" + ) + + self.validate_domain = validate_domain + self.save_app_data = save_app_data + self.get_webhook_details = get_webhook_details + + self.use_insecure_saleor_http = use_insecure_saleor_http + self.configuration_router = APIRouter( prefix="/configuration", tags=["configuration"] ) - self.include_webhook_router() + if self.http_webhook_handlers: + self.include_webhook_router() def include_saleor_app_routes(self): - # TODO: ensure a configuration-form path was defined at this point - self.configuration_router.get("/manifest", response_model=Manifest)(manifest) + self.configuration_router.get( + "/manifest", response_model=Manifest, name="manifest" + )(manifest) self.configuration_router.post( "/install", responses={ diff --git a/src/saleor_app/deps.py b/src/saleor_app/deps.py index 7e8e319..0ecdbea 100644 --- a/src/saleor_app/deps.py +++ b/src/saleor_app/deps.py @@ -1,7 +1,7 @@ import hashlib import hmac import logging -from typing import List +from typing import List, Optional import jwt from fastapi import Depends, Header, HTTPException, Query, Request @@ -19,7 +19,7 @@ async def saleor_domain_header( - saleor_domain=Header(None, alias=SALEOR_DOMAIN_HEADER), + saleor_domain: Optional[str] = Header(None, alias=SALEOR_DOMAIN_HEADER), ) -> DomainName: if not saleor_domain: logger.warning(f"Missing {SALEOR_DOMAIN_HEADER.upper()} header.") @@ -31,10 +31,10 @@ async def saleor_domain_header( async def saleor_token( request: Request, - token=Header(None, alias=SALEOR_TOKEN_HEADER), + token: Optional[str] = Header(None, alias=SALEOR_TOKEN_HEADER), ) -> str: - if request.app.development_auth_token: - token = token or request.app.development_auth_token + if request.app.app_settings.development_auth_token: + token = token or request.app.app_settings.development_auth_token if not token: logger.warning(f"Missing {SALEOR_TOKEN_HEADER.upper()} header.") raise HTTPException( @@ -53,7 +53,7 @@ async def verify_saleor_token( f"{schema}://{saleor_domain}", manifest=request.app.manifest ) as saleor: try: - response = saleor.execute( + response = await saleor.execute( VERIFY_TOKEN, variables={ "token": token, @@ -61,7 +61,6 @@ async def verify_saleor_token( ) except GraphQLError: return False - try: is_valid = response["tokenVerify"]["isValid"] is True except KeyError: @@ -86,9 +85,7 @@ async def verify_saleor_domain( request: Request, saleor_domain=Depends(saleor_domain_header), ) -> bool: - domain_is_valid = await request.app.extra["saleor"]["validate_domain"]( - saleor_domain - ) + domain_is_valid = await request.app.validate_domain(saleor_domain) if not domain_is_valid: logger.warning(f"Provided domain {saleor_domain} is invalid.") raise HTTPException( @@ -99,7 +96,7 @@ async def verify_saleor_domain( async def verify_webhook_signature( request: Request, - signature=Header(None, alias=SALEOR_SIGNATURE_HEADER), + signature: Optional[str] = Header(None, alias=SALEOR_SIGNATURE_HEADER), domain_name=Depends(saleor_domain_header), ): if not signature: @@ -107,9 +104,7 @@ async def verify_webhook_signature( status_code=401, detail=(f"Missing signature header - {SALEOR_SIGNATURE_HEADER}"), ) - webhook_details = await request.app.extra["saleor"]["get_webhook_details"]( - domain_name - ) + webhook_details = await request.app.get_webhook_details(domain_name) content = await request.body() webhook_signature_bytes = bytes(signature, "utf-8") diff --git a/src/saleor_app/endpoints.py b/src/saleor_app/endpoints.py index dfb5314..58f9f3d 100644 --- a/src/saleor_app/endpoints.py +++ b/src/saleor_app/endpoints.py @@ -1,32 +1,27 @@ -import json from typing import List from fastapi import Depends, Header, Request from fastapi.exceptions import HTTPException -from fastapi.responses import PlainTextResponse -from saleor_app.deps import ( - ConfigurationFormDeps, - saleor_domain_header, - verify_saleor_domain, -) +from saleor_app.deps import saleor_domain_header, verify_saleor_domain from saleor_app.errors import InstallAppError from saleor_app.http import SALEOR_EVENT_HEADER from saleor_app.install import install_app from saleor_app.saleor.exceptions import GraphQLError from saleor_app.schemas.core import InstallData +from saleor_app.schemas.utils import LazyUrl from saleor_app.schemas.webhook import Webhook async def manifest(request: Request): manifest = request.app.manifest for name, field in manifest: - if callable(field): + if isinstance(field, LazyUrl): setattr(manifest, name, field(request)) for extension in manifest.extensions: - if callable(extension.url): + if isinstance(extension.url, LazyUrl): extension.url = extension.url(request) - manifest.app_url = "http://127.0.0.1" + manifest.app_url = "" return manifest @@ -36,20 +31,35 @@ async def install( _domain_is_valid=Depends(verify_saleor_domain), saleor_domain=Depends(saleor_domain_header), ): - try: - await install_app( - saleor_domain=saleor_domain, - auth_token=data.auth_token, - manifest=request.app.manifest, - events=request.app.http_webhook_handlers.get_assigned_events(), - target_url=request.url_for("handle-webhook"), - save_app_data_callback=request.app.extra["saleor"]["save_app_data"], - use_insecure_saleor_http=request.app.use_insecure_saleor_http, - ) - except (InstallAppError, GraphQLError): - raise HTTPException( - status_code=403, detail="Incorrect token or not enough permissions" - ) + events = {} + if request.app.http_webhook_handlers: + events[ + request.url_for("handle-webhook") + ] = request.app.http_webhook_handlers.get_assigned_events() + if request.app.sqs_handlers: + events.update(request.app.sqs_handlers.get_assigned_events()) + + if events: + try: + webhook_data = await install_app( + saleor_domain=saleor_domain, + auth_token=data.auth_token, + manifest=request.app.manifest, + events=events, + use_insecure_saleor_http=request.app.use_insecure_saleor_http, + ) + except (InstallAppError, GraphQLError): + raise HTTPException( + status_code=403, detail="Incorrect token or not enough permissions" + ) + else: + webhook_data = None + + await request.app.save_app_data( + saleor_domain=saleor_domain, + auth_token=data.auth_token, + webhook_data=webhook_data, + ) return {} @@ -61,12 +71,3 @@ async def handle_webhook( _event_type=Header(None, alias=SALEOR_EVENT_HEADER), ): return {} - - -async def get_public_form(commons: ConfigurationFormDeps = Depends()): - context = { - "request": str(commons.request), - "form_url": str(commons.request.url), - "saleor_domain": commons.saleor_domain, - } - return PlainTextResponse(json.dumps(context, indent=4)) diff --git a/src/saleor_app/install.py b/src/saleor_app/install.py index a3e5944..85c49d1 100644 --- a/src/saleor_app/install.py +++ b/src/saleor_app/install.py @@ -1,13 +1,13 @@ import logging import secrets import string -from typing import Awaitable, Callable, List +from typing import Dict, Optional from saleor_app.errors import InstallAppError from saleor_app.saleor.exceptions import GraphQLError from saleor_app.saleor.mutations import CREATE_WEBHOOK from saleor_app.saleor.utils import get_client_for_app -from saleor_app.schemas.core import AppToken, DomainName, Url, WebhookData +from saleor_app.schemas.core import AppToken, DomainName, WebhookData from saleor_app.schemas.manifest import Manifest logger = logging.getLogger(__name__) @@ -17,9 +17,7 @@ async def install_app( saleor_domain: DomainName, auth_token: AppToken, manifest: Manifest, - events: List[str], - target_url: Url, - save_app_data_callback: Callable[[DomainName, WebhookData], Awaitable], + events: Optional[Dict[str, str]], use_insecure_saleor_http: bool, ): alphabet = string.ascii_letters + string.digits @@ -27,36 +25,35 @@ async def install_app( schema = "http" if use_insecure_saleor_http else "https" + errors = [] + async with get_client_for_app( f"{schema}://{saleor_domain}", manifest=manifest, auth_token=auth_token ) as saleor: - try: - response = await saleor.execute( - CREATE_WEBHOOK, - variables={ - "input": { - "targetUrl": target_url, - "events": [event.upper() for event in events], - "name": f"{manifest.name}-http", - "secretKey": secret_key, - } - }, - ) - except GraphQLError as exc: - logger.warning("Webhook create mutation raised an error: %s", exc) - raise - - webhook_error = response["webhookCreate"].get("errors") - if webhook_error: - logger.warning( - "Unable to finish installation of app for %s. Received error: %s", + for target_url, event_types in events.items(): + try: + response = await saleor.execute( + CREATE_WEBHOOK, + variables={ + "input": { + "targetUrl": target_url, + "events": [event.upper() for event in event_types], + "name": f"{manifest.name}", + "secretKey": secret_key, + } + }, + ) + except GraphQLError as exc: + errors.append(exc) + + if errors: + logger.error("Unable to finish installation of app for %s.", saleor_domain) + logger.debug( + "Unable to finish installation of app for %s. Received errors: %s", saleor_domain, - webhook_error, + list(map(str, errors)), ) - raise InstallAppError("Failed to create webhook for %s.", saleor_domain) + raise InstallAppError("Failed to create webhooks for %s.", saleor_domain) saleor_webhook_id = response["webhookCreate"]["webhook"]["id"] - install_app_data = WebhookData( - token=auth_token, webhook_id=saleor_webhook_id, webhook_secret_key=secret_key - ) - await save_app_data_callback(saleor_domain, install_app_data) + return WebhookData(webhook_id=saleor_webhook_id, webhook_secret_key=secret_key) diff --git a/src/saleor_app/saleor/client.py b/src/saleor_app/saleor/client.py index 3925448..c078b4c 100644 --- a/src/saleor_app/saleor/client.py +++ b/src/saleor_app/saleor/client.py @@ -42,6 +42,7 @@ async def execute(self, query, variables=None): exc = GraphQLError( errors=errors, response_data=response_data.get("data") ) - logger.error(str(exc)) + logger.error("Error when executing a GraphQL call to Saleor") + logger.debug(str(exc)) raise exc return response_data["data"] diff --git a/src/saleor_app/schemas/core.py b/src/saleor_app/schemas/core.py index c6daabc..06f32c4 100644 --- a/src/saleor_app/schemas/core.py +++ b/src/saleor_app/schemas/core.py @@ -8,7 +8,6 @@ class WebhookData(BaseModel): - token: str webhook_id: str webhook_secret_key: str diff --git a/src/saleor_app/schemas/handlers.py b/src/saleor_app/schemas/handlers.py index 51d5a69..77d893e 100644 --- a/src/saleor_app/schemas/handlers.py +++ b/src/saleor_app/schemas/handlers.py @@ -1,99 +1,99 @@ from typing import Awaitable, Callable, List, Optional -from pydantic import BaseModel +from pydantic import AnyHttpUrl, BaseModel, create_model from saleor_app.schemas.core import DomainName from saleor_app.schemas.webhook import Webhook - -class WebhookHandlers(BaseModel): - order_created: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - order_confirmed: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - order_fully_paid: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - order_updated: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - order_cancelled: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - order_fulfilled: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - - draft_order_created: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - draft_order_updated: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - draft_order_deleted: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - - sale_created: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - sale_updated: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - sale_deleted: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - - invoice_requested: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - invoice_deleted: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - invoice_sent: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - - customer_created: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - customer_updated: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - - product_created: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - product_updated: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - product_deleted: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - - product_variant_created: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - product_variant_updated: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - product_variant_deleted: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - product_variant_out_of_stock: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - product_variant_back_in_stock: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - - checkout_created: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - checkout_updated: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - - fulfillment_created: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - fulfillment_canceled: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - - notify_user: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - - page_created: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - page_updated: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - page_deleted: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - - payment_authorize: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - payment_capture: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - payment_confirm: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - payment_list_gateways: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - payment_process: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - payment_refund: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - payment_void: Optional[Callable[[List[Webhook], DomainName], Awaitable]] = None - - shipping_list_methods_for_checkout: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - - translation_created: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - translation_updated: Optional[ - Callable[[List[Webhook], DomainName], Awaitable] - ] = None - +SALEOR_EVENT_TYPES = ( + "ORDER_CREATED", + "ORDER_CONFIRMED", + "ORDER_FULLY_PAID", + "ORDER_UPDATED", + "ORDER_CANCELLED", + "ORDER_FULFILLED", + "DRAFT_ORDER_CREATED", + "DRAFT_ORDER_UPDATED", + "DRAFT_ORDER_DELETED", + "SALE_CREATED", + "SALE_UPDATED", + "SALE_DELETED", + "INVOICE_REQUESTED", + "INVOICE_DELETED", + "INVOICE_SENT", + "CUSTOMER_CREATED", + "CUSTOMER_UPDATED", + "PRODUCT_CREATED", + "PRODUCT_UPDATED", + "PRODUCT_DELETED", + "PRODUCT_VARIANT_CREATED", + "PRODUCT_VARIANT_UPDATED", + "PRODUCT_VARIANT_DELETED", + "PRODUCT_VARIANT_OUT_OF_STOCK", + "PRODUCT_VARIANT_BACK_IN_STOCK", + "CHECKOUT_CREATED", + "CHECKOUT_UPDATED", + "FULFILLMENT_CREATED", + "FULFILLMENT_CANCELED", + "NOTIFY_USER", + "PAGE_CREATED", + "PAGE_UPDATED", + "PAGE_DELETED", + "PAYMENT_AUTHORIZE", + "PAYMENT_CAPTURE", + "PAYMENT_CONFIRM", + "PAYMENT_LIST_GATEWAYS", + "PAYMENT_PROCESS", + "PAYMENT_REFUND", + "PAYMENT_VOID", + "SHIPPING_LIST_METHODS_FOR_CHECKOUT", + "TRANSLATION_CREATED", + "TRANSLATION_UPDATED", +) + + +WebHookHandlerSignature = Optional[Callable[[List[Webhook], DomainName], Awaitable]] + + +class WebhookHandlersBase(BaseModel): def get(self, event_name) -> Optional[Callable[[BaseModel], Awaitable]]: return self.__dict__.get(event_name) def get_assigned_events(self) -> List[str]: return [k for k, v in self.__dict__.items() if v is not None] + + +WebhookHandlers = create_model( + "WebhookHandlers", + __base__=WebhookHandlersBase, + **{ + event_type.lower(): (WebHookHandlerSignature, None) + for event_type in SALEOR_EVENT_TYPES + }, +) + + +class SQSUrl(AnyHttpUrl): + allowed_schemes = {"awssqs"} + + +class SQSHandler(BaseModel): + queue_url: SQSUrl + handler: WebHookHandlerSignature + + +class SQSHandlersBase(WebhookHandlersBase): + def get_assigned_events(self) -> List[str]: + sqs_queue_events = {} + for event_type, sqs_handler in self.__dict__.items(): + if sqs_handler is not None: + sqs_queue_events.setdefault(str(sqs_handler.queue_url), []) + sqs_queue_events[str(sqs_handler.queue_url)].append(event_type) + return sqs_queue_events + + +SQSHandlers = create_model( + "SQSHandlers", + __base__=SQSHandlersBase, + **{event_type.lower(): (SQSHandler, None) for event_type in SALEOR_EVENT_TYPES}, +) diff --git a/src/saleor_app/schemas/manifest.py b/src/saleor_app/schemas/manifest.py index 90f7a68..bcc51b3 100644 --- a/src/saleor_app/schemas/manifest.py +++ b/src/saleor_app/schemas/manifest.py @@ -1,8 +1,9 @@ from enum import Enum -from typing import Callable, List, Union +from typing import List, Union from pydantic import AnyHttpUrl, BaseModel, Field, root_validator -from starlette.requests import Request + +from saleor_app.schemas.utils import LazyUrl class ViewType(str, Enum): @@ -25,7 +26,7 @@ class Extension(BaseModel): type: ExtensionType target: TargetType permissions: List[str] - url: Union[AnyHttpUrl, Callable[[str], str]] + url: Union[AnyHttpUrl, LazyUrl] class Config: allow_population_by_field_name = True @@ -39,35 +40,18 @@ class Manifest(BaseModel): about: str extensions: List[Extension] data_privacy: str = Field(..., alias="dataPrivacy") - data_privacy_url: Union[AnyHttpUrl, Callable[[str], str]] = Field( - ..., alias="dataPrivacyUrl" - ) - homepage_url: Union[AnyHttpUrl, Callable[[str], str]] = Field( - ..., alias="homepageUrl" - ) - support_url: Union[AnyHttpUrl, Callable[[str], str]] = Field( - ..., alias="supportUrl" - ) - configuration_url: Union[AnyHttpUrl, Callable[[str], str]] = Field( - ..., alias="configurationUrl" - ) - app_url: Union[AnyHttpUrl, Callable[[str], str]] = Field("", alias="appUrl") - token_target_url: Union[AnyHttpUrl, Callable[[str], str]] = Field( - ..., alias="tokenTargetUrl" - ) + data_privacy_url: Union[AnyHttpUrl, LazyUrl] = Field(..., alias="dataPrivacyUrl") + homepage_url: Union[AnyHttpUrl, LazyUrl] = Field(..., alias="homepageUrl") + support_url: Union[AnyHttpUrl, LazyUrl] = Field(..., alias="supportUrl") + configuration_url: Union[AnyHttpUrl, LazyUrl] = Field(..., alias="configurationUrl") + app_url: Union[AnyHttpUrl, LazyUrl] = Field("", alias="appUrl") + token_target_url: Union[AnyHttpUrl, LazyUrl] = Field(..., alias="tokenTargetUrl") class Config: allow_population_by_field_name = True - @staticmethod - def url_for(name: str): - def resolve(request: Request): - return request.url_for(name=name) - - return resolve - @root_validator(pre=True) def default_token_target_url(cls, values): if not values.get("token_target_url"): - values["token_target_url"] = cls.url_for("app-install") + values["token_target_url"] = LazyUrl("app-install") return values diff --git a/src/saleor_app/schemas/utils.py b/src/saleor_app/schemas/utils.py new file mode 100644 index 0000000..a21f118 --- /dev/null +++ b/src/saleor_app/schemas/utils.py @@ -0,0 +1,24 @@ +from functools import cached_property + +from fastapi import Request +from starlette.routing import NoMatchFound + +from saleor_app.errors import ConfigurationError + + +class LazyUrl(str): + def __init__(self, name: str): + self.name = name + + @cached_property + def resolve(self): + return self.request.url_for(self.name) + + def __call__(self, request: Request): + self.request = request + try: + return self.resolve + except NoMatchFound: + raise ConfigurationError( + f"Failed to resolve a lazy url, check if an endpoint named '{self.name}' is defined." + ) diff --git a/src/saleor_app/schemas/webhook.py b/src/saleor_app/schemas/webhook.py index 23feecd..20e4c07 100644 --- a/src/saleor_app/schemas/webhook.py +++ b/src/saleor_app/schemas/webhook.py @@ -7,7 +7,7 @@ from pydantic.main import Extra -class OldStyleWebhook(BaseModel): +class WebhookV1(BaseModel): class Config: extra = Extra.allow allow_mutation = False @@ -30,7 +30,7 @@ class WebhookMeta(BaseModel): format: Optional[str] -class WebhookWithMeta(BaseModel): +class WebhookV2(BaseModel): meta: WebhookMeta class Config: @@ -38,7 +38,7 @@ class Config: allow_mutation = False -class NewStyleWebhook(BaseModel): +class WebhookV3(BaseModel): meta: WebhookMeta payload: Any @@ -47,4 +47,4 @@ class Config: allow_mutation = False -Webhook = Union[NewStyleWebhook, WebhookWithMeta, OldStyleWebhook] +Webhook = Union[WebhookV3, WebhookV2, WebhookV1] diff --git a/src/saleor_app/tests/conftest.py b/src/saleor_app/tests/conftest.py index f22a4db..9366572 100644 --- a/src/saleor_app/tests/conftest.py +++ b/src/saleor_app/tests/conftest.py @@ -1,21 +1,110 @@ -import os +from unittest.mock import AsyncMock, Mock import pytest -from saleor_app.conf import get_settings -from saleor_app.tests.sample_app import get_app +from saleor_app.app import SaleorApp +from saleor_app.schemas.handlers import SQSHandlers, WebhookHandlers +from saleor_app.schemas.manifest import Extension, Manifest +from saleor_app.schemas.utils import LazyUrl +from saleor_app.settings import AWSSettings, SaleorAppSettings -@pytest.fixture(autouse=True, scope="session") -def app_settings_env(): - os.environ["APP_SETTINGS"] = "saleor_app.tests.sample_settings.test_app_settings" +class TestSettings(SaleorAppSettings): + debug: bool = False -@pytest.fixture() -def clear_settings_cache(): - get_settings.cache_clear() +@pytest.fixture +def settings(): + return TestSettings( + debug=True, + development_auth_token="test_token", + ) + + +@pytest.fixture +def settings_with_aws(): + return TestSettings( + debug=True, + development_auth_token="test_token", + aws=AWSSettings( + account_id="", + access_key_id="", + secret_access_key="", + region="", + ), + ) + + +@pytest.fixture +def manifest(): + return Manifest( + name="Sample Saleor App", + version="0.1.0", + about="Sample Saleor App seving as an example.", + data_privacy="", + data_privacy_url="http://172.17.0.1:5000/dataPrivacyUrl", + homepage_url="http://172.17.0.1:5000/homepageUrl", + support_url="http://172.17.0.1:5000/supportUrl", + id="saleor-simple-sample", + permissions=["MANAGE_PRODUCTS", "MANAGE_USERS"], + configuration_url=LazyUrl("configuration-form"), + extensions=[ + Extension( + label="Custom Product Create", + view="PRODUCT", + type="OVERVIEW", + target="CREATE", + permissions=["MANAGE_PRODUCTS"], + url=LazyUrl("extension"), + ) + ], + ) + + +@pytest.fixture +def http_webhook_handlers(): + return WebhookHandlers( + product_created=Mock(), + product_updated=Mock(), + product_deleted=Mock(), + ) + + +@pytest.fixture +def sqs_handlers(): + return SQSHandlers( + product_created={ + "queue_url": "awssqs://user:password@sqs:4556/account_id/product_created" + }, + product_updated={ + "queue_url": "awssqs://user:password@sqs:4556/account_id/product_updated" + }, + product_deleted={ + "queue_url": "awssqs://user:password@sqs:4556/account_id/product_deleted" + }, + ) + + +@pytest.fixture +def saleor_app(manifest, settings_with_aws, http_webhook_handlers, sqs_handlers): + saleor_app = SaleorApp( + manifest=manifest, + validate_domain=AsyncMock(), + save_app_data=AsyncMock(), + get_webhook_details=AsyncMock(), + app_settings=settings_with_aws, + http_webhook_handlers=http_webhook_handlers, + sqs_handlers=sqs_handlers, + use_insecure_saleor_http=False, + ) + + saleor_app.get("/configuration", name="configuration-form")(lambda x: x) + saleor_app.get("/extension", name="extension")(lambda x: x) + saleor_app.include_saleor_app_routes() + + return saleor_app @pytest.fixture -def app(clear_settings_cache): - return get_app() +def mock_request(saleor_app): + return Mock(app=saleor_app, body=AsyncMock(return_value=b"request_body")) diff --git a/src/saleor_app/tests/saleor/test_client.py b/src/saleor_app/tests/saleor/test_client.py new file mode 100644 index 0000000..f4d74a5 --- /dev/null +++ b/src/saleor_app/tests/saleor/test_client.py @@ -0,0 +1,86 @@ +from unittest.mock import AsyncMock + +import aiohttp +import pytest +from aiohttp import ClientTimeout + +from saleor_app.saleor.client import SaleorClient +from saleor_app.saleor.exceptions import GraphQLError + + +@pytest.mark.parametrize( + "auth_token, timeout", ((None, None), (None, 5), ("token", None), ("token", 10)) +) +async def test__init__(auth_token, timeout): + kwargs = { + "saleor_url": "http://saleor.local", + "user_agent": "saleor_client/test-0.0.1", + } + if auth_token is not None: + kwargs["auth_token"] = auth_token + if timeout is not None: + kwargs["timeout"] = timeout + + client = SaleorClient(**kwargs) + + assert str(client.session._base_url) == kwargs["saleor_url"] + + if auth_token is not None: + assert client.session.headers["Authorization"] == f"Bearer {auth_token}" + if timeout is not None: + assert client.session.timeout == ClientTimeout(timeout) + + +async def test_close(mocker): + client = SaleorClient(saleor_url="http://saleor.local", user_agent="test") + spy = mocker.spy(client, "close") + + await client.close() + + spy.assert_awaited_once_with() + + +async def test_context_manager(mocker): + async with SaleorClient( + saleor_url="http://saleor.local", user_agent="test" + ) as saleor: + spy = mocker.spy(saleor, "close") + assert isinstance(saleor, SaleorClient) + + spy.assert_awaited_once_with() + + +async def test_execute(monkeypatch): + mock_session = AsyncMock(aiohttp.ClientSession) + mock_session.post.return_value.__aenter__.return_value.json.return_value = { + "data": "response_data" + } + async with SaleorClient( + saleor_url="http://saleor.local", user_agent="test" + ) as saleor: + monkeypatch.setattr(saleor, "session", mock_session, raising=True) + assert ( + await saleor.execute("QUERY", variables={"test": "value"}) + == "response_data" + ) + + mock_session.post.assert_called_once_with( + url="/graphql/", json={"query": "QUERY", "variables": {"test": "value"}} + ) + + +async def test_execute_error(monkeypatch): + mock_session = AsyncMock(aiohttp.ClientSession) + mock_session.post.return_value.__aenter__.return_value.json.return_value = { + "data": "response_data", + "errors": [{"message": "there are errors"}], + } + async with SaleorClient( + saleor_url="http://saleor.local", user_agent="test" + ) as saleor: + monkeypatch.setattr(saleor, "session", mock_session, raising=True) + with pytest.raises(GraphQLError) as excinfo: + await saleor.execute("QUERY", variables={"test": "value"}) + + assert excinfo.value.errors == [{"message": "there are errors"}] + assert excinfo.value.response_data == "response_data" diff --git a/src/saleor_app/tests/sample_app.py b/src/saleor_app/tests/sample_app.py deleted file mode 100644 index 1806828..0000000 --- a/src/saleor_app/tests/sample_app.py +++ /dev/null @@ -1,80 +0,0 @@ -import os - -from fastapi.responses import HTMLResponse -from pydantic import BaseModel -from starlette.middleware.cors import CORSMiddleware -from starlette.staticfiles import StaticFiles - -from saleor_app.app import SaleorApp -from saleor_app.conf import get_settings -from saleor_app.endpoints import get_public_form -from saleor_app.schemas.core import DomainName, WebhookData -from saleor_app.schemas.handlers import Payload, WebhookHandlers - -os.environ["APP_SETTINGS"] = "saleor_app.tests.conftest.test_app_settings" - - -class ConfigurationData(BaseModel): - public_api_token: str - private_api_key: int - - -async def validate_domain(domain_name: str) -> bool: - return domain_name == "172.17.0.1:8000" - - -async def store_app_data(domain_name: DomainName, app_data: WebhookData): - ... - - -async def get_webhook_details(domain_name: DomainName): - return WebhookData( - token="auth-token", - webhook_id="webhook-id", - webhook_secret_key="webhook-secret-key", - ) - - -async def product_created(payload: Payload, saleor_domain: DomainName): - ... - - -async def product_updated(payload: Payload, saleor_domain: DomainName): - ... - - -async def product_deleted(payload: Payload, saleor_domain: DomainName): - ... - - -async def extension(): - ... - - -def get_app(): - http_webhook_handlers = WebhookHandlers( - product_created=product_created, - product_updated=product_updated, - product_deleted=product_deleted, - ) - - settings = get_settings() - app = SaleorApp( - validate_domain=validate_domain, - save_app_data=store_app_data, - http_webhook_handlers=http_webhook_handlers, - get_webhook_details=get_webhook_details, - ) - app.configuration_router.get( - "", response_class=HTMLResponse, name="configuration-form" - )(get_public_form) - app.get("/extension", name="extension")(extension) - app.include_saleor_app_routes() - app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_headers=["*"], - allow_methods=["OPTIONS", "GET", "POST"], - ) - app.mount("/static", StaticFiles(directory=settings.static_dir), name="static") - return app diff --git a/src/saleor_app/tests/sample_settings.py b/src/saleor_app/tests/sample_settings.py deleted file mode 100644 index 608a3bc..0000000 --- a/src/saleor_app/tests/sample_settings.py +++ /dev/null @@ -1,35 +0,0 @@ -from pathlib import Path - -from saleor_app.conf import Settings, SettingsManifest - -manifest = SettingsManifest( - name="SampleApp", - version="1.0.0", - about="", - data_privacy="", - data_privacy_url="127.0.0.1:8888/app-info/privacy", - homepage_url="127.0.0.1:8888/app-info/homepage", - support_url="127.0.0.1:8888/app-info/support", - id="sample-app", - permissions=[], - configuration_url_for="configuration-form", - extensions=[ - { - "url_for": "extension", - "label": "Custom Product Create", - "view": "PRODUCT", - "type": "OVERVIEW", - "target": "CREATE", - "permissions": [ - "MANAGE_PRODUCTS", - ], - } - ], -) - -test_app_settings = Settings( - static_dir=Path("."), - project_dir=Path("."), - templates_dir=Path("."), - manifest=manifest, -) diff --git a/src/saleor_app/tests/test_app.py b/src/saleor_app/tests/test_app.py new file mode 100644 index 0000000..f2f198c --- /dev/null +++ b/src/saleor_app/tests/test_app.py @@ -0,0 +1,49 @@ +from unittest.mock import Mock + +import pytest +from starlette.routing import NoMatchFound + +from saleor_app.app import SaleorApp +from saleor_app.errors import ConfigurationError + + +async def test_saleor_app_init( + saleor_app, manifest, settings_with_aws, http_webhook_handlers, sqs_handlers +): + + saleor_app.include_saleor_app_routes() + + assert saleor_app.manifest == manifest + assert saleor_app.http_webhook_handlers == http_webhook_handlers + assert saleor_app.sqs_handlers == sqs_handlers + assert saleor_app.app_settings == settings_with_aws + + assert saleor_app.url_path_for("handle-webhook") == "/webhook" + assert saleor_app.url_path_for("manifest") == "/configuration/manifest" + assert saleor_app.url_path_for("app-install") == "/configuration/install" + + +async def test_saleor_app_no_handlers(manifest, settings): + saleor_app = SaleorApp( + manifest=manifest, + validate_domain=Mock(), + save_app_data=Mock(), + get_webhook_details=Mock(), + app_settings=settings, + use_insecure_saleor_http=False, + ) + with pytest.raises(NoMatchFound): + saleor_app.url_path_for("handle-webhook") + + +async def test_saleor_app_sqs_missing_config(manifest, settings, sqs_handlers): + with pytest.raises(ConfigurationError): + SaleorApp( + manifest=manifest, + validate_domain=Mock(), + save_app_data=Mock(), + get_webhook_details=Mock(), + app_settings=settings, + sqs_handlers=sqs_handlers, + use_insecure_saleor_http=False, + ) diff --git a/src/saleor_app/tests/test_conf.py b/src/saleor_app/tests/test_conf.py deleted file mode 100644 index a586130..0000000 --- a/src/saleor_app/tests/test_conf.py +++ /dev/null @@ -1,33 +0,0 @@ -import os -from unittest.mock import patch - -import pytest - -from saleor_app.conf import SETTINGS_ENV_VARIABLE, Settings, get_settings - -settings = Settings.construct( - app_name="TMP app", - project_dir=".", - static_dir="static", - templates_dir="templates", -) - -APP_SETTINGS = "saleor_app.tests.test_conf.settings" - - -def test_get_settings_returns_settings_object(clear_settings_cache): - with patch.dict(os.environ, {SETTINGS_ENV_VARIABLE: APP_SETTINGS}): - assert isinstance(get_settings(), Settings) - - -def test_get_settings_raises_error_when_incorrect_path(clear_settings_cache): - with pytest.raises(ImportError): - with patch.dict(os.environ, {SETTINGS_ENV_VARIABLE: "wrong.python.path"}): - get_settings() - - -def test_get_settings(clear_settings_cache): - with patch.dict(os.environ, {SETTINGS_ENV_VARIABLE: APP_SETTINGS}): - settings = get_settings() - assert isinstance(settings, Settings) - assert settings.app_name == "TMP app" diff --git a/src/saleor_app/tests/test_deps.py b/src/saleor_app/tests/test_deps.py new file mode 100644 index 0000000..e622898 --- /dev/null +++ b/src/saleor_app/tests/test_deps.py @@ -0,0 +1,117 @@ +import hashlib +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from saleor_app.deps import ( + saleor_domain_header, + saleor_token, + verify_saleor_domain, + verify_saleor_token, + verify_webhook_signature, +) +from saleor_app.saleor.client import SaleorClient +from saleor_app.saleor.exceptions import GraphQLError +from saleor_app.schemas.core import WebhookData + + +async def test_saleor_domain_header_missing(): + with pytest.raises(HTTPException) as excinfo: + await saleor_domain_header(None) + + assert str(excinfo.value.detail) == "Missing X-SALEOR-DOMAIN header." + + +async def test_saleor_domain_header(): + assert await saleor_domain_header("saleor_domain") == "saleor_domain" + + +async def test_saleor_token(mock_request): + assert await saleor_token(mock_request, "token") == "token" + + +async def test_saleor_token_from_settings(mock_request): + assert await saleor_token(mock_request, None) == "test_token" + + +async def test_saleor_token_missing(mock_request): + mock_request.app.app_settings.development_auth_token = None + with pytest.raises(HTTPException) as excinfo: + assert await saleor_token(mock_request, None) == "test_token" + + assert str(excinfo.value.detail) == "Missing X-SALEOR-TOKEN header." + + +async def test_verify_saleor_token(mock_request, mocker): + mock_saleor_client = AsyncMock(SaleorClient) + mock_saleor_client.__aenter__.return_value.execute.return_value = { + "tokenVerify": {"isValid": True} + } + mocker.patch("saleor_app.deps.get_client_for_app", return_value=mock_saleor_client) + assert await verify_saleor_token(mock_request, "saleor_domain", "token") + + +async def test_verify_saleor_token_invalid(mock_request, mocker): + mock_saleor_client = AsyncMock(SaleorClient) + mock_saleor_client.__aenter__.return_value.execute.return_value = { + "tokenVerify": {"isValid": False} + } + mocker.patch("saleor_app.deps.get_client_for_app", return_value=mock_saleor_client) + with pytest.raises(HTTPException) as excinfo: + await verify_saleor_token(mock_request, "saleor_domain", "token") + + assert ( + excinfo.value.detail + == "Provided X-SALEOR-DOMAIN and X-SALEOR-TOKEN are incorrect." + ) + + +async def test_verify_saleor_token_saleor_error(mock_request, mocker): + mock_saleor_client = AsyncMock(SaleorClient) + mock_saleor_client.__aenter__.return_value.execute.side_effect = GraphQLError( + "error" + ) + mocker.patch("saleor_app.deps.get_client_for_app", return_value=mock_saleor_client) + assert not await verify_saleor_token(mock_request, "saleor_domain", "token") + + +async def test_verify_saleor_domain(mock_request): + mock_request.app.validate_domain.return_value = True + assert await verify_saleor_domain(mock_request, "saleor_domain") + + +async def test_verify_saleor_domain_invalid(mock_request): + mock_request.app.validate_domain.return_value = False + with pytest.raises(HTTPException) as excinfo: + await verify_saleor_domain(mock_request, "saleor_domain") + + assert excinfo.value.detail == "Provided domain saleor_domain is invalid." + + +async def test_verify_webhook_signature(mock_request, mocker): + mock_request.app.get_webhook_details.return_value = WebhookData( + webhook_id="webhook_id", webhook_secret_key="webhook_secret_key" + ) + mock_hmac_new = mocker.patch("saleor_app.deps.hmac.new") + mock_hmac_new.return_value.hexdigest.return_value = "test_signature" + assert ( + await verify_webhook_signature(mock_request, "test_signature", "saleor_domain") + is None + ) + mock_hmac_new.assert_called_once_with( + b"webhook_secret_key", b"request_body", hashlib.sha256 + ) + + +async def test_verify_webhook_signature_invalid(mock_request, mocker): + mock_request.app.get_webhook_details.return_value = WebhookData( + webhook_id="webhook_id", webhook_secret_key="webhook_secret_key" + ) + mock_hmac_new = mocker.patch("saleor_app.deps.hmac.new") + mock_hmac_new.return_value.hexdigest.return_value = "test_signature" + + with pytest.raises(HTTPException) as excinfo: + await verify_webhook_signature(mock_request, "BAD_signature", "saleor_domain") + + assert excinfo.value.detail == "Invalid webhook signature for x-saleor-signature" diff --git a/src/saleor_app/tests/test_endpoints.py b/src/saleor_app/tests/test_endpoints.py index b52f867..95b649d 100644 --- a/src/saleor_app/tests/test_endpoints.py +++ b/src/saleor_app/tests/test_endpoints.py @@ -1,30 +1,23 @@ import json -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock -import pytest from httpx import AsyncClient -from saleor_app.conf import get_settings -from saleor_app.deps import ( - SALEOR_DOMAIN_HEADER, - SALEOR_EVENT_HEADER, - SALEOR_SIGNATURE_HEADER, - saleor_domain_header, - verify_saleor_domain, - verify_webhook_signature, - webhook_event_type, -) -from saleor_app.schemas.handlers import WebhookHandlers +from saleor_app.deps import SALEOR_DOMAIN_HEADER from saleor_app.schemas.manifest import Manifest -from saleor_app.tests.sample_app import store_app_data +# app.dependency_overrides[verify_saleor_domain] = lambda: True +# app.dependency_overrides[saleor_domain_header] = lambda: saleor_domain +# app.dependency_overrides[webhook_event_type] = lambda: "product_created" -@pytest.mark.asyncio -async def test_manifest(app): - settings = get_settings() - manifest = settings.manifest.dict(by_alias=True) - base_url = "http://test" +async def test_manifest(saleor_app): + base_url = "http://test_app.saleor.local" + + async with AsyncClient(app=saleor_app, base_url=base_url) as ac: + response = await ac.get("configuration/manifest") + + manifest = saleor_app.manifest.dict(by_alias=True) manifest["appUrl"] = "" manifest["tokenTargetUrl"] = f"{base_url}/configuration/install" manifest["configurationUrl"] = f"{base_url}/configuration" @@ -32,273 +25,45 @@ async def test_manifest(app): manifest = json.loads(json.dumps(Manifest(**manifest).dict(by_alias=True))) - async with AsyncClient(app=app, base_url=base_url) as ac: - response = await ac.get("configuration/manifest") assert response.status_code == 200 assert response.json() == manifest -@pytest.mark.asyncio -async def test_install(app, monkeypatch): +async def test_install(saleor_app, monkeypatch): install_app_mock = AsyncMock() monkeypatch.setattr("saleor_app.endpoints.install_app", install_app_mock) + base_url = "http://test_app.saleor.local" - base_url = "http://test" - - validate_domain_mock = AsyncMock(return_value=True) - app.extra["saleor"]["validate_domain"] = validate_domain_mock + saleor_app.validate_domain = AsyncMock(return_value=True) - async with AsyncClient(app=app, base_url=base_url) as ac: + async with AsyncClient(app=saleor_app, base_url=base_url) as ac: response = await ac.post( "configuration/install", json={"auth_token": "saleor-app-token"}, headers={SALEOR_DOMAIN_HEADER: "example.com"}, ) - assert response.status_code == 200 - install_app_mock.assert_awaited_once_with( - "example.com", - "saleor-app-token", - ["product_created", "product_updated", "product_deleted"], - "http://test/webhook", - store_app_data, - ) - - assert validate_domain_mock.called - - -@pytest.mark.asyncio -async def test_install_failed_installation(app, monkeypatch): - app.dependency_overrides[verify_saleor_domain] = lambda: True - app.dependency_overrides[saleor_domain_header] = lambda: "example.com" - - json_failed_response = { - "data": { - "webhookCreate": { - "errors": [ - { - "field": None, - "message": "Missing token or app", - "code": "INVALID", - } - ], - "webhook": None, - } - } - } - response = MagicMock() - response.__getitem__.side_effect = json_failed_response.__getitem__ - response.get.side_effect = json_failed_response.get - - errors = None - - mocked_executor = AsyncMock(return_value=(response, errors)) - monkeypatch.setattr( - "saleor_app.install.get_executor", lambda host, auth_token: mocked_executor - ) - - base_url = "http://test" - async with AsyncClient(app=app, base_url=base_url) as ac: - response = await ac.post( - "configuration/install", json={"auth_token": "saleor-app-token"} - ) - assert response.status_code == 403 - - -@pytest.mark.asyncio -async def test_install_invalid_saleor_domain(app, monkeypatch): - app.dependency_overrides[saleor_domain_header] = lambda: "example.com" - - install_app_mock = AsyncMock() - monkeypatch.setattr("saleor_app.endpoints.install_app", install_app_mock) - - base_url = "http://test" - async with AsyncClient(app=app, base_url=base_url) as ac: - response = await ac.post( - "configuration/install", json={"auth_token": "saleor-app-token"} - ) - assert response.status_code == 400 - - -@pytest.mark.asyncio -async def test_install_missing_saleor_domain_header(app, monkeypatch): - install_app_mock = AsyncMock() - monkeypatch.setattr("saleor_app.endpoints.install_app", install_app_mock) - - base_url = "http://test" - async with AsyncClient(app=app, base_url=base_url) as ac: - response = await ac.post( - "configuration/install", json={"auth_token": "saleor-app-token"} - ) - assert response.status_code == 400 - - -@pytest.mark.asyncio -async def test_handle_webhook_incorrect_domain_header(app): - saleor_domain = "example.com" - - app.dependency_overrides[webhook_event_type] = lambda: "product_created" - app.dependency_overrides[verify_webhook_signature] = lambda: True - - base_url = "http://test.com" - - validate_domain_mock = AsyncMock(return_value=False) - app.extra["saleor"]["validate_domain"] = validate_domain_mock - - webhook_payload = [{"data": "webhook-data"}] - async with AsyncClient(app=app, base_url=base_url) as ac: - response = await ac.post( - "/webhook", - json=webhook_payload, - headers={SALEOR_DOMAIN_HEADER: saleor_domain}, - ) - assert response.status_code == 400 - assert validate_domain_mock.called - - -@pytest.mark.asyncio -async def test_handle_webhook_missing_domain_header(app): - app.dependency_overrides[webhook_event_type] = lambda: "product_created" - app.dependency_overrides[verify_webhook_signature] = lambda: True - - base_url = "http://test.com" - - validate_domain_mock = AsyncMock(return_value=False) - app.extra["saleor"]["validate_domain"] = validate_domain_mock - - webhook_payload = [{"data": "webhook-data"}] - async with AsyncClient(app=app, base_url=base_url) as ac: - response = await ac.post("/webhook", json=webhook_payload) - assert response.status_code == 400 - assert not validate_domain_mock.called - - -@pytest.mark.asyncio -async def test_handle_webhook_missing_event_type_header(app): - saleor_domain = "example.com" - - app.dependency_overrides[verify_saleor_domain] = lambda: True - app.dependency_overrides[saleor_domain_header] = lambda: saleor_domain - - app.dependency_overrides[verify_webhook_signature] = lambda: True - - base_url = "http://test.com" - - webhook_payload = [{"data": "webhook-data"}] - async with AsyncClient(app=app, base_url=base_url) as ac: - response = await ac.post("/webhook", json=webhook_payload) - assert response.status_code == 400 - - -@pytest.mark.asyncio -async def test_handle_webhook_incorrect_event_type_header(app): - saleor_domain = "example.com" - - app.dependency_overrides[verify_saleor_domain] = lambda: True - app.dependency_overrides[saleor_domain_header] = lambda: saleor_domain - app.dependency_overrides[verify_webhook_signature] = lambda: True - - base_url = "http://test.com" - - webhook_payload = [{"data": "webhook-data"}] - async with AsyncClient(app=app, base_url=base_url) as ac: - response = await ac.post( - "/webhook", - json=webhook_payload, - headers={SALEOR_EVENT_HEADER: "incorrect_event_type"}, - ) - assert response.status_code == 404 - - -@pytest.mark.asyncio -async def test_handle_webhook_missing_signature_header(app): - saleor_domain = "example.com" - - app.dependency_overrides[verify_saleor_domain] = lambda: True - app.dependency_overrides[saleor_domain_header] = lambda: saleor_domain - app.dependency_overrides[webhook_event_type] = lambda: "product_created" - - base_url = "http://test.com" - - webhook_payload = [{"data": "webhook-data"}] - async with AsyncClient(app=app, base_url=base_url) as ac: - response = await ac.post("/webhook", json=webhook_payload) - assert response.status_code == 401 - - -@pytest.mark.asyncio -async def test_handle_webhook_incorrect_signature_header(app): - saleor_domain = "example.com" - app.dependency_overrides[verify_saleor_domain] = lambda: True - app.dependency_overrides[saleor_domain_header] = lambda: saleor_domain - app.dependency_overrides[webhook_event_type] = lambda: "product_created" - - base_url = "http://test.com" - - webhook_payload = [{"data": "webhook-data"}] - async with AsyncClient(app=app, base_url=base_url) as ac: - response = await ac.post( - "/webhook", - json=webhook_payload, - headers={SALEOR_SIGNATURE_HEADER: "incorrect"}, - ) - assert response.status_code == 401 - - -@pytest.mark.asyncio -async def test_handle_webhook_correct_signature_header(app): - saleor_domain = "example.com" - - app.dependency_overrides[verify_saleor_domain] = lambda: True - app.dependency_overrides[saleor_domain_header] = lambda: saleor_domain - app.dependency_overrides[webhook_event_type] = lambda: "product_created" - - base_url = "http://test.com" - - webhook_payload = [{"data": "webhook-data"}] - async with AsyncClient(app=app, base_url=base_url) as ac: - response = await ac.post( - "/webhook", - json=webhook_payload, - headers={ - SALEOR_SIGNATURE_HEADER: ( - "1d58736bf95a69ac1788c2548d5eef226aedf87a9794d42ba48609aeca760683" - ), - }, - ) - assert response.status_code == 200 - - -@pytest.mark.asyncio -async def test_handle_webhook(app): - product_created_mock = AsyncMock(return_value="") - product_updated_mock = AsyncMock(return_value="") - - saleor_domain = "example.com" - - base_url = "http://test.com" - - validate_domain_mock = AsyncMock(return_value=True) - app.extra["saleor"]["validate_domain"] = validate_domain_mock + assert response.status_code == 200 - app.extra["saleor"]["http_webhook_handlers"] = WebhookHandlers( - product_created=product_created_mock, - product_updated=product_updated_mock, + install_app_mock.assert_awaited_once_with( + saleor_domain="example.com", + auth_token="saleor-app-token", + manifest=saleor_app.manifest, + events={ + "http://test_app.saleor.local/webhook": [ + "product_created", + "product_updated", + "product_deleted", + ], + "awssqs://user:password@sqs:4556/account_id/product_created": [ + "product_created" + ], + "awssqs://user:password@sqs:4556/account_id/product_updated": [ + "product_updated" + ], + "awssqs://user:password@sqs:4556/account_id/product_deleted": [ + "product_deleted" + ], + }, + use_insecure_saleor_http=False, ) - webhook_payload = [{"data": "webhook-data"}] - async with AsyncClient(app=app, base_url=base_url) as ac: - response = await ac.post( - "/webhook", - json=webhook_payload, - headers={ - SALEOR_EVENT_HEADER: "product_created", - SALEOR_SIGNATURE_HEADER: ( - "1d58736bf95a69ac1788c2548d5eef226aedf87a9794d42ba48609aeca760683" - ), - SALEOR_DOMAIN_HEADER: saleor_domain, - }, - ) - assert response.status_code == 200 - - product_created_mock.assert_called_once_with(webhook_payload, saleor_domain) - assert not product_updated_mock.called diff --git a/src/saleor_app/tests/test_graphql.py b/src/saleor_app/tests/test_graphql.py deleted file mode 100644 index 5e0bdbb..0000000 --- a/src/saleor_app/tests/test_graphql.py +++ /dev/null @@ -1,87 +0,0 @@ -from unittest.mock import AsyncMock - -import pytest - -from saleor_app.conf import Settings -from saleor_app.graphql import get_executor, get_saleor_api_url - - -@pytest.fixture() -def settings(monkeypatch): - app_settings = Settings.construct( - static_dir="", project_dir="", templates_dir="", manifest_path="" - ) - monkeypatch.setattr("saleor_app.graphql.get_settings", lambda: app_settings) - return app_settings - - -def test_get_saleor_api_url_debug_enabled(settings): - settings.debug = True - - api_url = get_saleor_api_url("localhost:8000") - - assert api_url == "http://localhost:8000/graphql/" - - -def test_get_saleor_api_url(settings): - settings.debug = False - - api_url = get_saleor_api_url("localhost:8000") - assert api_url == "https://localhost:8000/graphql/" - - -@pytest.mark.asyncio -async def test_get_executor(monkeypatch, settings): - settings.debug = False - api_response = {"data": {"user": {"email": "admin@example.com"}}} - response = AsyncMock() - response.json.return_value = api_response - request = AsyncMock(return_value=response) - monkeypatch.setattr("saleor_app.graphql.ClientSession.request", request) - api_url = get_saleor_api_url("localhost:8000") - executor = get_executor(host=api_url, auth_token="saleor-token") - user_query = """ - query User($id: ID!) { - user(id: $id) { - email - } - } - """ - await executor(user_query, variables={"id": "user-id"}) - request.assert_awaited_once_with( - "POST", - url="https://localhost:8000/graphql/", - json={"query": user_query, "variables": {"id": "user-id"}}, - headers={"Authorization": "Bearer saleor-token"}, - timeout=10, - ) - - -@pytest.mark.asyncio -async def test_get_executor_returns_json_response(monkeypatch, settings): - settings.debug = False - api_response = {"data": {"user": {"email": "admin@example.com"}}} - response = AsyncMock() - response.json.return_value = api_response - request = AsyncMock(return_value=response) - monkeypatch.setattr("saleor_app.graphql.ClientSession.request", request) - api_url = get_saleor_api_url("localhost:8000") - executor = get_executor(host=api_url, auth_token="saleor-token") - user_query = """ - query User($id: ID!) { - user(id: $id) { - email - } - } - """ - response, _errors = await executor(user_query, variables={"id": "user-id"}) - - request.assert_awaited_once_with( - "POST", - url="https://localhost:8000/graphql/", - json={"query": user_query, "variables": {"id": "user-id"}}, - headers={"Authorization": "Bearer saleor-token"}, - timeout=10, - ) - - assert response == api_response diff --git a/src/saleor_app/tests/test_install.py b/src/saleor_app/tests/test_install.py index f2808f4..1990e00 100644 --- a/src/saleor_app/tests/test_install.py +++ b/src/saleor_app/tests/test_install.py @@ -1,179 +1,82 @@ -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock -import pytest - -from saleor_app.conf import get_settings -from saleor_app.errors import InstallAppError -from saleor_app.graphql import GraphQLError -from saleor_app.install import CREATE_WEBHOOK, install_app +from saleor_app.install import install_app +from saleor_app.saleor.client import SaleorClient +from saleor_app.saleor.mutations import CREATE_WEBHOOK from saleor_app.schemas.core import WebhookData -@pytest.mark.asyncio -async def test_install_app(monkeypatch): - saleor_webhook_id = "V2ViaG9vazoz" - json_response = { - "data": {"webhookCreate": {"errors": [], "webhook": {"id": saleor_webhook_id}}} +async def test_install_app(mocker, manifest): + mock_saleor_client = AsyncMock(SaleorClient) + mock_saleor_client.__aenter__.return_value.execute.return_value = { + "webhookCreate": {"webhook": {"id": "123"}} } - settings = get_settings() - response = MagicMock() - response.__getitem__.side_effect = json_response.__getitem__ - response.get.side_effect = json_response.get - - errors = None - - mocked_executor = AsyncMock(return_value=(response, errors)) - monkeypatch.setattr( - "saleor_app.install.get_executor", lambda host, auth_token: mocked_executor + mock_get_client_for_app = mocker.patch( + "saleor_app.install.get_client_for_app", return_value=mock_saleor_client ) - monkeypatch.setattr("saleor_app.install.secrets.choice", lambda _: "a") - - save_app_data_fun = AsyncMock() + mocker.patch("saleor_app.install.secrets.choice", return_value="A") - events = ["ORDER_CREATED", "PRODUCT_CREATED"] - target_url = "saleor.io/app/webhook-url" - saleor_store_domain = "saleor.io" - saleor_app_token = "saleor-token" - - await install_app( - domain=saleor_store_domain, - token=saleor_app_token, - events=events, - target_url=target_url, - save_app_data=save_app_data_fun, + assert ( + await install_app( + saleor_domain="saleor_domain", + auth_token="test_token", + manifest=manifest, + events={"queue_1": ["TEST_EVENT_1"], "url_1": ["TEST_EVENT_2"]}, + use_insecure_saleor_http=True, + ) + == WebhookData(webhook_id="123", webhook_secret_key="A" * 20) ) - expected_secret_key = "a" * 20 - variables = { - "input": { - "targetUrl": target_url, - "events": [event.upper() for event in events], - "name": settings.app_name, - "secretKey": expected_secret_key, - } - } - mocked_executor.assert_awaited_once_with(CREATE_WEBHOOK, variables=variables) - - save_app_data_fun.assert_awaited_once_with( - saleor_store_domain, - WebhookData( - token=saleor_app_token, - webhook_id=saleor_webhook_id, - webhook_secret_key=expected_secret_key, - ), + mock_get_client_for_app.assert_called_once_with( + "http://saleor_domain", manifest=manifest, auth_token="test_token" ) - -@pytest.mark.asyncio -async def test_install_app_graphql_error(monkeypatch): - json_failed_response = { - "errors": [ - { - "message": "You do not have permission to perform this action", + assert mock_saleor_client.__aenter__.return_value.execute.call_count == 2 + mock_saleor_client.__aenter__.return_value.execute.assert_any_await( + CREATE_WEBHOOK, + variables={ + "input": { + "targetUrl": "queue_1", + "events": ["TEST_EVENT_1"], + "name": f"{manifest.name}", + "secretKey": "A" * 20, } - ] - } - settings = get_settings() - response = MagicMock() - response.__getitem__.side_effect = json_failed_response.__getitem__ - response.get.side_effect = json_failed_response.get - - errors = [ - { - "message": "You do not have permission to perform this action", - } - ] - - mocked_executor = AsyncMock(return_value=(response, errors)) - monkeypatch.setattr( - "saleor_app.install.get_executor", lambda host, auth_token: mocked_executor + }, ) - monkeypatch.setattr("saleor_app.install.secrets.choice", lambda _: "a") - - save_app_data_fun = AsyncMock() - - events = ["ORDER_CREATED", "PRODUCT_CREATED"] - target_url = "saleor.io/app/webhook-url" - saleor_store_domain = "saleor.io" - saleor_app_token = "saleor-token" - - with pytest.raises(GraphQLError): - await install_app( - domain=saleor_store_domain, - token=saleor_app_token, - events=events, - target_url=target_url, - save_app_data=save_app_data_fun, - ) - - expected_secret_key = "a" * 20 - variables = { - "input": { - "targetUrl": target_url, - "events": [event.upper() for event in events], - "name": settings.app_name, - "secretKey": expected_secret_key, - } - } - mocked_executor.assert_awaited_once_with(CREATE_WEBHOOK, variables=variables) - assert not save_app_data_fun.called - - -@pytest.mark.asyncio -async def test_install_app_mutation_error(monkeypatch): - json_failed_response = { - "data": { - "webhookCreate": { - "errors": [ - { - "field": None, - "message": "Missing token or app", - "code": "INVALID", - } - ], - "webhook": None, + mock_saleor_client.__aenter__.return_value.execute.assert_any_await( + CREATE_WEBHOOK, + variables={ + "input": { + "targetUrl": "url_1", + "events": ["TEST_EVENT_2"], + "name": f"{manifest.name}", + "secretKey": "A" * 20, } - } - } - settings = get_settings() - response = MagicMock() - response.__getitem__.side_effect = json_failed_response.__getitem__ - response.get.side_effect = json_failed_response.get - - errors = None - - mocked_executor = AsyncMock(return_value=(response, errors)) - monkeypatch.setattr( - "saleor_app.install.get_executor", lambda host, auth_token: mocked_executor + }, ) - monkeypatch.setattr("saleor_app.install.secrets.choice", lambda _: "a") - - save_app_data_fun = AsyncMock() - events = ["ORDER_CREATED", "PRODUCT_CREATED"] - target_url = "saleor.io/app/webhook-url" - saleor_store_domain = "saleor.io" - saleor_app_token = "saleor-token" - with pytest.raises(InstallAppError): +async def test_install_app_secure_https(mocker, manifest): + mock_saleor_client = AsyncMock(SaleorClient) + mock_saleor_client.__aenter__.return_value.execute.return_value = { + "webhookCreate": {"webhook": {"id": "123"}} + } + mock_get_client_for_app = mocker.patch( + "saleor_app.install.get_client_for_app", return_value=mock_saleor_client + ) + mocker.patch("saleor_app.install.secrets.choice", return_value="A") + assert ( await install_app( - domain=saleor_store_domain, - token=saleor_app_token, - events=events, - target_url=target_url, - save_app_data=save_app_data_fun, + saleor_domain="saleor_domain", + auth_token="test_token", + manifest=manifest, + events={"queue_1": ["TEST_EVENT_1"], "url_1": ["TEST_EVENT_2"]}, + use_insecure_saleor_http=False, ) + == WebhookData(webhook_id="123", webhook_secret_key="A" * 20) + ) - expected_secret_key = "a" * 20 - variables = { - "input": { - "targetUrl": target_url, - "events": [event.upper() for event in events], - "name": settings.app_name, - "secretKey": expected_secret_key, - } - } - mocked_executor.assert_awaited_once_with(CREATE_WEBHOOK, variables=variables) - - assert not save_app_data_fun.called + mock_get_client_for_app.assert_called_once_with( + "https://saleor_domain", manifest=manifest, auth_token="test_token" + ) diff --git a/src/saleor_app/workers/base.py b/src/saleor_app/workers/base.py index 54b2d45..0620391 100644 --- a/src/saleor_app/workers/base.py +++ b/src/saleor_app/workers/base.py @@ -1,6 +1,6 @@ import asyncio import logging -from signal import signal, SIGINT, SIGTERM +from signal import SIGINT, SIGTERM, signal from saleor_app.app import SaleorApp from saleor_app.schemas.webhook import Webhook @@ -38,6 +38,9 @@ async def run(self, queue_name: str): try: await self.loop(queue_name=queue_name) except Exception as exc: - logger.critical("The loop exited with an error, will run the loop again in 60 seconds", exc_info=exc) + logger.critical( + "The loop exited with an error, will run the loop again in 60 seconds", + exc_info=exc, + ) asyncio.sleep(60) logger.info("Stopping the loop") diff --git a/src/saleor_app/workers/errors.py b/src/saleor_app/workers/errors.py index 563ebb6..f7ee23f 100644 --- a/src/saleor_app/workers/errors.py +++ b/src/saleor_app/workers/errors.py @@ -1,5 +1,4 @@ class BaseWorkerError(Exception): - def __init__(self, detail, message): self.detail = detail self.mesage = message @@ -8,7 +7,7 @@ def __init__(self, detail, message): class TransientError(BaseWorkerError): """ Error that can be solved in time, like any connectivity errors, in case - of a transient error the worker will hald and retry to handle the failing + of a transient error the worker will hald and retry to handle the failing event """ @@ -16,7 +15,7 @@ class TransientError(BaseWorkerError): class NonTransientError(BaseWorkerError): """ Error that will never be solved, like a malformet payload error, those errors - can be handled by a deadletter callback. The worker will not be halted in + can be handled by a deadletter callback. The worker will not be halted in the case of a non-transient error. """ diff --git a/src/saleor_app/workers/sqs.py b/src/saleor_app/workers/sqs.py index a8a3d3e..6daf5b1 100644 --- a/src/saleor_app/workers/sqs.py +++ b/src/saleor_app/workers/sqs.py @@ -7,42 +7,61 @@ from saleor_app.settings import AWSSettings from saleor_app.workers.base import SaleorAppWorker -from saleor_app.workers.errors import NonTransientError, TransientError, UnrecognizedEventPayload - +from saleor_app.workers.errors import ( + NonTransientError, + TransientError, + UnrecognizedEventPayload, +) logger = logging.getLogger(__name__) class SaleorAppSQSWorker(SaleorAppWorker): - - def __init__(self, queue_name: str, max_number_of_messages: int = 1, wait_time_seconds: int = 0, *args, **kwargs): + def __init__( + self, + queue_name: str, + max_number_of_messages: int = 1, + wait_time_seconds: int = 0, + *args, + **kwargs, + ): super().__init__(*args, **kwargs) self.transient_error_wait_seconds = 5 self.queue_name = queue_name self.max_number_of_messages = max_number_of_messages self.wait_time_seconds = wait_time_seconds - async def process_message(self, message): # TODO: figure out the type of a message and mark that as the return type + async def process_message( + self, message + ): # TODO: figure out the type of a message and mark that as the return type try: webhook = self.parse_webhook_payload(message_body=message.body) except ValidationError: - raise UnrecognizedEventPayload("The message body was not recognized", message=message) - + raise UnrecognizedEventPayload( + "The message body was not recognized", message=message + ) + # TODO: allow to decide if not recognized events should be ignored or dead lettered - handler = self.handlers.get() # TODO: figure out how to get an event_type from a message, is it in the message? if not add to webhook meta? + handler = ( + self.handlers.get() + ) # TODO: figure out how to get an event_type from a message, is it in the message? if not add to webhook meta? await handler(webhook, message) return message async def handle_non_transient_error(self, message, exc): """ - This handler will do nothing, thus allowing the loop to exhaust the + This handler will do nothing, thus allowing the loop to exhaust the attempts of a message retrieval which will result in a SQS dead letter """ - return + return async def handle_transient_error(self, message, exc): while True: - logger.warning("Transient error occured halting, will retry again in %s seconds", self.transient_error_wait_seconds, exc_info=exc) + logger.warning( + "Transient error occured halting, will retry again in %s seconds", + self.transient_error_wait_seconds, + exc_info=exc, + ) asyncio.sleep(self.transient_error_wait_seconds) try: await self.process_message(message) @@ -53,14 +72,16 @@ async def handle_transient_error(self, message, exc): async def handle_unhandled_exceptions(self, exc): """ - This handler will do nothing, thus allowing the loop to exhaust the + This handler will do nothing, thus allowing the loop to exhaust the attempts of a message retrieval which will result in a SQS dead letter """ # This should not happen, that's why it's critical - logger.critical("Failed to handle a message, message will be put in SQS deadletter", exc_info=exc) + logger.critical( + "Failed to handle a message, message will be put in SQS deadletter", + exc_info=exc, + ) return - async def loop(self): aws_settings: AWSSettings = self.app.settings.aws sqs = boto3.resource( @@ -78,7 +99,9 @@ async def loop(self): WaitTimeSeconds=self.wait_time_seconds, ) coros = [self.process_message(message) for message in messages] - results: List[Union[Any, Exception]] = asyncio.gather(*coros, return_exceptions=True) # TODO: figure out the type of a message and put it instead of Any + results: List[Union[Any, Exception]] = asyncio.gather( + *coros, return_exceptions=True + ) # TODO: figure out the type of a message and put it instead of Any successful_results = [] for result in results: if isinstance(result, TransientError): @@ -89,6 +112,6 @@ async def loop(self): await self.handle_unhandled_exceptions(result) else: successful_results.append(result) - + for message in successful_results: message.delete() From 919e2a5f4b5151cf4097fd38e7a07fd89f9d942d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kucmus?= Date: Sun, 16 Jan 2022 18:32:44 +0100 Subject: [PATCH 4/7] remove the need for the framework to enforce app settings --- samples/simple_app/__main__.py | 3 - samples/simple_app/app.py | 102 ++++++++++++------------------ src/saleor_app/app.py | 23 ++++--- src/saleor_app/deps.py | 4 +- src/saleor_app/settings.py | 7 -- src/saleor_app/tests/conftest.py | 60 ++++++++---------- src/saleor_app/tests/test_app.py | 20 +++--- src/saleor_app/tests/test_deps.py | 2 +- 8 files changed, 91 insertions(+), 130 deletions(-) diff --git a/samples/simple_app/__main__.py b/samples/simple_app/__main__.py index 8984e8b..3ef8d07 100644 --- a/samples/simple_app/__main__.py +++ b/samples/simple_app/__main__.py @@ -1,10 +1,7 @@ -import os - import uvicorn def main(): - os.environ["APP_SETTINGS"] = "simple_app.app.settings" uvicorn.run( "simple_app.app:app", host="0.0.0.0", port=5000, debug=True, reload=True ) diff --git a/samples/simple_app/app.py b/samples/simple_app/app.py index 82e2fe7..c3fd16d 100644 --- a/samples/simple_app/app.py +++ b/samples/simple_app/app.py @@ -1,37 +1,32 @@ import json -from pathlib import Path from typing import List, Optional from fastapi.param_functions import Depends from fastapi.responses import HTMLResponse, PlainTextResponse -from pydantic import BaseModel +from pydantic import BaseModel, BaseSettings from starlette.middleware.cors import CORSMiddleware from saleor_app.app import SaleorApp -from saleor_app.deps import ConfigurationDataDeps, ConfigurationFormDeps, saleor_domain_header +from saleor_app.deps import ( + ConfigurationDataDeps, + ConfigurationFormDeps, + saleor_domain_header, +) from saleor_app.schemas.core import DomainName, WebhookData -from saleor_app.schemas.handlers import SQSHandlers, WebhookHandlers +from saleor_app.schemas.handlers import WebhookHandlers from saleor_app.schemas.manifest import Manifest from saleor_app.schemas.utils import LazyUrl from saleor_app.schemas.webhook import Webhook -from saleor_app.settings import AWSSettings, SaleorAppSettings - -PROJECT_DIR = Path(__file__).parent -class Settings(SaleorAppSettings): +class Settings(BaseSettings): debug: bool = False + development_auth_token: Optional[str] = None settings = Settings( debug=True, development_auth_token="test_token", - aws=AWSSettings( - account_id="", - access_key_id="", - secret_access_key="", - region="", - ) ) @@ -44,7 +39,9 @@ async def validate_domain(saleor_domain: DomainName) -> bool: return saleor_domain == "172.17.0.1:8000" -async def store_app_data(saleor_domain: DomainName, auth_token: str, webhook_data: WebhookData): +async def store_app_data( + saleor_domain: DomainName, auth_token: str, webhook_data: WebhookData +): print("Called store_app_data") print(saleor_domain) print(auth_token) @@ -53,77 +50,63 @@ async def store_app_data(saleor_domain: DomainName, auth_token: str, webhook_dat async def get_webhook_details(saleor_domain: DomainName) -> WebhookData: return WebhookData( - token="auth-token", webhook_id="webhook-id", webhook_secret_key="webhook-secret-key", ) + async def example_dependency(): return "example" -async def product_created(payload: List[Webhook], saleor_domain=Depends(saleor_domain_header), example = Depends(example_dependency)): - print(example) - print(saleor_domain) +async def product_created( + payload: List[Webhook], + saleor_domain=Depends(saleor_domain_header), + example=Depends(example_dependency), +): print("Product created!") print(payload) -async def product_updated(payload: List[Webhook], saleor_domain=Depends(saleor_domain_header), example = Depends(example_dependency)): - print(example) - print(saleor_domain) +async def product_updated( + payload: List[Webhook], + saleor_domain=Depends(saleor_domain_header), + example=Depends(example_dependency), +): print("Product updated!") print(payload) -async def product_deleted(payload: List[Webhook], saleor_domain=Depends(saleor_domain_header), example = Depends(example_dependency)): - print(example) - print(saleor_domain) - print("Product deleted!") - print(payload) +manifest = Manifest( + name="Sample Saleor App", + version="0.1.0", + about="Sample Saleor App seving as an example.", + data_privacy="", + data_privacy_url="http://samle-saleor-app.example.com/dataPrivacyUrl", + homepage_url="http://samle-saleor-app.example.com/homepageUrl", + support_url="http://samle-saleor-app.example.com/supportUrl", + id="saleor-simple-sample", + permissions=["MANAGE_PRODUCTS", "MANAGE_USERS"], + configuration_url=LazyUrl("configuration-form"), + extensions=[], +) app = SaleorApp( - manifest=Manifest( - name="Sample Saleor App", - version="0.1.0", - about="Sample Saleor App seving as an example.", - data_privacy="", - data_privacy_url="http://172.17.0.1:5000/dataPrivacyUrl", - homepage_url="http://172.17.0.1:5000/homepageUrl", - support_url="http://172.17.0.1:5000/supportUrl", - id="saleor-simple-sample", - permissions=["MANAGE_PRODUCTS", "MANAGE_USERS"], - configuration_url=LazyUrl("configuration-form"), - extensions=[], - ), + manifest=manifest, validate_domain=validate_domain, save_app_data=store_app_data, + get_webhook_details=get_webhook_details, http_webhook_handlers=WebhookHandlers( product_created=product_created, product_updated=product_updated, - product_deleted=product_deleted, ), - # sqs_handlers=SQSHandlers( - # product_created={ - # "queue_url": "awssqs://test_user:test_password@localstack:4566/000000000000/product_created", - # "handler": lambda x: print(x) - # }, - # product_updated={ - # "queue_url": "awssqs://test_user:test_password@localstack:4566/000000000000/product_created", - # "handler": lambda x: print(x) - # }, - # product_deleted={ - # "queue_url": "awssqs://test_user:test_password@localstack:4566/000000000000/product_deleted", - # "handler": lambda x: print(x) - # } - # ), - get_webhook_details=get_webhook_details, use_insecure_saleor_http=settings.debug, - app_settings=settings, + development_auth_token=settings.development_auth_token, ) +@app.configuration_router.get("/", response_class=HTMLResponse, name="configuration-form") async def get_public_form(commons: ConfigurationFormDeps = Depends()): context = { "request": str(commons.request), @@ -133,11 +116,6 @@ async def get_public_form(commons: ConfigurationFormDeps = Depends()): return PlainTextResponse(json.dumps(context, indent=4)) -app.configuration_router.get( - "/", response_class=HTMLResponse, name="configuration-form" -)(get_public_form) - - @app.configuration_router.get("/data") async def get_configuration_data(commons: ConfigurationDataDeps = Depends()): return ConfigurationData(public_api_token="api_token", private_api_key=11) diff --git a/src/saleor_app/app.py b/src/saleor_app/app.py index d6c6044..3d674ff 100644 --- a/src/saleor_app/app.py +++ b/src/saleor_app/app.py @@ -11,7 +11,7 @@ from saleor_app.schemas.core import DomainName, WebhookData from saleor_app.schemas.handlers import SQSHandlers, WebhookHandlers from saleor_app.schemas.manifest import Manifest -from saleor_app.settings import SaleorAppSettings +from saleor_app.settings import AWSSettings class SaleorApp(FastAPI): @@ -21,11 +21,14 @@ def __init__( manifest: Manifest, validate_domain: Callable[[DomainName], Awaitable[bool]], save_app_data: Callable[[DomainName, WebhookData], Awaitable], - get_webhook_details: Callable[[DomainName], Awaitable[WebhookData]], - app_settings: SaleorAppSettings, + get_webhook_details: Optional[ + Callable[[DomainName], Awaitable[WebhookData]] + ] = None, http_webhook_handlers: Optional[WebhookHandlers] = None, + aws_settings: Optional[AWSSettings] = None, sqs_handlers: Optional[SQSHandlers] = None, use_insecure_saleor_http: bool = False, + development_auth_token: Optional[str] = None, **kwargs, ): super().__init__(**kwargs) @@ -34,33 +37,29 @@ def __init__( self.http_webhook_handlers = http_webhook_handlers self.sqs_handlers = sqs_handlers - if not isinstance(app_settings, SaleorAppSettings): - raise ConfigurationError( - "app_settings must inherit from saleor_app.settings.SaleorAppSettings" - ) - - self.app_settings = app_settings if self.sqs_handlers: + self.aws_settings = aws_settings warnings.simplefilter("always", RuntimeWarning) warnings.warn( "SQS support is highly experimental, be warned!", category=RuntimeWarning, ) - if not app_settings.aws: + if not aws_settings: raise ConfigurationError( - "To leverage SQS webhook handlers you must provide settings.aws" + "To leverage SQS webhook handlers you must provide aws_settings" ) self.validate_domain = validate_domain self.save_app_data = save_app_data - self.get_webhook_details = get_webhook_details self.use_insecure_saleor_http = use_insecure_saleor_http + self.development_auth_token = development_auth_token self.configuration_router = APIRouter( prefix="/configuration", tags=["configuration"] ) if self.http_webhook_handlers: + self.get_webhook_details = get_webhook_details self.include_webhook_router() def include_saleor_app_routes(self): diff --git a/src/saleor_app/deps.py b/src/saleor_app/deps.py index 0ecdbea..37d3590 100644 --- a/src/saleor_app/deps.py +++ b/src/saleor_app/deps.py @@ -33,8 +33,8 @@ async def saleor_token( request: Request, token: Optional[str] = Header(None, alias=SALEOR_TOKEN_HEADER), ) -> str: - if request.app.app_settings.development_auth_token: - token = token or request.app.app_settings.development_auth_token + if request.app.development_auth_token: + token = token or request.app.development_auth_token if not token: logger.warning(f"Missing {SALEOR_TOKEN_HEADER.upper()} header.") raise HTTPException( diff --git a/src/saleor_app/settings.py b/src/saleor_app/settings.py index 5e80be1..95e929a 100644 --- a/src/saleor_app/settings.py +++ b/src/saleor_app/settings.py @@ -12,10 +12,3 @@ class AWSSettings(BaseSettings): class Config: env_prefix = "AWS_" - - -class SaleorAppSettings(BaseSettings): - debug: bool = False - use_insecure_saleor_http: bool = False - development_auth_token: Optional[str] = None - aws: Optional[AWSSettings] = None diff --git a/src/saleor_app/tests/conftest.py b/src/saleor_app/tests/conftest.py index 9366572..31e4ee3 100644 --- a/src/saleor_app/tests/conftest.py +++ b/src/saleor_app/tests/conftest.py @@ -6,32 +6,16 @@ from saleor_app.schemas.handlers import SQSHandlers, WebhookHandlers from saleor_app.schemas.manifest import Extension, Manifest from saleor_app.schemas.utils import LazyUrl -from saleor_app.settings import AWSSettings, SaleorAppSettings - - -class TestSettings(SaleorAppSettings): - debug: bool = False +from saleor_app.settings import AWSSettings @pytest.fixture -def settings(): - return TestSettings( - debug=True, - development_auth_token="test_token", - ) - - -@pytest.fixture -def settings_with_aws(): - return TestSettings( - debug=True, - development_auth_token="test_token", - aws=AWSSettings( - account_id="", - access_key_id="", - secret_access_key="", - region="", - ), +def aws_settings(): + return AWSSettings( + account_id="", + access_key_id="", + secret_access_key="", + region="", ) @@ -64,9 +48,9 @@ def manifest(): @pytest.fixture def http_webhook_handlers(): return WebhookHandlers( - product_created=Mock(), - product_updated=Mock(), - product_deleted=Mock(), + product_created=AsyncMock(), + product_updated=AsyncMock(), + product_deleted=AsyncMock(), ) @@ -74,34 +58,44 @@ def http_webhook_handlers(): def sqs_handlers(): return SQSHandlers( product_created={ - "queue_url": "awssqs://user:password@sqs:4556/account_id/product_created" + "queue_url": "awssqs://user:password@sqs:4556/account_id/product_created", + "handler": AsyncMock(), }, product_updated={ - "queue_url": "awssqs://user:password@sqs:4556/account_id/product_updated" + "queue_url": "awssqs://user:password@sqs:4556/account_id/product_updated", + "handler": AsyncMock(), }, product_deleted={ - "queue_url": "awssqs://user:password@sqs:4556/account_id/product_deleted" + "queue_url": "awssqs://user:password@sqs:4556/account_id/product_deleted", + "handler": AsyncMock(), }, ) @pytest.fixture -def saleor_app(manifest, settings_with_aws, http_webhook_handlers, sqs_handlers): +def get_webhook_details(): + return AsyncMock() + + +@pytest.fixture +def saleor_app( + manifest, aws_settings, http_webhook_handlers, sqs_handlers, get_webhook_details +): saleor_app = SaleorApp( manifest=manifest, validate_domain=AsyncMock(), save_app_data=AsyncMock(), - get_webhook_details=AsyncMock(), - app_settings=settings_with_aws, + get_webhook_details=get_webhook_details, http_webhook_handlers=http_webhook_handlers, + aws_settings=aws_settings, sqs_handlers=sqs_handlers, use_insecure_saleor_http=False, + development_auth_token="test_token", ) saleor_app.get("/configuration", name="configuration-form")(lambda x: x) saleor_app.get("/extension", name="extension")(lambda x: x) saleor_app.include_saleor_app_routes() - return saleor_app diff --git a/src/saleor_app/tests/test_app.py b/src/saleor_app/tests/test_app.py index f2f198c..1b3a7fc 100644 --- a/src/saleor_app/tests/test_app.py +++ b/src/saleor_app/tests/test_app.py @@ -8,42 +8,42 @@ async def test_saleor_app_init( - saleor_app, manifest, settings_with_aws, http_webhook_handlers, sqs_handlers + saleor_app, + manifest, + aws_settings, + http_webhook_handlers, + sqs_handlers, + get_webhook_details, ): - - saleor_app.include_saleor_app_routes() - assert saleor_app.manifest == manifest assert saleor_app.http_webhook_handlers == http_webhook_handlers assert saleor_app.sqs_handlers == sqs_handlers - assert saleor_app.app_settings == settings_with_aws + assert saleor_app.aws_settings == aws_settings + assert saleor_app.get_webhook_details == get_webhook_details assert saleor_app.url_path_for("handle-webhook") == "/webhook" assert saleor_app.url_path_for("manifest") == "/configuration/manifest" assert saleor_app.url_path_for("app-install") == "/configuration/install" -async def test_saleor_app_no_handlers(manifest, settings): +async def test_saleor_app_no_handlers(manifest): saleor_app = SaleorApp( manifest=manifest, validate_domain=Mock(), save_app_data=Mock(), - get_webhook_details=Mock(), - app_settings=settings, use_insecure_saleor_http=False, ) with pytest.raises(NoMatchFound): saleor_app.url_path_for("handle-webhook") -async def test_saleor_app_sqs_missing_config(manifest, settings, sqs_handlers): +async def test_saleor_app_sqs_missing_config(manifest, sqs_handlers): with pytest.raises(ConfigurationError): SaleorApp( manifest=manifest, validate_domain=Mock(), save_app_data=Mock(), get_webhook_details=Mock(), - app_settings=settings, sqs_handlers=sqs_handlers, use_insecure_saleor_http=False, ) diff --git a/src/saleor_app/tests/test_deps.py b/src/saleor_app/tests/test_deps.py index e622898..166c492 100644 --- a/src/saleor_app/tests/test_deps.py +++ b/src/saleor_app/tests/test_deps.py @@ -36,7 +36,7 @@ async def test_saleor_token_from_settings(mock_request): async def test_saleor_token_missing(mock_request): - mock_request.app.app_settings.development_auth_token = None + mock_request.app.development_auth_token = None with pytest.raises(HTTPException) as excinfo: assert await saleor_token(mock_request, None) == "test_token" From ea40fb2037f0edf539573c8b1da19cc3e2cc90c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kucmus?= Date: Sun, 16 Jan 2022 18:33:39 +0100 Subject: [PATCH 5/7] add documentation --- .github/workflows/documentation.yml | 45 +++++ docs/assets/saleor.svg | 35 ++++ docs/event_handlers/http.md | 70 +++++++ docs/event_handlers/sqs.md | 5 + docs/index.md | 174 ++++++++++++++++ docs/local_app_development/index.md | 31 +++ docs_overrides/.icons/saleor/saleor.svg | 35 ++++ docs_overrides/main.html | 5 + mkdocs.yml | 83 ++++++++ poetry.lock | 254 +++++++++++++++++++++++- pyproject.toml | 2 + 11 files changed, 737 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/documentation.yml create mode 100644 docs/assets/saleor.svg create mode 100644 docs/event_handlers/http.md create mode 100644 docs/event_handlers/sqs.md create mode 100644 docs/index.md create mode 100644 docs/local_app_development/index.md create mode 100644 docs_overrides/.icons/saleor/saleor.svg create mode 100644 docs_overrides/main.html create mode 100644 mkdocs.yml diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..041b4b8 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,45 @@ +name: documentation +on: + push: + branches: + - main + +env: + PYTHON_VERSION: 3.10.1 + +jobs: + documentation: + name: Build documentation + runs-on: ubuntu-latest + steps: + + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Python runtime + uses: actions/setup-python@v1 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + poetry config virtualenvs.create true + poetry config virtualenvs.in-project true + poetry config --list + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v2 + with: + path: .venv + key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install Python dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: poetry install --no-interaction --no-root + + - name: Deploy documentation + run: | + poetry run mkdocs gh-deploy --force + poetry run mkdocs --version diff --git a/docs/assets/saleor.svg b/docs/assets/saleor.svg new file mode 100644 index 0000000..3ea2375 --- /dev/null +++ b/docs/assets/saleor.svg @@ -0,0 +1,35 @@ + + + + + + + image/svg+xml + + + + + + + + + diff --git a/docs/event_handlers/http.md b/docs/event_handlers/http.md new file mode 100644 index 0000000..faf1c37 --- /dev/null +++ b/docs/event_handlers/http.md @@ -0,0 +1,70 @@ +# HTTP Webhook Event Handling + +While it's not neccesary 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). + +To configure your app to listen to HTTP webhooks issued from Saleor you need to provide your app with two more arguments. + +## Setting up the Saleor App + +### Defining webhook handlers + +An HTTP webhook handler is a function that is exactly ile one that one would use as a FastAPI endpoint. There is no need to register those as routes since that is deatl with when the app is initialized. + +An example of a http webhook handler is: + +```python +from saleor_app.schemas.webhook import Webhook +from saleor_app.deps import saleor_domain_header # (1) + + +async def product_created( + payload: List[Webhook], + saleor_domain=Depends(saleor_domain_header) # (2) +): + await do_something(payload, saleor_domain) +``` + +1. :information_source: `saleor_app.deps` contains a set of FastAPI dependencies that you might find useful +2. :information_source: since `product_created` is just a FastAPI endpoint you have access for everything a usual endpoint would, like `request: Request` + +### Getting Webhook details + +The framework ensures that the webhook comes from a trusted source but to achieve that it needs to be provided with a way of retreivng the webhook_secret your app sotred 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. + +```python +from saleor_app.schemas.core import DomainName, WebhookData + + +async def get_webhook_details(saleor_domain: DomainName) -> WebhookData: + return WebhookData( + webhook_id="webhook-id", + webhook_secret_key="webhook-secret-key", + ) # (1) + +``` + +1. :material-database: Typically the data would be taken from a database + +The function takes the `saleor_domain` and must return a `WebhookData` Pytantic model instance + +### Injecting the handlers into the app + +Now when your handlers code is prepared you can provide it to the `SaleorApp`: + +```python +from saleor_app.schemas.handlers import WebhookHandlers + + +app = SaleorApp( + # ... + get_webhook_details=get_webhook_details, + http_webhook_handlers=WebhookHandlers( + product_created=product_created, + ), + # ... +) +``` + +### Reinstall the app + +Neither Saleor nor the app will automatically update the registered webhooks, you need to reinstall the app in Saleor if it was already installed. diff --git a/docs/event_handlers/sqs.md b/docs/event_handlers/sqs.md new file mode 100644 index 0000000..0b5392f --- /dev/null +++ b/docs/event_handlers/sqs.md @@ -0,0 +1,5 @@ +# AWS SQS Handlers + +!!! warning "Experimental" + + SQS event handing is in the works, more content to come diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..4a58ebc --- /dev/null +++ b/docs/index.md @@ -0,0 +1,174 @@ +# Welcome to Saleor App Framework + +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). + +The only supported web framework is **FastAPI**. + +## Quickstart + +### Install the framework + +Using Poetry (recommended): + +```bash +poetry add git+https://github.com/saleor/saleor-app-framework-python.git@main +# (1) +``` + +1. Not on PyPi yet, you must install from git + +Using Pip: + +```bash +pip install git+https://github.com/saleor/saleor-app-framework-python.git@main +``` + +### Create the FastAPI app + +To run your Saleor App you can use the ```#!python SaleorApp``` class which overloads the usual ```#!python FastAPI``` class. + +```python +{!./docs/../samples/simple_app/app.py[ln:9]!} + +{!./docs/../samples/simple_app/app.py[ln:95]!} + # more arguments to come +) +``` + +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. + +#### Manifest + +As described in [:saleor-saleor: App manifest](https://docs.saleor.io/docs/3.0/developer/extending/apps/manifest) an app needs a manifest, the framework provides a Pydantic representation of that which needs to be provided when initializing the app. + +```python +{!./docs/../samples/simple_app/app.py[ln:9]!} +{!./docs/../samples/simple_app/app.py[ln:17]!} +{!./docs/../samples/simple_app/app.py[ln:18]!} + + +{!./docs/../samples/simple_app/app.py[ln:80-92]!} + + +{!./docs/../samples/simple_app/app.py[ln:95-96]!} + # more arguments to come +) +``` + +??? info "LazyUrl" + + ```#!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. + +#### Validate Domain + +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. + +```python +{!./docs/../samples/simple_app/app.py[ln:9]!} +from saleor_app.schemas.core import DomainName +{!./docs/../samples/simple_app/app.py[ln:17]!} +{!./docs/../samples/simple_app/app.py[ln:18]!} + + +{!./docs/../samples/simple_app/app.py[ln:38-39]!} + + +{!./docs/../samples/simple_app/app.py[ln:80-92]!} + + +{!./docs/../samples/simple_app/app.py[ln:95-97]!} + # more arguments to come +) +``` + + +#### Saving Application Data + +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. + +```python +{!./docs/../samples/simple_app/app.py[ln:9]!} +{!./docs/../samples/simple_app/app.py[ln:15]!} +{!./docs/../samples/simple_app/app.py[ln:17]!} +{!./docs/../samples/simple_app/app.py[ln:18]!} + + +{!./docs/../samples/simple_app/app.py[ln:38-48]!} #(1) + + +{!./docs/../samples/simple_app/app.py[ln:80-92]!} + + +{!./docs/../samples/simple_app/app.py[ln:95-98]!} +) +``` + +1. :material-database: Typically, you'd store all the data passed to this function to a DB table + + +#### Configuration URL + +To finalize you need to provide the endpoint named ```#!python configuration-form``` sepecified in the [Manifest](#manifest). + +```python +import json + +{!./docs/../samples/simple_app/app.py[ln:4-5]!} + +{!./docs/../samples/simple_app/app.py[ln:9]!} +from saleor_app.deps import ConfigurationFormDeps +{!./docs/../samples/simple_app/app.py[ln:15]!} +{!./docs/../samples/simple_app/app.py[ln:17]!} +{!./docs/../samples/simple_app/app.py[ln:18]!} + + +{!./docs/../samples/simple_app/app.py[ln:38-48]!} + + +{!./docs/../samples/simple_app/app.py[ln:80-92]!} + + +{!./docs/../samples/simple_app/app.py[ln:95-98]!} +) + + +{!./docs/../samples/simple_app/app.py[ln:109-116]!} + + +{!./docs/../samples/simple_app/app.py[ln:124]!} #(1) +``` + +1. Once you are done defining all the configuration routes you need to tell the app to load them + +> This is a complete example that will work as is. + + +### Running the App + +To run the app you can save the above example in `simple_app/app.py` and run it with: + +```bash +uvicorn simple_app.app:app --host 0.0.0.0 --port 5000 --reload +``` + +Or create a `simple_app/__main__.py` with: + +```python +import uvicorn + + +def main(): + uvicorn.run( + "simple_app.app:app", host="0.0.0.0", port=5000, debug=True, reload=True + ) + + +if __name__ == "__main__": + main() +``` + +and run the module as a script with Python's `-m` flag: + +```bash +python -m simple_app +``` diff --git a/docs/local_app_development/index.md b/docs/local_app_development/index.md new file mode 100644 index 0000000..dc0281f --- /dev/null +++ b/docs/local_app_development/index.md @@ -0,0 +1,31 @@ +# Running everything locally + +## Development mode + +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!**. + +```python +from pydantic import BaseSettings + + +class Settings(BaseSettings): + debug: bool = False + development_auth_token: Optional[str] = None + + +settings = Settings( + debug=True, + development_auth_token="test_token", +) + + +app = SaleorApp( + # [...] + use_insecure_saleor_http=settings.debug, + development_auth_token=settings.development_auth_token, +) +``` + +## Developing Apps on a local Saleor + +Coming soon... diff --git a/docs_overrides/.icons/saleor/saleor.svg b/docs_overrides/.icons/saleor/saleor.svg new file mode 100644 index 0000000..3ea2375 --- /dev/null +++ b/docs_overrides/.icons/saleor/saleor.svg @@ -0,0 +1,35 @@ + + + + + + + image/svg+xml + + + + + + + + + diff --git a/docs_overrides/main.html b/docs_overrides/main.html new file mode 100644 index 0000000..56612b1 --- /dev/null +++ b/docs_overrides/main.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} + +{% block announce %} + Saleor App Framework is still in development, expect things to change. +{% endblock %} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..e6f125b --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,83 @@ +site_name: Saleor App Framework +site_url: https://github.com/saleor/saleor-app-framework-python +site_author: Saleor.io +site_description: >- + Small Framework helping you to bootstrap your Saleor 3rd Party App +# Repository +repo_name: saleor/saleor-app-framework-python +repo_url: https://github.com/saleor/saleor-app-framework-python +edit_uri: "" + +theme: + name: material + custom_dir: docs_overrides + logo: assets/saleor.svg + favicon: assets/saleor.svg + palette: + - scheme: default + primary: white + accent: light blue + toggle: + icon: material/toggle-switch + name: Switch to dark mode + - scheme: slate + primary: light blue + accent: light blue + toggle: + icon: material/toggle-switch-off-outline + name: Switch to light mode + features: + - navigation.instant + - navigation.tracking + - navigation.sections + - navigation.expand + - navigation.top + - content.code.annotate + +plugins: + - search + +markdown_extensions: + - admonition + - abbr + - attr_list + - def_list + - footnotes + - meta + - md_in_html + - toc: + permalink: true + - pymdownx.arithmatex: + generic: true + - pymdownx.betterem: + smart_enable: all + - pymdownx.caret + - pymdownx.details + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg + options: + custom_icons: + - docs_overrides/.icons + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.keys + - pymdownx.magiclink: + repo_url_shorthand: true + user: squidfunk + repo: mkdocs-material + - pymdownx.mark + - pymdownx.smartsymbols + - pymdownx.snippets + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.tilde + - mdx_include diff --git a/poetry.lock b/poetry.lock index 45a671d..e44c4e2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -260,6 +260,14 @@ sdist = ["setuptools_rust (>=0.11.4)"] ssh = ["bcrypt (>=3.1.5)"] test = ["pytest (>=6.2.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] +[[package]] +name = "cyclic" +version = "1.0.0" +description = "Handle cyclic relations" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "decorator" version = "5.1.1" @@ -327,6 +335,20 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "ghp-import" +version = "2.0.2" +description = "Copy your docs directly to the gh-pages branch." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["twine", "markdown", "flake8", "wheel"] + [[package]] name = "h11" version = "0.12.0" @@ -391,6 +413,22 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "importlib-metadata" +version = "4.10.0" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +perf = ["ipython"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] + [[package]] name = "iniconfig" version = "1.1.1" @@ -506,6 +544,20 @@ python-versions = ">= 3.6" [package.dependencies] cryptography = ">=3.1,<3.4.0 || >3.4.0" +[[package]] +name = "markdown" +version = "3.3.6" +description = "Python implementation of Markdown." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} + +[package.extras] +testing = ["coverage", "pyyaml"] + [[package]] name = "markupsafe" version = "2.0.1" @@ -533,6 +585,74 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "mdx-include" +version = "1.4.1" +description = "Python Markdown extension to include local or remote files" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +cyclic = "*" +Markdown = ">=2.6" +rcslice = ">=1.1.0" + +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "mkdocs" +version = "1.2.3" +description = "Project documentation with Markdown." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +click = ">=3.3" +ghp-import = ">=1.0" +importlib-metadata = ">=3.10" +Jinja2 = ">=2.10.1" +Markdown = ">=3.2.1" +mergedeep = ">=1.3.4" +packaging = ">=20.5" +PyYAML = ">=3.10" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] + +[[package]] +name = "mkdocs-material" +version = "8.1.6" +description = "A Material Design theme for MkDocs" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +jinja2 = ">=2.11.1" +markdown = ">=3.2" +mkdocs = ">=1.2.3" +mkdocs-material-extensions = ">=1.0" +pygments = ">=2.10" +pymdown-extensions = ">=9.0" + +[[package]] +name = "mkdocs-material-extensions" +version = "1.0.3" +description = "Extension pack for Python Markdown." +category = "dev" +optional = false +python-versions = ">=3.6" + [[package]] name = "multidict" version = "5.2.0" @@ -721,6 +841,17 @@ category = "dev" optional = false python-versions = ">=3.5" +[[package]] +name = "pymdown-extensions" +version = "9.1" +description = "Extension pack for Python Markdown." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +Markdown = ">=3.2" + [[package]] name = "pyparsing" version = "3.0.6" @@ -815,7 +946,7 @@ name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" category = "main" -optional = true +optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" [package.dependencies] @@ -829,6 +960,25 @@ category = "dev" optional = false python-versions = ">=3.6" +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +description = "A custom YAML tag for referencing environment variables in YAML files. " +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyyaml = "*" + +[[package]] +name = "rcslice" +version = "1.1.0" +description = "Slice a list of sliceables (1 indexed, start and end index both are inclusive)" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "regex" version = "2021.11.10" @@ -1023,6 +1173,17 @@ six = ">=1.9.0,<2" docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] +[[package]] +name = "watchdog" +version = "2.1.6" +description = "Filesystem events monitoring" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + [[package]] name = "wcwidth" version = "0.2.5" @@ -1043,13 +1204,25 @@ python-versions = ">=3.6" idna = ">=2.0" multidict = ">=4.0" +[[package]] +name = "zipp" +version = "3.7.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] + [extras] sqs = ["boto3"] [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "62216fadc2823835780f7431eee41cac6097e85e4e069d03505ecef853fa20ac" +content-hash = "da25dfc60391e122ea7f22f6cefdf2a42855d62ae3d0d328f924b0eab59c6c90" [metadata.files] aiofiles = [ @@ -1320,6 +1493,10 @@ cryptography = [ {file = "cryptography-36.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:39bdf8e70eee6b1c7b289ec6e5d84d49a6bfa11f8b8646b5b3dfe41219153316"}, {file = "cryptography-36.0.1.tar.gz", hash = "sha256:53e5c1dc3d7a953de055d77bef2ff607ceef7a2aac0353b5d630ab67f7423638"}, ] +cyclic = [ + {file = "cyclic-1.0.0-py3-none-any.whl", hash = "sha256:32d8181d7698f426bce6f14f4c3921ef95b6a84af9f96192b59beb05bc00c3ed"}, + {file = "cyclic-1.0.0.tar.gz", hash = "sha256:ecddd56cb831ee3e6b79f61ecb0ad71caee606c507136867782911aa01c3e5eb"}, +] decorator = [ {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, @@ -1414,6 +1591,10 @@ frozenlist = [ {file = "frozenlist-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:735f386ec522e384f511614c01d2ef9cf799f051353876b4c6fb93ef67a6d1ee"}, {file = "frozenlist-1.2.0.tar.gz", hash = "sha256:68201be60ac56aff972dc18085800b6ee07973c49103a8aba669dee3d71079de"}, ] +ghp-import = [ + {file = "ghp-import-2.0.2.tar.gz", hash = "sha256:947b3771f11be850c852c64b561c600fdddf794bab363060854c1ee7ad05e071"}, + {file = "ghp_import-2.0.2-py3-none-any.whl", hash = "sha256:5f8962b30b20652cdffa9c5a9812f7de6bcb56ec475acac579807719bf242c46"}, +] h11 = [ {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, @@ -1434,6 +1615,10 @@ idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, ] +importlib-metadata = [ + {file = "importlib_metadata-4.10.0-py3-none-any.whl", hash = "sha256:b7cf7d3fef75f1e4c80a96ca660efbd51473d7e8f39b5ab9210febc7809012a4"}, + {file = "importlib_metadata-4.10.0.tar.gz", hash = "sha256:92a8b58ce734b2a4494878e0ecf7d79ccd7a128b5fc6014c401e0b61f006f0f6"}, +] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, @@ -1464,6 +1649,10 @@ jmespath = [ jwt = [ {file = "jwt-1.3.1-py3-none-any.whl", hash = "sha256:61c9170f92e736b530655e75374681d4fcca9cfa8763ab42be57353b2b203494"}, ] +markdown = [ + {file = "Markdown-3.3.6-py3-none-any.whl", hash = "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"}, + {file = "Markdown-3.3.6.tar.gz", hash = "sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006"}, +] markupsafe = [ {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, @@ -1508,6 +1697,26 @@ mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] +mdx-include = [ + {file = "mdx_include-1.4.1-py3-none-any.whl", hash = "sha256:eb1ff93fc98d415c0690b75920d815f3cef2fd6c106a91b20d8961be69278900"}, + {file = "mdx_include-1.4.1.tar.gz", hash = "sha256:2dc288993795e31cf7229ce826d63c837b2b9c6354af217542ead91d739b58e2"}, +] +mergedeep = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] +mkdocs = [ + {file = "mkdocs-1.2.3-py3-none-any.whl", hash = "sha256:a1fa8c2d0c1305d7fc2b9d9f607c71778572a8b110fb26642aa00296c9e6d072"}, + {file = "mkdocs-1.2.3.tar.gz", hash = "sha256:89f5a094764381cda656af4298727c9f53dc3e602983087e1fe96ea1df24f4c1"}, +] +mkdocs-material = [ + {file = "mkdocs-material-8.1.6.tar.gz", hash = "sha256:12eb74faf018950f51261a773f9bea12cc296ec4bdbb2c8cf74102ee35b6df79"}, + {file = "mkdocs_material-8.1.6-py2.py3-none-any.whl", hash = "sha256:b2303413e3154502759f90ee2720b451be8855f769c385d8fb06a93ce54aafe2"}, +] +mkdocs-material-extensions = [ + {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"}, + {file = "mkdocs_material_extensions-1.0.3-py3-none-any.whl", hash = "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44"}, +] multidict = [ {file = "multidict-5.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3822c5894c72e3b35aae9909bef66ec83e44522faf767c0ad39e0e2de11d3b55"}, {file = "multidict-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:28e6d883acd8674887d7edc896b91751dc2d8e87fbdca8359591a13872799e4e"}, @@ -1687,6 +1896,10 @@ pygments = [ {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, ] +pymdown-extensions = [ + {file = "pymdown-extensions-9.1.tar.gz", hash = "sha256:74247f2c80f1d9e3c7242abe1c16317da36c6f26c7ad4b8a7f457f0ec20f0365"}, + {file = "pymdown_extensions-9.1-py3-none-any.whl", hash = "sha256:b03e66f91f33af4a6e7a0e20c740313522995f69a03d86316b1449766c473d0e"}, +] pyparsing = [ {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, @@ -1749,6 +1962,14 @@ pyyaml = [ {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] +pyyaml-env-tag = [ + {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, + {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, +] +rcslice = [ + {file = "rcslice-1.1.0-py3-none-any.whl", hash = "sha256:1b12fc0c0ca452e8a9fd2b56ac008162f19e250906a4290a7e7a98be3200c2a6"}, + {file = "rcslice-1.1.0.tar.gz", hash = "sha256:a2ce70a60690eb63e52b722e046b334c3aaec5e900b28578f529878782ee5c6e"}, +] regex = [ {file = "regex-2021.11.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9345b6f7ee578bad8e475129ed40123d265464c4cfead6c261fd60fc9de00bcf"}, {file = "regex-2021.11.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:416c5f1a188c91e3eb41e9c8787288e707f7d2ebe66e0a6563af280d9b68478f"}, @@ -1901,6 +2122,31 @@ virtualenv = [ {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"}, {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"}, ] +watchdog = [ + {file = "watchdog-2.1.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9693f35162dc6208d10b10ddf0458cc09ad70c30ba689d9206e02cd836ce28a3"}, + {file = "watchdog-2.1.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:aba5c812f8ee8a3ff3be51887ca2d55fb8e268439ed44110d3846e4229eb0e8b"}, + {file = "watchdog-2.1.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ae38bf8ba6f39d5b83f78661273216e7db5b00f08be7592062cb1fc8b8ba542"}, + {file = "watchdog-2.1.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ad6f1796e37db2223d2a3f302f586f74c72c630b48a9872c1e7ae8e92e0ab669"}, + {file = "watchdog-2.1.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:922a69fa533cb0c793b483becaaa0845f655151e7256ec73630a1b2e9ebcb660"}, + {file = "watchdog-2.1.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b2fcf9402fde2672545b139694284dc3b665fd1be660d73eca6805197ef776a3"}, + {file = "watchdog-2.1.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3386b367e950a11b0568062b70cc026c6f645428a698d33d39e013aaeda4cc04"}, + {file = "watchdog-2.1.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f1c00aa35f504197561060ca4c21d3cc079ba29cf6dd2fe61024c70160c990b"}, + {file = "watchdog-2.1.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b52b88021b9541a60531142b0a451baca08d28b74a723d0c99b13c8c8d48d604"}, + {file = "watchdog-2.1.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8047da932432aa32c515ec1447ea79ce578d0559362ca3605f8e9568f844e3c6"}, + {file = "watchdog-2.1.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e92c2d33858c8f560671b448205a268096e17870dcf60a9bb3ac7bfbafb7f5f9"}, + {file = "watchdog-2.1.6-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b7d336912853d7b77f9b2c24eeed6a5065d0a0cc0d3b6a5a45ad6d1d05fb8cd8"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_aarch64.whl", hash = "sha256:cca7741c0fcc765568350cb139e92b7f9f3c9a08c4f32591d18ab0a6ac9e71b6"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_armv7l.whl", hash = "sha256:25fb5240b195d17de949588628fdf93032ebf163524ef08933db0ea1f99bd685"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_i686.whl", hash = "sha256:be9be735f827820a06340dff2ddea1fb7234561fa5e6300a62fe7f54d40546a0"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0d19fb2441947b58fbf91336638c2b9f4cc98e05e1045404d7a4cb7cddc7a65"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:3becdb380d8916c873ad512f1701f8a92ce79ec6978ffde92919fd18d41da7fb"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_s390x.whl", hash = "sha256:ae67501c95606072aafa865b6ed47343ac6484472a2f95490ba151f6347acfc2"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e0f30db709c939cabf64a6dc5babb276e6d823fd84464ab916f9b9ba5623ca15"}, + {file = "watchdog-2.1.6-py3-none-win32.whl", hash = "sha256:e02794ac791662a5eafc6ffeaf9bcc149035a0e48eb0a9d40a8feb4622605a3d"}, + {file = "watchdog-2.1.6-py3-none-win_amd64.whl", hash = "sha256:bd9ba4f332cf57b2c1f698be0728c020399ef3040577cde2939f2e045b39c1e5"}, + {file = "watchdog-2.1.6-py3-none-win_ia64.whl", hash = "sha256:a0f1c7edf116a12f7245be06120b1852275f9506a7d90227648b250755a03923"}, + {file = "watchdog-2.1.6.tar.gz", hash = "sha256:a36e75df6c767cbf46f61a91c70b3ba71811dfa0aca4a324d9407a06a8b7a2e7"}, +] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, @@ -1979,3 +2225,7 @@ yarl = [ {file = "yarl-1.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:797c2c412b04403d2da075fb93c123df35239cd7b4cc4e0cd9e5839b73f52c58"}, {file = "yarl-1.7.2.tar.gz", hash = "sha256:45399b46d60c253327a460e99856752009fcee5f5d3c80b2f7c0cae1c38d56dd"}, ] +zipp = [ + {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"}, + {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"}, +] diff --git a/pyproject.toml b/pyproject.toml index 971d5a9..014b12c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,8 @@ tox-poetry = "^0" ipdb = "^0" httpx = "^0" pytest-mock = "^3.6.1" +mkdocs-material = "^8.1.6" +mdx-include = "^1.4.1" [tool.poetry.extras] sqs = ["boto3"] From 905128464246f13b9fcaa4873faa8b303ea5e03e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kucmus?= Date: Mon, 17 Jan 2022 19:22:20 +0100 Subject: [PATCH 6/7] remove handler injection in favor of handler registration, address typos and review coments --- docs/event_handlers/http.md | 112 +++++++++++++++------ docs/event_handlers/sqs.md | 32 ++++++ docs/index.md | 41 ++++---- samples/simple_app/app.py | 52 +++++----- src/saleor_app/app.py | 57 ++--------- src/saleor_app/deps.py | 4 +- src/saleor_app/endpoints.py | 29 ++---- src/saleor_app/errors.py | 8 +- src/saleor_app/http.py | 23 ----- src/saleor_app/install.py | 6 +- src/saleor_app/saleor/__init__.py | 0 src/saleor_app/schemas/handlers.py | 130 +++++++++---------------- src/saleor_app/schemas/manifest.py | 12 +-- src/saleor_app/schemas/utils.py | 23 +++++ src/saleor_app/tests/conftest.py | 78 ++++++++------- src/saleor_app/tests/test_app.py | 38 ++------ src/saleor_app/tests/test_deps.py | 8 +- src/saleor_app/tests/test_endpoints.py | 28 +++--- src/saleor_app/webhook.py | 80 +++++++++++++++ src/saleor_app/workers/base.py | 46 --------- src/saleor_app/workers/errors.py | 24 ----- src/saleor_app/workers/sqs.py | 117 ---------------------- 22 files changed, 415 insertions(+), 533 deletions(-) delete mode 100644 src/saleor_app/http.py create mode 100644 src/saleor_app/saleor/__init__.py create mode 100644 src/saleor_app/webhook.py delete mode 100644 src/saleor_app/workers/base.py delete mode 100644 src/saleor_app/workers/errors.py delete mode 100644 src/saleor_app/workers/sqs.py diff --git a/docs/event_handlers/http.md b/docs/event_handlers/http.md index faf1c37..a71ea2f 100644 --- a/docs/event_handlers/http.md +++ b/docs/event_handlers/http.md @@ -1,37 +1,37 @@ # HTTP Webhook Event Handling -While it's not neccesary 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). +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). -To configure your app to listen to HTTP webhooks issued from Saleor you need to provide your app with two more arguments. +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. ## Setting up the Saleor App -### Defining webhook handlers +### Getting Webhook details -An HTTP webhook handler is a function that is exactly ile one that one would use as a FastAPI endpoint. There is no need to register those as routes since that is deatl with when the app is initialized. +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. -An example of a http webhook handler is: +```python linenums="1" +from saleor_app.schemas.core import DomainName, WebhookData -```python -from saleor_app.schemas.webhook import Webhook -from saleor_app.deps import saleor_domain_header # (1) +async def get_webhook_details(saleor_domain: DomainName) -> WebhookData: + return WebhookData( + webhook_id="webhook-id", + webhook_secret_key="webhook-secret-key", + ) # (1) -async def product_created( - payload: List[Webhook], - saleor_domain=Depends(saleor_domain_header) # (2) -): - await do_something(payload, saleor_domain) ``` -1. :information_source: `saleor_app.deps` contains a set of FastAPI dependencies that you might find useful -2. :information_source: since `product_created` is just a FastAPI endpoint you have access for everything a usual endpoint would, like `request: Request` +1. :material-database: Typically the data would be taken from a database -### Getting Webhook details +The function takes the `saleor_domain` and must return a `WebhookData` Pydantic model instance + +### Enabling the webhook router -The framework ensures that the webhook comes from a trusted source but to achieve that it needs to be provided with a way of retreivng the webhook_secret your app sotred 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. +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: -```python +```python linenums="1" hl_lines="16" +from saleor_app.app import SaleorApp from saleor_app.schemas.core import DomainName, WebhookData @@ -39,30 +39,80 @@ async def get_webhook_details(saleor_domain: DomainName) -> WebhookData: return WebhookData( webhook_id="webhook-id", webhook_secret_key="webhook-secret-key", - ) # (1) + ) + +app = SaleorApp( + #[...] +) + +app.include_webhook_router(get_webhook_details=get_webhook_details) ``` +### Defining webhook handlers -1. :material-database: Typically the data would be taken from a database +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. + +An example of a HTTP webhook handler is: + +```python linenums="1" hl_lines="21-26" +from saleor_app.app import SaleorApp +from saleor_app.deps import saleor_domain_header # (1) +from saleor_app.schemas.handlers import SaleorEventType +from saleor_app.schemas.webhook import Webhook +from saleor_app.schemas.core import DomainName, WebhookData + + +async def get_webhook_details(saleor_domain: DomainName) -> WebhookData: + return WebhookData( + webhook_id="webhook-id", + webhook_secret_key="webhook-secret-key", + ) + + +app = SaleorApp( + #[...] +) +app.include_webhook_router(get_webhook_details=get_webhook_details) + + +@app.webhook_router.http_event_route(SaleorEventType.PRODUCT_CREATED) +async def product_created( + payload: List[Webhook], + saleor_domain=Depends(saleor_domain_header) # (2) +): + await do_something(payload, saleor_domain) +``` -The function takes the `saleor_domain` and must return a `WebhookData` Pytantic model instance +1. :information_source: `saleor_app.deps` contains a set of FastAPI dependencies that you might find useful +2. :information_source: since `product_created` is just a FastAPI endpoint you have access to everything a usual endpoint would, like `request: Request` -### Injecting the handlers into the app +If your app is bigger and you need to import your endpoints from a different module you can: -Now when your handlers code is prepared you can provide it to the `SaleorApp`: +```python linenums="1" hl_lines="6 22-26" +from saleor_app.app import SaleorApp +from saleor_app.schemas.handlers import SaleorEventType +from saleor_app.schemas.webhook import Webhook +from saleor_app.schemas.core import DomainName, WebhookData -```python -from saleor_app.schemas.handlers import WebhookHandlers +from my_app.webhook_handlers import product_created + + +async def get_webhook_details(saleor_domain: DomainName) -> WebhookData: + return WebhookData( + webhook_id="webhook-id", + webhook_secret_key="webhook-secret-key", + ) app = SaleorApp( - # ... - get_webhook_details=get_webhook_details, - http_webhook_handlers=WebhookHandlers( - product_created=product_created, - ), - # ... + #[...] ) +app.include_webhook_router(get_webhook_details=get_webhook_details) + + +@app.webhook_router.http_event_route( + SaleorEventType.PRODUCT_CREATED +)(product_created) ``` ### Reinstall the app diff --git a/docs/event_handlers/sqs.md b/docs/event_handlers/sqs.md index 0b5392f..627a6eb 100644 --- a/docs/event_handlers/sqs.md +++ b/docs/event_handlers/sqs.md @@ -3,3 +3,35 @@ !!! warning "Experimental" SQS event handing is in the works, more content to come + + +## SQS Consumer + +The Saleor App Framework does not provide any means to consume events from an SQS queue. An SQS worker is a work in progress. + +## Registering SQS handlers + +```python +from saleor_app.schemas.handlers import SQSUrl + + +@app.webhook_router.sqs_event_route( + SQSUrl( + None, + scheme="awssqs", + user="test", + password="test", + host="localstack", + port="4566", + path="/00000000/product_updated", + ), + SaleorEventType.PRODUCT_UPDATED, +) +async def product_updated( + payload: List[Webhook], + saleor_domain=Depends(saleor_domain_header), + example=Depends(example_dependency), +): + print("Product updated!") + print(payload) +``` diff --git a/docs/index.md b/docs/index.md index 4a58ebc..9dbb2c5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ The only supported web framework is **FastAPI**. ### Install the framework -Using Poetry (recommended): +Using Poetry (recommended, [installing poetry](https://python-poetry.org/docs/#installation){ target=_blank }): ```bash poetry add git+https://github.com/saleor/saleor-app-framework-python.git@main @@ -23,14 +23,14 @@ Using Pip: pip install git+https://github.com/saleor/saleor-app-framework-python.git@main ``` -### Create the FastAPI app +### Create the Saleor app To run your Saleor App you can use the ```#!python SaleorApp``` class which overloads the usual ```#!python FastAPI``` class. -```python +```python linenums="1" {!./docs/../samples/simple_app/app.py[ln:9]!} -{!./docs/../samples/simple_app/app.py[ln:95]!} +{!./docs/../samples/simple_app/app.py[ln:77]!} # more arguments to come ) ``` @@ -41,16 +41,16 @@ You can use the ```#!python app``` instance as you would normally use the standa As described in [:saleor-saleor: App manifest](https://docs.saleor.io/docs/3.0/developer/extending/apps/manifest) an app needs a manifest, the framework provides a Pydantic representation of that which needs to be provided when initializing the app. -```python +```python linenums="1" hl_lines="2-3 6-18 22" {!./docs/../samples/simple_app/app.py[ln:9]!} {!./docs/../samples/simple_app/app.py[ln:17]!} {!./docs/../samples/simple_app/app.py[ln:18]!} -{!./docs/../samples/simple_app/app.py[ln:80-92]!} +{!./docs/../samples/simple_app/app.py[ln:62-74]!} -{!./docs/../samples/simple_app/app.py[ln:95-96]!} +{!./docs/../samples/simple_app/app.py[ln:77-78]!} # more arguments to come ) ``` @@ -63,7 +63,7 @@ As described in [:saleor-saleor: App manifest](https://docs.saleor.io/docs/3.0/d 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. -```python +```python linenums="1" hl_lines="2 7-8 28" {!./docs/../samples/simple_app/app.py[ln:9]!} from saleor_app.schemas.core import DomainName {!./docs/../samples/simple_app/app.py[ln:17]!} @@ -73,10 +73,10 @@ from saleor_app.schemas.core import DomainName {!./docs/../samples/simple_app/app.py[ln:38-39]!} -{!./docs/../samples/simple_app/app.py[ln:80-92]!} +{!./docs/../samples/simple_app/app.py[ln:62-74]!} -{!./docs/../samples/simple_app/app.py[ln:95-97]!} +{!./docs/../samples/simple_app/app.py[ln:77-79]!} # more arguments to come ) ``` @@ -86,9 +86,9 @@ from saleor_app.schemas.core import DomainName 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. -```python +```python linenums="1" hl_lines="2 11-17 38" {!./docs/../samples/simple_app/app.py[ln:9]!} -{!./docs/../samples/simple_app/app.py[ln:15]!} +{!./docs/../samples/simple_app/app.py[ln:10]!} {!./docs/../samples/simple_app/app.py[ln:17]!} {!./docs/../samples/simple_app/app.py[ln:18]!} @@ -96,10 +96,10 @@ When Saleor is authorized to install the app an authentication key is issued, th {!./docs/../samples/simple_app/app.py[ln:38-48]!} #(1) -{!./docs/../samples/simple_app/app.py[ln:80-92]!} +{!./docs/../samples/simple_app/app.py[ln:62-74]!} -{!./docs/../samples/simple_app/app.py[ln:95-98]!} +{!./docs/../samples/simple_app/app.py[ln:77-80]!} ) ``` @@ -110,14 +110,14 @@ When Saleor is authorized to install the app an authentication key is issued, th To finalize you need to provide the endpoint named ```#!python configuration-form``` sepecified in the [Manifest](#manifest). -```python +```python linenums="1" hl_lines="1 3-4 8 48-100" import json {!./docs/../samples/simple_app/app.py[ln:4-5]!} {!./docs/../samples/simple_app/app.py[ln:9]!} +{!./docs/../samples/simple_app/app.py[ln:10]!} from saleor_app.deps import ConfigurationFormDeps -{!./docs/../samples/simple_app/app.py[ln:15]!} {!./docs/../samples/simple_app/app.py[ln:17]!} {!./docs/../samples/simple_app/app.py[ln:18]!} @@ -125,14 +125,14 @@ from saleor_app.deps import ConfigurationFormDeps {!./docs/../samples/simple_app/app.py[ln:38-48]!} -{!./docs/../samples/simple_app/app.py[ln:80-92]!} +{!./docs/../samples/simple_app/app.py[ln:62-74]!} -{!./docs/../samples/simple_app/app.py[ln:95-98]!} +{!./docs/../samples/simple_app/app.py[ln:77-80]!} ) -{!./docs/../samples/simple_app/app.py[ln:109-116]!} +{!./docs/../samples/simple_app/app.py[ln:107-116]!} {!./docs/../samples/simple_app/app.py[ln:124]!} #(1) @@ -142,6 +142,7 @@ from saleor_app.deps import ConfigurationFormDeps > This is a complete example that will work as is. +!!! warning "Remember about `app.include_saleor_app_routes()`" ### Running the App @@ -153,7 +154,7 @@ uvicorn simple_app.app:app --host 0.0.0.0 --port 5000 --reload Or create a `simple_app/__main__.py` with: -```python +```python linenums="1" import uvicorn diff --git a/samples/simple_app/app.py b/samples/simple_app/app.py index c3fd16d..eccb9dc 100644 --- a/samples/simple_app/app.py +++ b/samples/simple_app/app.py @@ -7,13 +7,13 @@ from starlette.middleware.cors import CORSMiddleware from saleor_app.app import SaleorApp +from saleor_app.schemas.core import DomainName, WebhookData from saleor_app.deps import ( ConfigurationDataDeps, ConfigurationFormDeps, saleor_domain_header, ) -from saleor_app.schemas.core import DomainName, WebhookData -from saleor_app.schemas.handlers import WebhookHandlers +from saleor_app.schemas.handlers import SaleorEventType from saleor_app.schemas.manifest import Manifest from saleor_app.schemas.utils import LazyUrl from saleor_app.schemas.webhook import Webhook @@ -59,24 +59,6 @@ async def example_dependency(): return "example" -async def product_created( - payload: List[Webhook], - saleor_domain=Depends(saleor_domain_header), - example=Depends(example_dependency), -): - print("Product created!") - print(payload) - - -async def product_updated( - payload: List[Webhook], - saleor_domain=Depends(saleor_domain_header), - example=Depends(example_dependency), -): - print("Product updated!") - print(payload) - - manifest = Manifest( name="Sample Saleor App", version="0.1.0", @@ -96,17 +78,35 @@ async def product_updated( manifest=manifest, validate_domain=validate_domain, save_app_data=store_app_data, - get_webhook_details=get_webhook_details, - http_webhook_handlers=WebhookHandlers( - product_created=product_created, - product_updated=product_updated, - ), use_insecure_saleor_http=settings.debug, development_auth_token=settings.development_auth_token, ) +app.include_webhook_router(get_webhook_details=get_webhook_details) -@app.configuration_router.get("/", response_class=HTMLResponse, name="configuration-form") +@app.webhook_router.http_event_route(SaleorEventType.PRODUCT_CREATED) +async def product_created( + payload: List[Webhook], + saleor_domain=Depends(saleor_domain_header), + example=Depends(example_dependency), +): + print("Product created!") + print(payload) + + +@app.webhook_router.http_event_route(SaleorEventType.PRODUCT_UPDATED) +async def product_updated( + payload: List[Webhook], + saleor_domain=Depends(saleor_domain_header), + example=Depends(example_dependency), +): + print("Product updated!") + print(payload) + + +@app.configuration_router.get( + "/", response_class=HTMLResponse, name="configuration-form" +) async def get_public_form(commons: ConfigurationFormDeps = Depends()): context = { "request": str(commons.request), diff --git a/src/saleor_app/app.py b/src/saleor_app/app.py index 3d674ff..447e5e9 100644 --- a/src/saleor_app/app.py +++ b/src/saleor_app/app.py @@ -1,17 +1,11 @@ -import warnings from typing import Awaitable, Callable, Optional -from fastapi import APIRouter, Depends, FastAPI -from fastapi.routing import APIRoute +from fastapi import APIRouter, FastAPI -from saleor_app.deps import verify_saleor_domain, verify_webhook_signature -from saleor_app.endpoints import handle_webhook, install, manifest -from saleor_app.errors import ConfigurationError -from saleor_app.http import WebhookRoute +from saleor_app.endpoints import install, manifest from saleor_app.schemas.core import DomainName, WebhookData -from saleor_app.schemas.handlers import SQSHandlers, WebhookHandlers from saleor_app.schemas.manifest import Manifest -from saleor_app.settings import AWSSettings +from saleor_app.webhook import WebhookRoute, WebhookRouter class SaleorApp(FastAPI): @@ -21,12 +15,6 @@ def __init__( manifest: Manifest, validate_domain: Callable[[DomainName], Awaitable[bool]], save_app_data: Callable[[DomainName, WebhookData], Awaitable], - get_webhook_details: Optional[ - Callable[[DomainName], Awaitable[WebhookData]] - ] = None, - http_webhook_handlers: Optional[WebhookHandlers] = None, - aws_settings: Optional[AWSSettings] = None, - sqs_handlers: Optional[SQSHandlers] = None, use_insecure_saleor_http: bool = False, development_auth_token: Optional[str] = None, **kwargs, @@ -34,20 +22,6 @@ def __init__( super().__init__(**kwargs) self.manifest = manifest - self.http_webhook_handlers = http_webhook_handlers - self.sqs_handlers = sqs_handlers - - if self.sqs_handlers: - self.aws_settings = aws_settings - warnings.simplefilter("always", RuntimeWarning) - warnings.warn( - "SQS support is highly experimental, be warned!", - category=RuntimeWarning, - ) - if not aws_settings: - raise ConfigurationError( - "To leverage SQS webhook handlers you must provide aws_settings" - ) self.validate_domain = validate_domain self.save_app_data = save_app_data @@ -58,9 +32,6 @@ def __init__( self.configuration_router = APIRouter( prefix="/configuration", tags=["configuration"] ) - if self.http_webhook_handlers: - self.get_webhook_details = get_webhook_details - self.include_webhook_router() def include_saleor_app_routes(self): self.configuration_router.get( @@ -77,28 +48,18 @@ def include_saleor_app_routes(self): self.include_router(self.configuration_router) - def include_webhook_router(self): - router = APIRouter( + def include_webhook_router( + self, get_webhook_details: Callable[[DomainName], Awaitable[WebhookData]] + ): + self.get_webhook_details = get_webhook_details + self.webhook_router = WebhookRouter( prefix="/webhook", responses={ 400: {"description": "Missing required header"}, 401: {"description": "Incorrect signature"}, 404: {"description": "Incorrect saleor event"}, }, - dependencies=[ - Depends(verify_saleor_domain), - Depends(verify_webhook_signature), - ], route_class=WebhookRoute, ) - router.post("", name="handle-webhook")(handle_webhook) - self.webhook_handler_routes = { - name: APIRoute( - "", - handler, - ) - for name, handler in self.http_webhook_handlers - if handler - } - self.include_router(router) + self.include_router(self.webhook_router) diff --git a/src/saleor_app/deps.py b/src/saleor_app/deps.py index 37d3590..be77e9f 100644 --- a/src/saleor_app/deps.py +++ b/src/saleor_app/deps.py @@ -51,9 +51,9 @@ async def verify_saleor_token( schema = "http" if request.app.use_insecure_saleor_http else "https" async with get_client_for_app( f"{schema}://{saleor_domain}", manifest=request.app.manifest - ) as saleor: + ) as saleor_client: try: - response = await saleor.execute( + response = await saleor_client.execute( VERIFY_TOKEN, variables={ "token": token, diff --git a/src/saleor_app/endpoints.py b/src/saleor_app/endpoints.py index 58f9f3d..a5a2b9e 100644 --- a/src/saleor_app/endpoints.py +++ b/src/saleor_app/endpoints.py @@ -1,16 +1,12 @@ -from typing import List - -from fastapi import Depends, Header, Request +from fastapi import Depends, Request from fastapi.exceptions import HTTPException from saleor_app.deps import saleor_domain_header, verify_saleor_domain from saleor_app.errors import InstallAppError -from saleor_app.http import SALEOR_EVENT_HEADER from saleor_app.install import install_app from saleor_app.saleor.exceptions import GraphQLError from saleor_app.schemas.core import InstallData from saleor_app.schemas.utils import LazyUrl -from saleor_app.schemas.webhook import Webhook async def manifest(request: Request): @@ -32,12 +28,14 @@ async def install( saleor_domain=Depends(saleor_domain_header), ): events = {} - if request.app.http_webhook_handlers: - events[ - request.url_for("handle-webhook") - ] = request.app.http_webhook_handlers.get_assigned_events() - if request.app.sqs_handlers: - events.update(request.app.sqs_handlers.get_assigned_events()) + if request.app.webhook_router.http_routes: + events[request.url_for("handle-webhook")] = list( + request.app.webhook_router.http_routes.keys() + ) + for event_type, sqs_handler in request.app.webhook_router.sqs_routes.items(): + key = str(sqs_handler.target_url) + events.setdefault(key, []) + events[key].append(event_type) if events: try: @@ -62,12 +60,3 @@ async def install( ) return {} - - -async def handle_webhook( - request: Request, - payload: List[Webhook], # FIXME provide a way to proper define payload types - saleor_domain=Depends(saleor_domain_header), - _event_type=Header(None, alias=SALEOR_EVENT_HEADER), -): - return {} diff --git a/src/saleor_app/errors.py b/src/saleor_app/errors.py index b1c55e4..8f07df9 100644 --- a/src/saleor_app/errors.py +++ b/src/saleor_app/errors.py @@ -1,6 +1,10 @@ -class InstallAppError(Exception): +class SaleorAppError(Exception): + """Generic Saleor App Error, all framework errros inherit from this""" + + +class InstallAppError(SaleorAppError): """Install App error""" -class ConfigurationError(Exception): +class ConfigurationError(SaleorAppError): """App is misconfigured""" diff --git a/src/saleor_app/http.py b/src/saleor_app/http.py deleted file mode 100644 index 44f068b..0000000 --- a/src/saleor_app/http.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Callable - -from fastapi import HTTPException, Request -from fastapi.routing import APIRoute -from starlette.responses import Response - -SALEOR_EVENT_HEADER = "x-saleor-event" - - -class WebhookRoute(APIRoute): - def get_route_handler(self) -> Callable: - async def custom_route_handler(request: Request) -> Response: - if event_type := request.headers.get("x-saleor-event"): - route = request.app.webhook_handler_routes[event_type] - handler = route.get_route_handler() - response: Response = await handler(request) - return response - - raise HTTPException( - status_code=400, detail=f"Missing {SALEOR_EVENT_HEADER.upper()} header." - ) - - return custom_route_handler diff --git a/src/saleor_app/install.py b/src/saleor_app/install.py index 85c49d1..6353c58 100644 --- a/src/saleor_app/install.py +++ b/src/saleor_app/install.py @@ -29,14 +29,14 @@ async def install_app( async with get_client_for_app( f"{schema}://{saleor_domain}", manifest=manifest, auth_token=auth_token - ) as saleor: + ) as saleor_client: for target_url, event_types in events.items(): try: - response = await saleor.execute( + response = await saleor_client.execute( CREATE_WEBHOOK, variables={ "input": { - "targetUrl": target_url, + "targetUrl": str(target_url), "events": [event.upper() for event in event_types], "name": f"{manifest.name}", "secretKey": secret_key, diff --git a/src/saleor_app/saleor/__init__.py b/src/saleor_app/saleor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/saleor_app/schemas/handlers.py b/src/saleor_app/schemas/handlers.py index 77d893e..68af049 100644 --- a/src/saleor_app/schemas/handlers.py +++ b/src/saleor_app/schemas/handlers.py @@ -1,76 +1,59 @@ +from enum import Enum from typing import Awaitable, Callable, List, Optional -from pydantic import AnyHttpUrl, BaseModel, create_model +from pydantic import AnyHttpUrl, BaseModel from saleor_app.schemas.core import DomainName from saleor_app.schemas.webhook import Webhook -SALEOR_EVENT_TYPES = ( - "ORDER_CREATED", - "ORDER_CONFIRMED", - "ORDER_FULLY_PAID", - "ORDER_UPDATED", - "ORDER_CANCELLED", - "ORDER_FULFILLED", - "DRAFT_ORDER_CREATED", - "DRAFT_ORDER_UPDATED", - "DRAFT_ORDER_DELETED", - "SALE_CREATED", - "SALE_UPDATED", - "SALE_DELETED", - "INVOICE_REQUESTED", - "INVOICE_DELETED", - "INVOICE_SENT", - "CUSTOMER_CREATED", - "CUSTOMER_UPDATED", - "PRODUCT_CREATED", - "PRODUCT_UPDATED", - "PRODUCT_DELETED", - "PRODUCT_VARIANT_CREATED", - "PRODUCT_VARIANT_UPDATED", - "PRODUCT_VARIANT_DELETED", - "PRODUCT_VARIANT_OUT_OF_STOCK", - "PRODUCT_VARIANT_BACK_IN_STOCK", - "CHECKOUT_CREATED", - "CHECKOUT_UPDATED", - "FULFILLMENT_CREATED", - "FULFILLMENT_CANCELED", - "NOTIFY_USER", - "PAGE_CREATED", - "PAGE_UPDATED", - "PAGE_DELETED", - "PAYMENT_AUTHORIZE", - "PAYMENT_CAPTURE", - "PAYMENT_CONFIRM", - "PAYMENT_LIST_GATEWAYS", - "PAYMENT_PROCESS", - "PAYMENT_REFUND", - "PAYMENT_VOID", - "SHIPPING_LIST_METHODS_FOR_CHECKOUT", - "TRANSLATION_CREATED", - "TRANSLATION_UPDATED", -) - -WebHookHandlerSignature = Optional[Callable[[List[Webhook], DomainName], Awaitable]] - - -class WebhookHandlersBase(BaseModel): - def get(self, event_name) -> Optional[Callable[[BaseModel], Awaitable]]: - return self.__dict__.get(event_name) - - def get_assigned_events(self) -> List[str]: - return [k for k, v in self.__dict__.items() if v is not None] +class SaleorEventType(str, Enum): + ORDER_CREATED = "ORDER_CREATED" + ORDER_CONFIRMED = "ORDER_CONFIRMED" + ORDER_FULLY_PAID = "ORDER_FULLY_PAID" + ORDER_UPDATED = "ORDER_UPDATED" + ORDER_CANCELLED = "ORDER_CANCELLED" + ORDER_FULFILLED = "ORDER_FULFILLED" + DRAFT_ORDER_CREATED = "DRAFT_ORDER_CREATED" + DRAFT_ORDER_UPDATED = "DRAFT_ORDER_UPDATED" + DRAFT_ORDER_DELETED = "DRAFT_ORDER_DELETED" + SALE_CREATED = "SALE_CREATED" + SALE_UPDATED = "SALE_UPDATED" + SALE_DELETED = "SALE_DELETED" + INVOICE_REQUESTED = "INVOICE_REQUESTED" + INVOICE_DELETED = "INVOICE_DELETED" + INVOICE_SENT = "INVOICE_SENT" + CUSTOMER_CREATED = "CUSTOMER_CREATED" + CUSTOMER_UPDATED = "CUSTOMER_UPDATED" + PRODUCT_CREATED = "PRODUCT_CREATED" + PRODUCT_UPDATED = "PRODUCT_UPDATED" + PRODUCT_DELETED = "PRODUCT_DELETED" + PRODUCT_VARIANT_CREATED = "PRODUCT_VARIANT_CREATED" + PRODUCT_VARIANT_UPDATED = "PRODUCT_VARIANT_UPDATED" + PRODUCT_VARIANT_DELETED = "PRODUCT_VARIANT_DELETED" + PRODUCT_VARIANT_OUT_OF_STOCK = "PRODUCT_VARIANT_OUT_OF_STOCK" + PRODUCT_VARIANT_BACK_IN_STOCK = "PRODUCT_VARIANT_BACK_IN_STOCK" + CHECKOUT_CREATED = "CHECKOUT_CREATED" + CHECKOUT_UPDATED = "CHECKOUT_UPDATED" + FULFILLMENT_CREATED = "FULFILLMENT_CREATED" + FULFILLMENT_CANCELED = "FULFILLMENT_CANCELED" + NOTIFY_USER = "NOTIFY_USER" + PAGE_CREATED = "PAGE_CREATED" + PAGE_UPDATED = "PAGE_UPDATED" + PAGE_DELETED = "PAGE_DELETED" + PAYMENT_AUTHORIZE = "PAYMENT_AUTHORIZE" + PAYMENT_CAPTURE = "PAYMENT_CAPTURE" + PAYMENT_CONFIRM = "PAYMENT_CONFIRM" + PAYMENT_LIST_GATEWAYS = "PAYMENT_LIST_GATEWAYS" + PAYMENT_PROCESS = "PAYMENT_PROCESS" + PAYMENT_REFUND = "PAYMENT_REFUND" + PAYMENT_VOID = "PAYMENT_VOID" + SHIPPING_LIST_METHODS_FOR_CHECKOUT = "SHIPPING_LIST_METHODS_FOR_CHECKOUT" + TRANSLATION_CREATED = "TRANSLATION_CREATED" + TRANSLATION_UPDATED = "TRANSLATION_UPDATED" -WebhookHandlers = create_model( - "WebhookHandlers", - __base__=WebhookHandlersBase, - **{ - event_type.lower(): (WebHookHandlerSignature, None) - for event_type in SALEOR_EVENT_TYPES - }, -) +WebHookHandlerSignature = Optional[Callable[[List[Webhook], DomainName], Awaitable]] class SQSUrl(AnyHttpUrl): @@ -78,22 +61,5 @@ class SQSUrl(AnyHttpUrl): class SQSHandler(BaseModel): - queue_url: SQSUrl + target_url: SQSUrl handler: WebHookHandlerSignature - - -class SQSHandlersBase(WebhookHandlersBase): - def get_assigned_events(self) -> List[str]: - sqs_queue_events = {} - for event_type, sqs_handler in self.__dict__.items(): - if sqs_handler is not None: - sqs_queue_events.setdefault(str(sqs_handler.queue_url), []) - sqs_queue_events[str(sqs_handler.queue_url)].append(event_type) - return sqs_queue_events - - -SQSHandlers = create_model( - "SQSHandlers", - __base__=SQSHandlersBase, - **{event_type.lower(): (SQSHandler, None) for event_type in SALEOR_EVENT_TYPES}, -) diff --git a/src/saleor_app/schemas/manifest.py b/src/saleor_app/schemas/manifest.py index bcc51b3..3a671a0 100644 --- a/src/saleor_app/schemas/manifest.py +++ b/src/saleor_app/schemas/manifest.py @@ -1,7 +1,7 @@ from enum import Enum from typing import List, Union -from pydantic import AnyHttpUrl, BaseModel, Field, root_validator +from pydantic import AnyHttpUrl, BaseModel, Field from saleor_app.schemas.utils import LazyUrl @@ -45,13 +45,9 @@ class Manifest(BaseModel): support_url: Union[AnyHttpUrl, LazyUrl] = Field(..., alias="supportUrl") configuration_url: Union[AnyHttpUrl, LazyUrl] = Field(..., alias="configurationUrl") app_url: Union[AnyHttpUrl, LazyUrl] = Field("", alias="appUrl") - token_target_url: Union[AnyHttpUrl, LazyUrl] = Field(..., alias="tokenTargetUrl") + token_target_url: Union[AnyHttpUrl, LazyUrl] = Field( + LazyUrl("app-install"), alias="tokenTargetUrl" + ) class Config: allow_population_by_field_name = True - - @root_validator(pre=True) - def default_token_target_url(cls, values): - if not values.get("token_target_url"): - values["token_target_url"] = LazyUrl("app-install") - return values diff --git a/src/saleor_app/schemas/utils.py b/src/saleor_app/schemas/utils.py index a21f118..0afc19e 100644 --- a/src/saleor_app/schemas/utils.py +++ b/src/saleor_app/schemas/utils.py @@ -10,6 +10,14 @@ class LazyUrl(str): def __init__(self, name: str): self.name = name + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + return v + @cached_property def resolve(self): return self.request.url_for(self.name) @@ -22,3 +30,18 @@ def __call__(self, request: Request): raise ConfigurationError( f"Failed to resolve a lazy url, check if an endpoint named '{self.name}' is defined." ) + + def __hash__(self): + return hash(self.name) + + def __eq__(self, other): + return self.name == other.name + + def __ne__(self, other): + return not (self.name == other.name) + + def __str__(self): + return f"LazyURL('{self.name}')" + + def __repr__(self): + return str(self) diff --git a/src/saleor_app/tests/conftest.py b/src/saleor_app/tests/conftest.py index 31e4ee3..c1b687e 100644 --- a/src/saleor_app/tests/conftest.py +++ b/src/saleor_app/tests/conftest.py @@ -3,7 +3,7 @@ import pytest from saleor_app.app import SaleorApp -from saleor_app.schemas.handlers import SQSHandlers, WebhookHandlers +from saleor_app.schemas.handlers import SaleorEventType, SQSUrl from saleor_app.schemas.manifest import Extension, Manifest from saleor_app.schemas.utils import LazyUrl from saleor_app.settings import AWSSettings @@ -46,59 +46,71 @@ def manifest(): @pytest.fixture -def http_webhook_handlers(): - return WebhookHandlers( - product_created=AsyncMock(), - product_updated=AsyncMock(), - product_deleted=AsyncMock(), - ) - - -@pytest.fixture -def sqs_handlers(): - return SQSHandlers( - product_created={ - "queue_url": "awssqs://user:password@sqs:4556/account_id/product_created", - "handler": AsyncMock(), - }, - product_updated={ - "queue_url": "awssqs://user:password@sqs:4556/account_id/product_updated", - "handler": AsyncMock(), - }, - product_deleted={ - "queue_url": "awssqs://user:password@sqs:4556/account_id/product_deleted", - "handler": AsyncMock(), - }, - ) +def get_webhook_details(): + return AsyncMock() @pytest.fixture -def get_webhook_details(): +def webhook_handler(): return AsyncMock() @pytest.fixture -def saleor_app( - manifest, aws_settings, http_webhook_handlers, sqs_handlers, get_webhook_details -): +def saleor_app(manifest): saleor_app = SaleorApp( manifest=manifest, validate_domain=AsyncMock(), save_app_data=AsyncMock(), - get_webhook_details=get_webhook_details, - http_webhook_handlers=http_webhook_handlers, - aws_settings=aws_settings, - sqs_handlers=sqs_handlers, use_insecure_saleor_http=False, development_auth_token="test_token", ) saleor_app.get("/configuration", name="configuration-form")(lambda x: x) saleor_app.get("/extension", name="extension")(lambda x: x) + saleor_app.get("/test_webhook_handler", name="test-webhook-handler")(lambda x: x) saleor_app.include_saleor_app_routes() return saleor_app +@pytest.fixture +def saleor_app_with_webhooks(saleor_app, get_webhook_details, webhook_handler): + saleor_app.include_webhook_router(get_webhook_details) + saleor_app.webhook_router.http_event_route(SaleorEventType.PRODUCT_CREATED)( + webhook_handler + ) + saleor_app.webhook_router.http_event_route(SaleorEventType.PRODUCT_UPDATED)( + webhook_handler + ) + saleor_app.webhook_router.http_event_route(SaleorEventType.PRODUCT_DELETED)( + webhook_handler + ) + saleor_app.webhook_router.sqs_event_route( + SQSUrl( + None, + scheme="awssqs", + user="username", + password="password", + host="localstack", + port="4566", + path="/account_id/order_created", + ), + SaleorEventType.ORDER_CREATED, + )(webhook_handler) + saleor_app.webhook_router.sqs_event_route( + SQSUrl( + None, + scheme="awssqs", + user="username", + password="password", + host="localstack", + port="4566", + path="/account_id/order_updated", + ), + SaleorEventType.ORDER_UPDATED, + )(webhook_handler) + return saleor_app + + @pytest.fixture def mock_request(saleor_app): return Mock(app=saleor_app, body=AsyncMock(return_value=b"request_body")) diff --git a/src/saleor_app/tests/test_app.py b/src/saleor_app/tests/test_app.py index 1b3a7fc..9150fff 100644 --- a/src/saleor_app/tests/test_app.py +++ b/src/saleor_app/tests/test_app.py @@ -1,49 +1,25 @@ -from unittest.mock import Mock - import pytest from starlette.routing import NoMatchFound -from saleor_app.app import SaleorApp -from saleor_app.errors import ConfigurationError +from saleor_app.webhook import WebhookRouter async def test_saleor_app_init( saleor_app, manifest, - aws_settings, - http_webhook_handlers, - sqs_handlers, - get_webhook_details, ): assert saleor_app.manifest == manifest - assert saleor_app.http_webhook_handlers == http_webhook_handlers - assert saleor_app.sqs_handlers == sqs_handlers - assert saleor_app.aws_settings == aws_settings - assert saleor_app.get_webhook_details == get_webhook_details - assert saleor_app.url_path_for("handle-webhook") == "/webhook" assert saleor_app.url_path_for("manifest") == "/configuration/manifest" assert saleor_app.url_path_for("app-install") == "/configuration/install" - -async def test_saleor_app_no_handlers(manifest): - saleor_app = SaleorApp( - manifest=manifest, - validate_domain=Mock(), - save_app_data=Mock(), - use_insecure_saleor_http=False, - ) with pytest.raises(NoMatchFound): saleor_app.url_path_for("handle-webhook") -async def test_saleor_app_sqs_missing_config(manifest, sqs_handlers): - with pytest.raises(ConfigurationError): - SaleorApp( - manifest=manifest, - validate_domain=Mock(), - save_app_data=Mock(), - get_webhook_details=Mock(), - sqs_handlers=sqs_handlers, - use_insecure_saleor_http=False, - ) +async def test_include_webhook_router(saleor_app, get_webhook_details): + saleor_app.include_webhook_router(get_webhook_details) + + assert saleor_app.get_webhook_details == get_webhook_details + assert saleor_app.url_path_for("handle-webhook") == "/webhook" + assert isinstance(saleor_app.webhook_router, WebhookRouter) diff --git a/src/saleor_app/tests/test_deps.py b/src/saleor_app/tests/test_deps.py index 166c492..2b2b0cc 100644 --- a/src/saleor_app/tests/test_deps.py +++ b/src/saleor_app/tests/test_deps.py @@ -89,7 +89,8 @@ async def test_verify_saleor_domain_invalid(mock_request): assert excinfo.value.detail == "Provided domain saleor_domain is invalid." -async def test_verify_webhook_signature(mock_request, mocker): +async def test_verify_webhook_signature(get_webhook_details, mock_request, mocker): + mock_request.app.include_webhook_router(get_webhook_details) mock_request.app.get_webhook_details.return_value = WebhookData( webhook_id="webhook_id", webhook_secret_key="webhook_secret_key" ) @@ -104,7 +105,10 @@ async def test_verify_webhook_signature(mock_request, mocker): ) -async def test_verify_webhook_signature_invalid(mock_request, mocker): +async def test_verify_webhook_signature_invalid( + get_webhook_details, mock_request, mocker +): + mock_request.app.include_webhook_router(get_webhook_details) mock_request.app.get_webhook_details.return_value = WebhookData( webhook_id="webhook_id", webhook_secret_key="webhook_secret_key" ) diff --git a/src/saleor_app/tests/test_endpoints.py b/src/saleor_app/tests/test_endpoints.py index 95b649d..89295a6 100644 --- a/src/saleor_app/tests/test_endpoints.py +++ b/src/saleor_app/tests/test_endpoints.py @@ -4,6 +4,7 @@ from httpx import AsyncClient from saleor_app.deps import SALEOR_DOMAIN_HEADER +from saleor_app.schemas.handlers import SaleorEventType from saleor_app.schemas.manifest import Manifest # app.dependency_overrides[verify_saleor_domain] = lambda: True @@ -29,14 +30,14 @@ async def test_manifest(saleor_app): assert response.json() == manifest -async def test_install(saleor_app, monkeypatch): +async def test_install(saleor_app_with_webhooks, get_webhook_details, monkeypatch): install_app_mock = AsyncMock() monkeypatch.setattr("saleor_app.endpoints.install_app", install_app_mock) base_url = "http://test_app.saleor.local" - saleor_app.validate_domain = AsyncMock(return_value=True) + saleor_app_with_webhooks.validate_domain = AsyncMock(return_value=True) - async with AsyncClient(app=saleor_app, base_url=base_url) as ac: + async with AsyncClient(app=saleor_app_with_webhooks, base_url=base_url) as ac: response = await ac.post( "configuration/install", json={"auth_token": "saleor-app-token"}, @@ -48,21 +49,18 @@ async def test_install(saleor_app, monkeypatch): install_app_mock.assert_awaited_once_with( saleor_domain="example.com", auth_token="saleor-app-token", - manifest=saleor_app.manifest, + manifest=saleor_app_with_webhooks.manifest, events={ - "http://test_app.saleor.local/webhook": [ - "product_created", - "product_updated", - "product_deleted", + "awssqs://username:password@localstack:4566/account_id/order_created": [ + SaleorEventType.ORDER_CREATED, ], - "awssqs://user:password@sqs:4556/account_id/product_created": [ - "product_created" + "awssqs://username:password@localstack:4566/account_id/order_updated": [ + SaleorEventType.ORDER_UPDATED, ], - "awssqs://user:password@sqs:4556/account_id/product_updated": [ - "product_updated" - ], - "awssqs://user:password@sqs:4556/account_id/product_deleted": [ - "product_deleted" + "http://test_app.saleor.local/webhook": [ + SaleorEventType.PRODUCT_CREATED, + SaleorEventType.PRODUCT_UPDATED, + SaleorEventType.PRODUCT_DELETED, ], }, use_insecure_saleor_http=False, diff --git a/src/saleor_app/webhook.py b/src/saleor_app/webhook.py new file mode 100644 index 0000000..2243141 --- /dev/null +++ b/src/saleor_app/webhook.py @@ -0,0 +1,80 @@ +from typing import Callable, List + +from fastapi import APIRouter, Depends, Header, HTTPException, Request +from fastapi.routing import APIRoute +from starlette.responses import Response + +from saleor_app.deps import ( + saleor_domain_header, + verify_saleor_domain, + verify_webhook_signature, +) +from saleor_app.schemas.handlers import ( + SaleorEventType, + SQSHandler, + SQSUrl, + WebHookHandlerSignature, +) +from saleor_app.schemas.webhook import Webhook + +SALEOR_EVENT_HEADER = "x-saleor-event" + + +class WebhookRoute(APIRoute): + def get_route_handler(self) -> Callable: + async def custom_route_handler(request: Request) -> Response: + if event_type := request.headers.get(SALEOR_EVENT_HEADER): + route = request.app.webhook_router.http_routes[event_type.upper()] + handler = route.get_route_handler() + response: Response = await handler(request) + return response + + raise HTTPException( + status_code=400, detail=f"Missing {SALEOR_EVENT_HEADER.upper()} header." + ) + + return custom_route_handler + + +class WebhookRouter(APIRouter): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.http_routes = {} + self.sqs_routes = {} + self.post("", name="handle-webhook")(self.__handle_webhook_stub) + + async def __handle_webhook_stub( + request: Request, + payload: List[Webhook], # FIXME provide a way to proper define payload types + saleor_domain=Depends(saleor_domain_header), + _verify_saleor_domain=Depends(verify_saleor_domain), + _verify_webhook_signature=Depends(verify_webhook_signature), + _event_type=Header(None, alias=SALEOR_EVENT_HEADER), + ): + """ + This definition will never be used, it's here for the sake of the + OpenAPI spec being complete. + Endpoints registered by `http_event_route` are invoked in place of this. + """ + return {} + + def http_event_route(self, event_type: SaleorEventType): + def decorator(func: WebHookHandlerSignature): + self.http_routes[event_type] = APIRoute( + "", + func, + dependencies=[ + Depends(verify_saleor_domain), + Depends(verify_webhook_signature), + ], + ) + + return decorator + + def sqs_event_route(self, target_url: SQSUrl, event_type: SaleorEventType): + def decorator(func): + self.sqs_routes[event_type] = SQSHandler( + target_url=str(target_url), handler=func + ) + + return decorator diff --git a/src/saleor_app/workers/base.py b/src/saleor_app/workers/base.py deleted file mode 100644 index 0620391..0000000 --- a/src/saleor_app/workers/base.py +++ /dev/null @@ -1,46 +0,0 @@ -import asyncio -import logging -from signal import SIGINT, SIGTERM, signal - -from saleor_app.app import SaleorApp -from saleor_app.schemas.webhook import Webhook - -logger = logging.getLogger(__name__) - - -class SignalHandler: - def __init__(self): - self.received_signal = False - signal(SIGINT, self._signal_handler) - signal(SIGTERM, self._signal_handler) - - def _signal_handler(self, signal, frame): - self.received_signal = True - - -class SaleorAppWorker: - def __init__(self, app: SaleorApp): - self.app = app - self.handlers = { - name: handler for name, handler in self.app.sqs_webhook_handlers if handler - } - - async def parse_webhook_payload(self, message_body): - return Webhook.parse_raw(message_body) - - async def loop(self, queue_name: str): - raise NotImplementedError() - - async def run(self, queue_name: str): - signal_handler = SignalHandler() - while not signal_handler.received_signal: - logger.info("Starting the loop") - try: - await self.loop(queue_name=queue_name) - except Exception as exc: - logger.critical( - "The loop exited with an error, will run the loop again in 60 seconds", - exc_info=exc, - ) - asyncio.sleep(60) - logger.info("Stopping the loop") diff --git a/src/saleor_app/workers/errors.py b/src/saleor_app/workers/errors.py deleted file mode 100644 index f7ee23f..0000000 --- a/src/saleor_app/workers/errors.py +++ /dev/null @@ -1,24 +0,0 @@ -class BaseWorkerError(Exception): - def __init__(self, detail, message): - self.detail = detail - self.mesage = message - - -class TransientError(BaseWorkerError): - """ - Error that can be solved in time, like any connectivity errors, in case - of a transient error the worker will hald and retry to handle the failing - event - """ - - -class NonTransientError(BaseWorkerError): - """ - Error that will never be solved, like a malformet payload error, those errors - can be handled by a deadletter callback. The worker will not be halted in - the case of a non-transient error. - """ - - -class UnrecognizedEventPayload(NonTransientError): - pass diff --git a/src/saleor_app/workers/sqs.py b/src/saleor_app/workers/sqs.py deleted file mode 100644 index 6daf5b1..0000000 --- a/src/saleor_app/workers/sqs.py +++ /dev/null @@ -1,117 +0,0 @@ -import asyncio -import logging -from typing import Any, List, Union - -import boto3 -from pydantic import ValidationError - -from saleor_app.settings import AWSSettings -from saleor_app.workers.base import SaleorAppWorker -from saleor_app.workers.errors import ( - NonTransientError, - TransientError, - UnrecognizedEventPayload, -) - -logger = logging.getLogger(__name__) - - -class SaleorAppSQSWorker(SaleorAppWorker): - def __init__( - self, - queue_name: str, - max_number_of_messages: int = 1, - wait_time_seconds: int = 0, - *args, - **kwargs, - ): - super().__init__(*args, **kwargs) - self.transient_error_wait_seconds = 5 - self.queue_name = queue_name - self.max_number_of_messages = max_number_of_messages - self.wait_time_seconds = wait_time_seconds - - async def process_message( - self, message - ): # TODO: figure out the type of a message and mark that as the return type - try: - webhook = self.parse_webhook_payload(message_body=message.body) - except ValidationError: - raise UnrecognizedEventPayload( - "The message body was not recognized", message=message - ) - - # TODO: allow to decide if not recognized events should be ignored or dead lettered - handler = ( - self.handlers.get() - ) # TODO: figure out how to get an event_type from a message, is it in the message? if not add to webhook meta? - await handler(webhook, message) - return message - - async def handle_non_transient_error(self, message, exc): - """ - This handler will do nothing, thus allowing the loop to exhaust the - attempts of a message retrieval which will result in a SQS dead letter - """ - return - - async def handle_transient_error(self, message, exc): - while True: - logger.warning( - "Transient error occured halting, will retry again in %s seconds", - self.transient_error_wait_seconds, - exc_info=exc, - ) - asyncio.sleep(self.transient_error_wait_seconds) - try: - await self.process_message(message) - except TransientError as new_exc: - exc = new_exc - else: - break - - async def handle_unhandled_exceptions(self, exc): - """ - This handler will do nothing, thus allowing the loop to exhaust the - attempts of a message retrieval which will result in a SQS dead letter - """ - # This should not happen, that's why it's critical - logger.critical( - "Failed to handle a message, message will be put in SQS deadletter", - exc_info=exc, - ) - return - - async def loop(self): - aws_settings: AWSSettings = self.app.settings.aws - sqs = boto3.resource( - "sqs", - region_name=aws_settings.region, - endpoint_url=aws_settings.endpoint_url, - aws_access_key_id=aws_settings.access_key_id, - aws_secret_access_key=aws_settings.secret_access_key, - ) - sqs_queue = sqs.get_queue_by_name(QueueName=self.queue_name) - - while True: - messages = sqs_queue.receive_messages( - MaxNumberOfMessages=self.max_number_of_messages, - WaitTimeSeconds=self.wait_time_seconds, - ) - coros = [self.process_message(message) for message in messages] - results: List[Union[Any, Exception]] = asyncio.gather( - *coros, return_exceptions=True - ) # TODO: figure out the type of a message and put it instead of Any - successful_results = [] - for result in results: - if isinstance(result, TransientError): - await self.handle_transient_error(result.message, result) - elif isinstance(result, NonTransientError): - await self.handle_non_transient_error(result.message, result) - elif isinstance(result, Exception): - await self.handle_unhandled_exceptions(result) - else: - successful_results.append(result) - - for message in successful_results: - message.delete() From 092a3a05eb096796821e6ffea89e2b979964da44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kucmus?= Date: Tue, 18 Jan 2022 22:05:33 +0100 Subject: [PATCH 7/7] fix the complex app example --- docs/index.md | 12 ++- pyproject.toml | 8 +- samples/complex_app/__main__.py | 20 ++++- samples/complex_app/app.py | 56 ++++++++----- .../complex_app/configuration_endpoints.py | 23 ------ samples/complex_app/db.py | 30 ++++++- .../complex_app/endpoints/configuration.py | 32 ++++++++ .../complex_app/{ => endpoints}/extension.py | 0 samples/complex_app/settings.py | 81 ++++++++++--------- samples/complex_app/webhooks.py | 20 ++--- src/saleor_app/endpoints.py | 5 +- src/saleor_app/install.py | 5 +- src/saleor_app/tests/test_endpoints.py | 4 - 13 files changed, 184 insertions(+), 112 deletions(-) delete mode 100644 samples/complex_app/configuration_endpoints.py create mode 100644 samples/complex_app/endpoints/configuration.py rename samples/complex_app/{ => endpoints}/extension.py (100%) diff --git a/docs/index.md b/docs/index.md index 9dbb2c5..e1b7cab 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # Welcome to Saleor App Framework -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). +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 }. The only supported web framework is **FastAPI**. @@ -8,7 +8,7 @@ The only supported web framework is **FastAPI**. ### Install the framework -Using Poetry (recommended, [installing poetry](https://python-poetry.org/docs/#installation){ target=_blank }): +Using Poetry (recommended, [:material-file-link: installing poetry](https://python-poetry.org/docs/#installation){ target=_blank }): ```bash poetry add git+https://github.com/saleor/saleor-app-framework-python.git@main @@ -39,7 +39,7 @@ You can use the ```#!python app``` instance as you would normally use the standa #### Manifest -As described in [:saleor-saleor: App manifest](https://docs.saleor.io/docs/3.0/developer/extending/apps/manifest) an app needs a manifest, the framework provides a Pydantic representation of that which needs to be provided when initializing the app. +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. ```python linenums="1" hl_lines="2-3 6-18 22" {!./docs/../samples/simple_app/app.py[ln:9]!} @@ -108,7 +108,7 @@ When Saleor is authorized to install the app an authentication key is issued, th #### Configuration URL -To finalize you need to provide the endpoint named ```#!python configuration-form``` sepecified in the [Manifest](#manifest). +To finalize, you need to provide the endpoint named ```#!python configuration-form``` specified in the [#Manifest](#manifest). ```python linenums="1" hl_lines="1 3-4 8 48-100" import json @@ -173,3 +173,7 @@ and run the module as a script with Python's `-m` flag: ```bash python -m simple_app ``` + +## Examples + +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. diff --git a/pyproject.toml b/pyproject.toml index 014b12c..f99a228 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ build-backend = "poetry.core.masonry.api" [tool.tox] legacy_tox_ini = """ [tox] -envlist = py38, py39, py310, lint, coverage +envlist = py38, py39, py310, lint, docs, coverage [testenv] description = run the test driver with {basepython} @@ -88,6 +88,12 @@ deps = .[develop] commands = pytest src/saleor_app +[testenv:docs] +description = check if docs have no errors or warnings +basepython = python3.10 +commands = + mkdocs build -s + [testenv:lint] description = check the code style basepython = python3.10 diff --git a/samples/complex_app/__main__.py b/samples/complex_app/__main__.py index 4ba60a2..45d20a3 100644 --- a/samples/complex_app/__main__.py +++ b/samples/complex_app/__main__.py @@ -1,14 +1,30 @@ -import os +import sys import uvicorn +from .db import get_db, configuration, get_domain_config + + +def add_domain(saleor_domain): + db = next(get_db()) + db_config = get_domain_config(db, saleor_domain) + if not db_config: + query = configuration.insert().values( + saleor_domain=saleor_domain, + ) + db.execute(query) + db.commit() + def main(): - os.environ["APP_SETTINGS"] = "complex_app.app.settings" uvicorn.run( "complex_app.app:app", host="0.0.0.0", port=5000, debug=True, reload=True ) if __name__ == "__main__": + if len(sys.argv) == 3: + if sys.argv[1] == "add-domain": + add_domain(sys.argv[2]) + main() diff --git a/samples/complex_app/app.py b/samples/complex_app/app.py index 539abd8..6b85056 100644 --- a/samples/complex_app/app.py +++ b/samples/complex_app/app.py @@ -3,48 +3,60 @@ from saleor_app.app import SaleorApp from saleor_app.schemas.core import DomainName, WebhookData +from saleor_app.schemas.handlers import SaleorEventType -from .configuration_endpoints import router as configuration_router -from .db import configuration, get_db -from .extension import router as extension_router -from .settings import settings -from .webhooks import webhook_handlers +from .endpoints.configuration import router as configuration_router +from .db import configuration, get_db, get_domain_config, update_domain_config +from .endpoints.extension import router as extension_router +from .settings import settings, manifest +from .webhooks import product_created, product_updated, product_deleted -async def validate_domain(domain_name: DomainName) -> bool: - return domain_name == "172.17.0.1:8000" +async def validate_domain(saleor_domain: DomainName) -> bool: + db = next(get_db()) + db_config = get_domain_config(db, saleor_domain) + if db_config: + return True + return False -async def store_app_data(domain_name: DomainName, app_data: WebhookData): +async def store_app_data( + saleor_domain: DomainName, auth_token: str, webhook_data: WebhookData +): print("Called store_app_data") - query = configuration.insert().values( - domain_name=domain_name, - webhook_id=app_data.webhook_id, - webhook_token=app_data.token, - webhook_secret=app_data.webhook_secret_key, - ) db = next(get_db()) - db.execute(query) - db.commit() + update_domain_config( + db, + saleor_domain, + auth_token, + webhook_data.webhook_id, + webhook_data.webhook_secret_key, + ) -async def get_webhook_details(domain_name: DomainName) -> WebhookData: +async def get_webhook_details(saleor_domain: DomainName) -> WebhookData: + db = next(get_db()) + db_config = get_domain_config(db, saleor_domain) return WebhookData( - token="auth-token", - webhook_id="webhook-id", - webhook_secret_key="webhook-secret-key", + webhook_id=db_config.webhook_id, + webhook_secret_key=db_config.webhook_secret, ) app = SaleorApp( + manifest=manifest, validate_domain=validate_domain, save_app_data=store_app_data, - http_webhook_handlers=webhook_handlers, - get_webhook_details=get_webhook_details, + use_insecure_saleor_http=settings.debug, ) app.configuration_router.include_router(configuration_router) app.include_router(extension_router, prefix="/products") app.include_saleor_app_routes() +app.include_webhook_router(get_webhook_details) + +app.webhook_router.http_event_route(SaleorEventType.PRODUCT_CREATED)(product_created) +app.webhook_router.http_event_route(SaleorEventType.PRODUCT_UPDATED)(product_updated) +app.webhook_router.http_event_route(SaleorEventType.PRODUCT_DELETED)(product_deleted) app.add_middleware( CORSMiddleware, diff --git a/samples/complex_app/configuration_endpoints.py b/samples/complex_app/configuration_endpoints.py deleted file mode 100644 index 34ec3ba..0000000 --- a/samples/complex_app/configuration_endpoints.py +++ /dev/null @@ -1,23 +0,0 @@ -from fastapi import APIRouter -from fastapi.param_functions import Depends -from fastapi.responses import HTMLResponse -from pydantic import BaseModel - -from saleor_app.deps import ConfigurationDataDeps -from saleor_app.endpoints import get_public_form - - -class ConfigurationData(BaseModel): - public_api_token: str - private_api_key: int - - -router = APIRouter() - - -router.get("/", response_class=HTMLResponse, name="configuration-form")(get_public_form) - - -@router.get("/data") -async def get_configuration_data(commons: ConfigurationDataDeps = Depends()): - return ConfigurationData(public_api_token="api_token", private_api_key=1) diff --git a/samples/complex_app/db.py b/samples/complex_app/db.py index 7b748d7..d51834a 100644 --- a/samples/complex_app/db.py +++ b/samples/complex_app/db.py @@ -1,3 +1,5 @@ +from urllib.parse import urlparse + import sqlalchemy from sqlalchemy.orm import sessionmaker @@ -9,9 +11,9 @@ "configuration", metadata, sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), - sqlalchemy.Column("domain_name", sqlalchemy.String), + sqlalchemy.Column("saleor_domain", sqlalchemy.String), + sqlalchemy.Column("auth_token", sqlalchemy.String), sqlalchemy.Column("webhook_id", sqlalchemy.String), - sqlalchemy.Column("webhook_token", sqlalchemy.String), sqlalchemy.Column("webhook_secret", sqlalchemy.String), ) @@ -30,3 +32,27 @@ def get_db(): yield db finally: db.close() + + +def get_domain_config(db, saleor_domain: str): + if saleor_domain.startswith("http"): + saleor_domain = urlparse(saleor_domain).netloc + + return ( + db.query(configuration) + .filter( + configuration.c.saleor_domain == saleor_domain, + ) + .first() + ) + + +def update_domain_config(db, saleor_domain: str, auth_token: str, webhook_id: str, webhook_secret: str): + db_config = get_domain_config(db, saleor_domain) + stmt = configuration.update().where(configuration.c.id == db_config.id).values( + auth_token=auth_token, + webhook_id=webhook_id, + webhook_secret=webhook_secret, + ) + db.execute(stmt) + db.commit() diff --git a/samples/complex_app/endpoints/configuration.py b/samples/complex_app/endpoints/configuration.py new file mode 100644 index 0000000..8df1672 --- /dev/null +++ b/samples/complex_app/endpoints/configuration.py @@ -0,0 +1,32 @@ +import json +from fastapi import APIRouter +from fastapi.param_functions import Depends +from fastapi.responses import PlainTextResponse +from pydantic import BaseModel + +from saleor_app.deps import ConfigurationDataDeps, ConfigurationFormDeps + + +class ConfigurationData(BaseModel): + public_api_token: str + private_api_key: int + + +router = APIRouter() + + +@router.get( + "/", response_class=PlainTextResponse, name="configuration-form" +) +async def get_public_form(commons: ConfigurationFormDeps = Depends()): + context = { + "request": str(commons.request), + "form_url": str(commons.request.url), + "saleor_domain": commons.saleor_domain, + } + return PlainTextResponse(json.dumps(context, indent=4)) + + +@router.get("/data") +async def get_configuration_data(commons: ConfigurationDataDeps = Depends()): + return ConfigurationData(public_api_token="api_token", private_api_key=1) diff --git a/samples/complex_app/extension.py b/samples/complex_app/endpoints/extension.py similarity index 100% rename from samples/complex_app/extension.py rename to samples/complex_app/endpoints/extension.py diff --git a/samples/complex_app/settings.py b/samples/complex_app/settings.py index 706c2c0..f634c4b 100644 --- a/samples/complex_app/settings.py +++ b/samples/complex_app/settings.py @@ -1,49 +1,54 @@ from pathlib import Path - -from saleor_app.conf import Settings, SettingsManifest +from pydantic import BaseSettings, DirectoryPath +from saleor_app.schemas.manifest import ( + Manifest, + Extension, + ViewType, + TargetType, + ExtensionType, +) +from saleor_app.schemas.utils import LazyUrl from saleor_app.schemas.core import SaleorPermissions + PROJECT_DIR = Path(__file__).parent -class AppSettings(Settings): +manifest = Manifest( + name="Sample Complex Saleor App", + version="0.1.0", + about="Sample Saleor App seving as an example.", + data_privacy="", + data_privacy_url="", + homepage_url="http://172.17.0.1:5000/homepageUrl", + support_url="http://172.17.0.1:5000/supportUrl", + id="saleor-complex-sample", + permissions=[ + SaleorPermissions.MANAGE_PRODUCTS, + SaleorPermissions.MANAGE_USERS, + ], + configuration_url=LazyUrl("configuration-form"), + extensions=[ + Extension( + url=LazyUrl("custom-add-product"), + label="Custom Product Create", + view=ViewType.PRODUCT, + type=ExtensionType.OVERVIEW, + target=TargetType.CREATE, + permissions=[ + SaleorPermissions.MANAGE_PRODUCTS, + ], + ) + ], +) + + +class AppSettings(BaseSettings): + debug: bool database_dsn: str + static_dir: DirectoryPath settings = AppSettings( - app_name="ComplexApp", - project_dir=PROJECT_DIR, - static_dir=PROJECT_DIR / "static", - templates_dir=PROJECT_DIR / "static", - debug=True, - manifest=SettingsManifest( - name="Sample Saleor App", - version="0.1.0", - about="Sample Saleor App seving as an example.", - data_privacy="", - data_privacy_url="", - homepage_url="http://172.17.0.1:5000/homepageUrl", - support_url="http://172.17.0.1:5000/supportUrl", - id="saleor-complex-sample", - permissions=[ - SaleorPermissions.MANAGE_PRODUCTS, - SaleorPermissions.MANAGE_USERS, - ], - configuration_url_for="configuration-form", - extensions=[ - { - "url_for": "custom-add-product", - "label": "Custom Product Create", - "view": "PRODUCT", - "type": "OVERVIEW", - "target": "CREATE", - "permissions": [ - SaleorPermissions.MANAGE_PRODUCTS, - ], - } - ], - ), - dev_saleor_domain="127.0.0.1:5000", - dev_saleor_token="test_token", - database_dsn="sqlite:///db.sqlite3", + debug=True, database_dsn="sqlite:///db.sqlite3", static_dir=PROJECT_DIR / "static" ) diff --git a/samples/complex_app/webhooks.py b/samples/complex_app/webhooks.py index 002dcf8..d59fd1d 100644 --- a/samples/complex_app/webhooks.py +++ b/samples/complex_app/webhooks.py @@ -1,24 +1,20 @@ -from saleor_app.schemas.core import DomainName -from saleor_app.schemas.handlers import Payload, WebhookHandlers +from typing import List +from fastapi.param_functions import Depends +from saleor_app.schemas.webhook import Webhook +from saleor_app.deps import saleor_domain_header -async def product_created(payload: Payload, saleor_domain: DomainName): + +async def product_created(payload: List[Webhook], saleor_domain=Depends(saleor_domain_header)): print("Product created!") print(payload) -async def product_updated(payload: Payload, saleor_domain: DomainName): +async def product_updated(payload: List[Webhook], saleor_domain=Depends(saleor_domain_header)): print("Product updated!") print(payload) -async def product_deleted(payload: Payload, saleor_domain: DomainName): +async def product_deleted(payload: List[Webhook], saleor_domain=Depends(saleor_domain_header)): print("Product deleted!") print(payload) - - -http_webhook_handlers = WebhookHandlers( - product_created=product_created, - product_updated=product_updated, - product_deleted=product_deleted, -) diff --git a/src/saleor_app/endpoints.py b/src/saleor_app/endpoints.py index a5a2b9e..671e8f8 100644 --- a/src/saleor_app/endpoints.py +++ b/src/saleor_app/endpoints.py @@ -1,3 +1,5 @@ +from collections import defaultdict + from fastapi import Depends, Request from fastapi.exceptions import HTTPException @@ -27,14 +29,13 @@ async def install( _domain_is_valid=Depends(verify_saleor_domain), saleor_domain=Depends(saleor_domain_header), ): - events = {} + events = defaultdict(list) if request.app.webhook_router.http_routes: events[request.url_for("handle-webhook")] = list( request.app.webhook_router.http_routes.keys() ) for event_type, sqs_handler in request.app.webhook_router.sqs_routes.items(): key = str(sqs_handler.target_url) - events.setdefault(key, []) events[key].append(event_type) if events: diff --git a/src/saleor_app/install.py b/src/saleor_app/install.py index 6353c58..39f85cf 100644 --- a/src/saleor_app/install.py +++ b/src/saleor_app/install.py @@ -1,13 +1,14 @@ import logging import secrets import string -from typing import Dict, Optional +from typing import Dict from saleor_app.errors import InstallAppError from saleor_app.saleor.exceptions import GraphQLError from saleor_app.saleor.mutations import CREATE_WEBHOOK from saleor_app.saleor.utils import get_client_for_app from saleor_app.schemas.core import AppToken, DomainName, WebhookData +from saleor_app.schemas.handlers import SaleorEventType from saleor_app.schemas.manifest import Manifest logger = logging.getLogger(__name__) @@ -17,7 +18,7 @@ async def install_app( saleor_domain: DomainName, auth_token: AppToken, manifest: Manifest, - events: Optional[Dict[str, str]], + events: Dict[str, SaleorEventType], use_insecure_saleor_http: bool, ): alphabet = string.ascii_letters + string.digits diff --git a/src/saleor_app/tests/test_endpoints.py b/src/saleor_app/tests/test_endpoints.py index 89295a6..ce0bcdd 100644 --- a/src/saleor_app/tests/test_endpoints.py +++ b/src/saleor_app/tests/test_endpoints.py @@ -7,10 +7,6 @@ from saleor_app.schemas.handlers import SaleorEventType from saleor_app.schemas.manifest import Manifest -# app.dependency_overrides[verify_saleor_domain] = lambda: True -# app.dependency_overrides[saleor_domain_header] = lambda: saleor_domain -# app.dependency_overrides[webhook_event_type] = lambda: "product_created" - async def test_manifest(saleor_app): base_url = "http://test_app.saleor.local"