Merge pull request #351 from arcbtc/FastAPI

Latest from arcbtc branch
This commit is contained in:
Arc 2021-10-02 19:12:42 +02:00 committed by GitHub
commit ac52800e3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 382 additions and 258 deletions

View File

@ -27,6 +27,7 @@ asyncio = "*"
fastapi = "*"
uvicorn = {extras = ["standard"], version = "*"}
sse-starlette = "*"
jinja2 = "3.0.1"
[dev-packages]
black = "==20.8b1"

182
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "e26f678c4b89a86400e0a62396d06e360bfdf1e0f922d474ded200ee1ffde5c4"
"sha256": "97473b3cb250742ebabd8c3a71d4e4c42f8feeaff49dd4542cae24429f096535"
},
"pipfile-spec": 6,
"requires": {
@ -26,11 +26,11 @@
},
"anyio": {
"hashes": [
"sha256:929a6852074397afe1d989002aa96d457e3e1e5441357c60d03e7eea0e65e1b0",
"sha256:ae57a67583e5ff8b4af47666ff5651c3732d45fd26c929253748e796af860374"
"sha256:85913b4e2fec030e8c72a8f9f98092eeb9e25847a6e00d567751b77e34f856fe",
"sha256:d7c604dd491eca70e19c78664d685d5e4337612d574419d503e76f5d7d1590bd"
],
"markers": "python_full_version >= '3.6.2'",
"version": "==3.3.0"
"version": "==3.3.1"
},
"asgiref": {
"hashes": [
@ -91,11 +91,11 @@
},
"charset-normalizer": {
"hashes": [
"sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b",
"sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"
"sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6",
"sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f"
],
"markers": "python_version >= '3.5'",
"version": "==2.0.4"
"version": "==2.0.6"
},
"click": {
"hashes": [
@ -115,10 +115,10 @@
},
"embit": {
"hashes": [
"sha256:19f69929caf0d2fcfd4b708dd873384dfc36267944d02d5e6dfebc835f294e1b"
"sha256:992332bd89af6e2d027e26fe437eb14aa33997db08c882c49064d49c3e6f4ab9"
],
"index": "pypi",
"version": "==0.4.6"
"version": "==0.4.9"
},
"environs": {
"hashes": [
@ -146,11 +146,11 @@
},
"httpcore": {
"hashes": [
"sha256:b0d16f0012ec88d8cc848f5a55f8a03158405f4bca02ee49bc4ca2c1fda49f3e",
"sha256:db4c0dcb8323494d01b8c6d812d80091a31e520033e7b0120883d6f52da649ff"
"sha256:036f960468759e633574d7c121afba48af6419615d36ab8ede979f1ad6276fa3",
"sha256:369aa481b014cf046f7067fddd67d00560f2f00426e79569d99cb11245134af0"
],
"markers": "python_version >= '3.6'",
"version": "==0.13.6"
"version": "==0.13.7"
},
"httptools": {
"hashes": [
@ -195,6 +195,14 @@
"markers": "python_version < '3.8'",
"version": "==4.8.1"
},
"jinja2": {
"hashes": [
"sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4",
"sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"
],
"index": "pypi",
"version": "==3.0.1"
},
"lnurl": {
"hashes": [
"sha256:579982fd8c4d25bc84c61c74ec45cb7999fa1fa2426f5d5aeb0160ba333b9c92",
@ -203,6 +211,66 @@
"index": "pypi",
"version": "==0.3.6"
},
"markupsafe": {
"hashes": [
"sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298",
"sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64",
"sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b",
"sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567",
"sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff",
"sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724",
"sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74",
"sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646",
"sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35",
"sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6",
"sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6",
"sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad",
"sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26",
"sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38",
"sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac",
"sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7",
"sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6",
"sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75",
"sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f",
"sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135",
"sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8",
"sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a",
"sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a",
"sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9",
"sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864",
"sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914",
"sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18",
"sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8",
"sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2",
"sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d",
"sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b",
"sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b",
"sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f",
"sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb",
"sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833",
"sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28",
"sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415",
"sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902",
"sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d",
"sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9",
"sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d",
"sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145",
"sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066",
"sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c",
"sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1",
"sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f",
"sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53",
"sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134",
"sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85",
"sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5",
"sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94",
"sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509",
"sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51",
"sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"
],
"markers": "python_version >= '3.6'",
"version": "==2.0.1"
},
"marshmallow": {
"hashes": [
"sha256:c67929438fd73a2be92128caa0325b1b5ed8b626d91a094d2f7f2771bf1f1c0e",
@ -457,12 +525,12 @@
},
"typing-extensions": {
"hashes": [
"sha256:045dd532231acfa03628df5e0c66dba64e2cc8fc8b844538d4ad6d5dd6cb82dc",
"sha256:83af6730a045fda60f46510f7f1f094776d90321caa4d97d20ef38871bef4bd3",
"sha256:8bbffbd37fbeb9747a0241fdfde5ae99d4531ad1d1a41ccaea62100e15a5814c"
"sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e",
"sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7",
"sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"
],
"index": "pypi",
"version": "==3.10.0.1"
"version": "==3.10.0.2"
},
"uvicorn": {
"extras": [
@ -505,41 +573,33 @@
},
"websockets": {
"hashes": [
"sha256:0dd4eb8e0bbf365d6f652711ce21b8fd2b596f873d32aabb0fbb53ec604418cc",
"sha256:1d0971cc7251aeff955aa742ec541ee8aaea4bb2ebf0245748fbec62f744a37e",
"sha256:1d6b4fddb12ab9adf87b843cd4316c4bd602db8d5efd2fb83147f0458fe85135",
"sha256:230a3506df6b5f446fed2398e58dcaafdff12d67fe1397dff196411a9e820d02",
"sha256:276d2339ebf0df4f45df453923ebd2270b87900eda5dfd4a6b0cfa15f82111c3",
"sha256:2cf04601633a4ec176b9cc3d3e73789c037641001dbfaf7c411f89cd3e04fcaf",
"sha256:3ddff38894c7857c476feb3538dd847514379d6dc844961dc99f04b0384b1b1b",
"sha256:48c222feb3ced18f3dc61168ca18952a22fb88e5eb8902d2bf1b50faefdc34a2",
"sha256:51d04df04ed9d08077d10ccbe21e6805791b78eac49d16d30a1f1fe2e44ba0af",
"sha256:597c28f3aa7a09e8c070a86b03107094ee5cdafcc0d55f2f2eac92faac8dc67d",
"sha256:5c8f0d82ea2468282e08b0cf5307f3ad022290ed50c45d5cb7767957ca782880",
"sha256:7189e51955f9268b2bdd6cc537e0faa06f8fffda7fb386e5922c6391de51b077",
"sha256:7df3596838b2a0c07c6f6d67752c53859a54993d4f062689fdf547cb56d0f84f",
"sha256:826ccf85d4514609219725ba4a7abd569228c2c9f1968e8be05be366f68291ec",
"sha256:836d14eb53b500fd92bd5db2fc5894f7c72b634f9c2a28f546f75967503d8e25",
"sha256:85db8090ba94e22d964498a47fdd933b8875a1add6ebc514c7ac8703eb97bbf0",
"sha256:85e701a6c316b7067f1e8675c638036a796fe5116783a4c932e7eb8e305a3ffe",
"sha256:900589e19200be76dd7cbaa95e9771605b5ce3f62512d039fb3bc5da9014912a",
"sha256:9147868bb0cc01e6846606cd65cbf9c58598f187b96d14dd1ca17338b08793bb",
"sha256:9e7fdc775fe7403dbd8bc883ba59576a6232eac96dacb56512daacf7af5d618d",
"sha256:ab5ee15d3462198c794c49ccd31773d8a2b8c17d622aa184f669d2b98c2f0857",
"sha256:ad893d889bc700a5835e0a95a3e4f2c39e91577ab232a3dc03c262a0f8fc4b5c",
"sha256:b2e71c4670ebe1067fa8632f0d081e47254ee2d3d409de54168b43b0ba9147e0",
"sha256:b43b13e5622c5a53ab12f3272e6f42f1ce37cd5b6684b2676cb365403295cd40",
"sha256:b4ad84b156cf50529b8ac5cc1638c2cf8680490e3fccb6121316c8c02620a2e4",
"sha256:be5fd35e99970518547edc906efab29afd392319f020c3c58b0e1a158e16ed20",
"sha256:caa68c95bc1776d3521f81eeb4d5b9438be92514ec2a79fececda814099c8314",
"sha256:d144b350045c53c8ff09aa1cfa955012dd32f00c7e0862c199edcabb1a8b32da",
"sha256:d2c2d9b24d3c65b5a02cac12cbb4e4194e590314519ed49db2f67ef561c3cf58",
"sha256:e9e5fd6dbdf95d99bc03732ded1fc8ef22ebbc05999ac7e0c7bf57fe6e4e5ae2",
"sha256:ebf459a1c069f9866d8569439c06193c586e72c9330db1390af7c6a0a32c4afd",
"sha256:f31722f1c033c198aa4a39a01905951c00bd1c74f922e8afc1b1c62adbcdd56a",
"sha256:f68c352a68e5fdf1e97288d5cec9296664c590c25932a8476224124aaf90dbcd"
"sha256:01db0ecd1a0ca6702d02a5ed40413e18b7d22f94afb3bbe0d323bac86c42c1c8",
"sha256:085bb8a6e780d30eaa1ba48ac7f3a6707f925edea787cfb761ce5a39e77ac09b",
"sha256:1ac35426fe3e7d3d0fac3d63c8965c76ed67a8fd713937be072bf0ce22808539",
"sha256:1f6b814cff6aadc4288297cb3a248614829c6e4ff5556593c44a115e9dd49939",
"sha256:2a43072e434c041a99f2e1eb9b692df0232a38c37c61d00e9f24db79474329e4",
"sha256:5b2600e01c7ca6f840c42c747ffbe0254f319594ed108db847eb3d75f4aacb80",
"sha256:62160772314920397f9d219147f958b33fa27a12c662d4455c9ccbba9a07e474",
"sha256:706e200fc7f03bed99ad0574cd1ea8b0951477dd18cc978ccb190683c69dba76",
"sha256:71358c7816e2762f3e4af3adf0040f268e219f5a38cb3487a9d0fc2e554fef6a",
"sha256:7d2e12e4f901f1bc062dfdf91831712c4106ed18a9a4cdb65e2e5f502124ca37",
"sha256:7f79f02c7f9a8320aff7d3321cd1c7e3a7dbc15d922ac996cca827301ee75238",
"sha256:82b17524b1ce6ae7f7dd93e4d18e9b9474071e28b65dbf1dfe9b5767778db379",
"sha256:82bd921885231f4a30d9bc550552495b3fc36b1235add6d374e7c65c3babd805",
"sha256:8bbf8660c3f833ddc8b1afab90213f2e672a9ddac6eecb3cde968e6b2807c1c7",
"sha256:9a4d889162bd48588e80950e07fa5e039eee9deb76a58092e8c3ece96d7ef537",
"sha256:b4ade7569b6fd17912452f9c3757d96f8e4044016b6d22b3b8391e641ca50456",
"sha256:b8176deb6be540a46695960a765a77c28ac8b2e3ef2ec95d50a4f5df901edb1c",
"sha256:c4fc9a1d242317892590abe5b61a9127f1a61740477bfb121743f290b8054002",
"sha256:c5880442f5fc268f1ef6d37b2c152c114deccca73f48e3a8c48004d2f16f4567",
"sha256:cd8c6f2ec24aedace251017bc7a414525171d4e6578f914acab9349362def4da",
"sha256:d67646ddd17a86117ae21c27005d83c1895c0cef5d7be548b7549646372f868a",
"sha256:e42a1f1e03437b017af341e9bbfdc09252cd48ef32a8c3c3ead769eab3b17368",
"sha256:eb282127e9c136f860c6068a4fba5756eb25e755baffb5940b6f1eae071928b2",
"sha256:fe83b3ec9ef34063d86dfe1029160a85f24a5a94271036e5714a57acfdd089a1",
"sha256:ff59c6bdb87b31f7e2d596f09353d5a38c8c8ff571b0e2238e8ee2d55ad68465"
],
"version": "==9.1"
"version": "==10.0"
},
"zipp": {
"hashes": [
@ -707,11 +767,11 @@
},
"pluggy": {
"hashes": [
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
"sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159",
"sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.13.1"
"markers": "python_version >= '3.6'",
"version": "==1.0.0"
},
"py": {
"hashes": [
@ -731,11 +791,11 @@
},
"pytest": {
"hashes": [
"sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b",
"sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"
"sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89",
"sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"
],
"index": "pypi",
"version": "==6.2.4"
"version": "==6.2.5"
},
"pytest-cov": {
"hashes": [
@ -837,12 +897,12 @@
},
"typing-extensions": {
"hashes": [
"sha256:045dd532231acfa03628df5e0c66dba64e2cc8fc8b844538d4ad6d5dd6cb82dc",
"sha256:83af6730a045fda60f46510f7f1f094776d90321caa4d97d20ef38871bef4bd3",
"sha256:8bbffbd37fbeb9747a0241fdfde5ae99d4531ad1d1a41ccaea62100e15a5814c"
"sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e",
"sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7",
"sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"
],
"index": "pypi",
"version": "==3.10.0.1"
"version": "==3.10.0.2"
},
"zipp": {
"hashes": [

View File

@ -65,7 +65,7 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
register_routes(app)
# register_commands(app)
register_async_tasks(app)
# register_exception_handlers(app)
register_exception_handlers(app)
return app
@ -96,9 +96,14 @@ def register_routes(app: FastAPI) -> None:
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
ext_route = getattr(ext_module, f"{ext.code}_ext")
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
for s in ext_statics:
app.mount(s["path"], s["app"], s["name"])
if hasattr(ext_module, f"{ext.code}_start"):
ext_start_func = getattr(ext_module, f"{ext.code}_start")
ext_start_func()
if hasattr(ext_module, f"{ext.code}_static_files"):
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
for s in ext_statics:
app.mount(s["path"], s["app"], s["name"])
app.include_router(ext_route)
except Exception as e:
@ -145,11 +150,11 @@ def register_async_tasks(app):
async def stop_listeners():
pass
def register_exception_handlers(app):
@app.errorhandler(Exception)
def register_exception_handlers(app: FastAPI):
@app.exception_handler(Exception)
async def basic_error(request: Request, err):
print("handled error", traceback.format_exc())
etype, value, tb = sys.exc_info()
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, err, tb)
exc = traceback.format_exc()
return template_renderer().TemplateResponse("error.html", {"request": request, "err": err})

View File

@ -52,6 +52,7 @@ async def api_payments(wallet: WalletTypeInfo = Depends(get_key_type)):
class CreateInvoiceData(BaseModel):
out: Optional[bool] = True
amount: int = Query(None, ge=1)
memo: str = None
unit: Optional[str] = None
@ -60,6 +61,7 @@ class CreateInvoiceData(BaseModel):
lnurl_balance_check: Optional[str] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
bolt11: Optional[str] = None
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
if "description_hash" in data:
@ -169,17 +171,16 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
@core_app.post("/api/v1/payments", deprecated=True,
description="DEPRECATED. Use /api/v2/TBD and /api/v2/TBD instead",
status_code=HTTPStatus.CREATED)
async def api_payments_create(wallet: WalletTypeInfo = Depends(get_key_type), out: bool = True,
invoiceData: Optional[CreateInvoiceData] = Body(None),
bolt11: Optional[str] = Body(None)):
async def api_payments_create(wallet: WalletTypeInfo = Depends(get_key_type),
invoiceData: CreateInvoiceData = Body(...)):
if wallet.wallet_type < 0 or wallet.wallet_type > 2:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Key is invalid")
if out is True and wallet.wallet_type == 0:
if not bolt11:
if invoiceData.out is True and wallet.wallet_type == 0:
if not invoiceData.bolt11:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="BOLT11 string is invalid or not given")
return await api_payments_pay_invoice(bolt11, wallet.wallet) # admin key
return await api_payments_pay_invoice(invoiceData.bolt11, wallet.wallet) # admin key
return await api_payments_create_invoice(invoiceData, wallet.wallet) # invoice key
class CreateLNURLData(BaseModel):
@ -189,8 +190,9 @@ class CreateLNURLData(BaseModel):
comment: Optional[str] = None
description: Optional[str] = None
@core_app.post("/api/v1/payments/lnurl", dependencies=[Depends(WalletAdminKeyChecker())])
async def api_payments_pay_lnurl(data: CreateLNURLData):
@core_app.post("/api/v1/payments/lnurl")
async def api_payments_pay_lnurl(data: CreateLNURLData,
wallet: WalletTypeInfo = Depends(get_key_type)):
domain = urlparse(data.callback).netloc
async with httpx.AsyncClient() as client:
@ -220,13 +222,13 @@ async def api_payments_pay_lnurl(data: CreateLNURLData):
if invoice.amount_msat != data.amount:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} returned an invalid invoice. Expected {g().data['amount']} msat, got {invoice.amount_msat}."
detail=f"{domain} returned an invalid invoice. Expected {data['amount']} msat, got {invoice.amount_msat}."
)
if invoice.description_hash != g().data["description_hash"]:
if invoice.description_hash != data.description_hash:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} returned an invalid invoice. Expected description_hash == {g().data['description_hash']}, got {invoice.description_hash}."
detail=f"{domain} returned an invalid invoice. Expected description_hash == {data['description_hash']}, got {invoice.description_hash}."
)
@ -238,7 +240,7 @@ async def api_payments_pay_lnurl(data: CreateLNURLData):
extra["comment"] = data.comment
payment_hash = await pay_invoice(
wallet_id=g().wallet.id,
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,

View File

@ -96,6 +96,8 @@ async def get_key_type(r: Request,
await checker.__call__(r)
return WalletTypeInfo(0, checker.wallet)
except HTTPException as e:
if e.status_code == HTTPStatus.BAD_REQUEST:
raise
if e.status_code == HTTPStatus.UNAUTHORIZED:
pass
except:
@ -106,6 +108,8 @@ async def get_key_type(r: Request,
await checker.__call__(r)
return WalletTypeInfo(1, checker.wallet)
except HTTPException as e:
if e.status_code == HTTPStatus.BAD_REQUEST:
raise
if e.status_code == HTTPStatus.UNAUTHORIZED:
return WalletTypeInfo(2, None)
except:

View File

@ -1,17 +1,33 @@
from quart import Blueprint
import asyncio
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_lnticket")
lnticket_ext: Blueprint = Blueprint(
"lnticket", __name__, static_folder="static", template_folder="templates"
lnticket_ext: APIRouter = APIRouter(
prefix="/lnticket",
tags=["LNTicket"]
# "lnticket", __name__, static_folder="static", template_folder="templates"
)
def lnticket_renderer():
return template_renderer(
[
"lnbits/extensions/lnticket/templates",
]
)
from .views_api import * # noqa
from .views import * # noqa
from .tasks import register_listeners
from .tasks import wait_for_paid_invoices
from lnbits.tasks import record_async
lnticket_ext.record(record_async(register_listeners))
def lnticket_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@ -1,27 +1,24 @@
from lnbits.core.models import Wallet
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Tickets, Forms
from .models import CreateFormData, CreateTicketData, Tickets, Forms
import httpx
async def create_ticket(
payment_hash: str,
wallet: str,
form: str,
name: str,
email: str,
ltext: str,
sats: int,
data: CreateTicketData
) -> Tickets:
await db.execute(
"""
INSERT INTO lnticket.ticket (id, form, email, ltext, name, wallet, sats, paid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(payment_hash, form, email, ltext, name, wallet, sats, False),
(payment_hash, data.form, data.email, data.ltext, data.name, wallet, data.sats, False),
)
ticket = await get_ticket(payment_hash)
@ -103,13 +100,8 @@ async def delete_ticket(ticket_id: str) -> None:
async def create_form(
*,
wallet: str,
name: str,
webhook: Optional[str] = None,
description: str,
amount: int,
flatrate: int,
data: CreateFormData,
wallet: Wallet,
) -> Forms:
form_id = urlsafe_short_hash()
await db.execute(
@ -117,7 +109,7 @@ async def create_form(
INSERT INTO lnticket.form2 (id, wallet, name, webhook, description, flatrate, amount, amountmade)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(form_id, wallet, name, webhook, description, flatrate, amount, 0),
(form_id, wallet.id, wallet.name, data.webhook, data.description, data.flatrate, data.amount, 0),
)
form = await get_form(form_id)

View File

@ -1,10 +1,26 @@
from typing import Optional
from fastapi.param_functions import Query
from pydantic import BaseModel
class CreateFormData(BaseModel):
name: str = Query(...)
webhook: str = Query(None)
description: str = Query(..., min_length=0)
amount: int = Query(..., ge=0)
flatrate: int = Query(...)
class CreateTicketData(BaseModel):
form: str = Query(...)
name: str = Query(...)
email: str = Query("")
ltext: str = Query(...)
sats: int = Query(..., ge=0)
class Forms(BaseModel):
id: str
wallet: str
name: str
webhook: str
webhook: Optional[str]
description: str
amount: int
flatrate: int

View File

@ -1,23 +1,17 @@
import json
import trio # type: ignore
import asyncio
from lnbits.core.models import Payment
from lnbits.core.crud import create_payment
from lnbits.core import db as core_db
from lnbits.tasks import register_invoice_listener, internal_invoice_paid
from lnbits.helpers import urlsafe_short_hash
from lnbits.tasks import register_invoice_listener
from .crud import get_ticket, set_ticket_paid
async def register_listeners():
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
register_invoice_listener(invoice_paid_chan_send)
await wait_for_paid_invoices(invoice_paid_chan_recv)
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async for payment in invoice_paid_chan:
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)

View File

@ -337,7 +337,7 @@
LNbits.api
.request(
'GET',
'/lnticket/api/v1/tickets?all_wallets',
'/lnticket/api/v1/tickets?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
@ -382,7 +382,7 @@
LNbits.api
.request(
'GET',
'/lnticket/api/v1/forms?all_wallets',
'/lnticket/api/v1/forms?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {

View File

@ -1,32 +1,40 @@
from quart import g, abort, render_template
from fastapi.param_functions import Depends
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.core.crud import get_wallet
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.decorators import check_user_exists
from http import HTTPStatus
from . import lnticket_ext
from . import lnticket_ext, lnticket_renderer
from .crud import get_form
from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
@lnticket_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index(request: Request):
return await templates.TemplateResponse("lnticket/index.html", {"request": request,"user":g.user})
@lnticket_ext.get("/", response_class=HTMLResponse)
# not needed as we automatically get the user with the given ID
# If no user with this ID is found, an error is raised
# @validate_uuids(["usr"], required=True)
# @check_user_exists()
async def index(request: Request, user: User = Depends(check_user_exists)):
return lnticket_renderer().TemplateResponse("lnticket/index.html", {"request": request,"user": user.dict()})
@lnticket_ext.route("/<form_id>")
@lnticket_ext.get("/{form_id}")
async def display(request: Request, form_id):
form = await get_form(form_id)
if not form:
abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.")
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="LNTicket does not exist."
)
# abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.")
wallet = await get_wallet(form.wallet)
return await templates.TemplateResponse(
return lnticket_renderer().TemplateResponse(
"lnticket/display.html",
{"request": request,
"form_id":form.id,

View File

@ -1,15 +1,19 @@
from lnbits.extensions.lnticket.models import CreateFormData, CreateTicketData
import re
from quart import g, jsonify, request
from http import HTTPStatus
from typing import List
from fastapi import Query
from fastapi.params import Depends
from fastapi import FastAPI, Query
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from starlette.exceptions import HTTPException
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse # type: ignore
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.services import create_invoice, check_invoice_status
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.decorators import WalletTypeInfo, get_key_type
from . import lnticket_ext
from .crud import (
@ -30,29 +34,17 @@ from .crud import (
@lnticket_ext.get("/api/v1/forms")
@api_check_wallet_key("invoice")
async def api_forms():
wallet_ids = [g.wallet.id]
async def api_forms_get(r: Request, all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)):
wallet_ids = [wallet.wallet.id]
if "all_wallets" in request.args:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return (
[form._asdict() for form in await get_forms(wallet_ids)],
HTTPStatus.OK,
)
return [form.dict() for form in await get_forms(wallet_ids)]
class CreateData(BaseModel):
wallet: str = Query(...)
name: str = Query(...)
webhook: str = Query(None)
description: str = Query(..., min_length=0)
amount: int = Query(..., ge=0)
flatrate: int = Query(...)
@lnticket_ext.post("/api/v1/forms")
@lnticket_ext.post("/api/v1/forms", status_code=HTTPStatus.CREATED)
@lnticket_ext.put("/api/v1/forms/{form_id}")
@api_check_wallet_key("invoice")
# @api_check_wallet_key("invoice")
# @api_validate_post_request(
# schema={
# "wallet": {"type": "string", "empty": False, "required": True},
@ -63,62 +55,69 @@ class CreateData(BaseModel):
# "flatrate": {"type": "integer", "required": True},
# }
# )
async def api_form_create(data: CreateData, form_id=None):
async def api_form_create(data: CreateFormData, form_id=None, wallet: WalletTypeInfo = Depends(get_key_type)):
if form_id:
form = await get_form(form_id)
if not form:
return {"message": "Form does not exist."}, HTTPStatus.NOT_FOUND
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Form does not exist."
)
# return {"message": "Form does not exist."}, HTTPStatus.NOT_FOUND
if form.wallet != g.wallet.id:
return jsonify{"message": "Not your form."}, HTTPStatus.FORBIDDEN
if form.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"Not your form."
)
# return {"message": "Not your form."}, HTTPStatus.FORBIDDEN
form = await update_form(form_id, **data)
else:
form = await create_form(**data)
return form._asdict(), HTTPStatus.CREATED
form = await create_form(data, wallet.wallet)
return form.dict()
@lnticket_ext.delete("/api/v1/forms/{form_id}")
@api_check_wallet_key("invoice")
async def api_form_delete(form_id):
# @api_check_wallet_key("invoice")
async def api_form_delete(form_id, wallet: WalletTypeInfo = Depends(get_key_type)):
form = await get_form(form_id)
if not form:
return {"message": "Form does not exist."}, HTTPStatus.NOT_FOUND
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Form does not exist."
)
# return {"message": "Form does not exist."}, HTTPStatus.NOT_FOUND
if form.wallet != g.wallet.id:
return {"message": "Not your form."}, HTTPStatus.FORBIDDEN
if form.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"Not your form."
)
# return {"message": "Not your form."}, HTTPStatus.FORBIDDEN
await delete_form(form_id)
return "", HTTPStatus.NO_CONTENT
# return "", HTTPStatus.NO_CONTENT
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
#########tickets##########
@lnticket_ext.get("/api/v1/tickets")
@api_check_wallet_key("invoice")
async def api_tickets(all_wallets: bool = Query(None)):
wallet_ids = [g.wallet.id]
# @api_check_wallet_key("invoice")
async def api_tickets(all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return (
[form._asdict() for form in await get_tickets(wallet_ids)],
HTTPStatus.OK,
)
return [form.dict() for form in await get_tickets(wallet_ids)]
class CreateTicketData(BaseModel):
form: str = Query(...)
name: str = Query(...)
email: str = Query("")
ltext: str = Query(...)
sats: int = Query(..., ge=0)
@lnticket_ext.post("/api/v1/tickets/{form_id}")
@lnticket_ext.post("/api/v1/tickets/{form_id}", status_code=HTTPStatus.CREATED)
# @api_validate_post_request(
# schema={
# "form": {"type": "string", "empty": False, "required": True},
@ -131,66 +130,86 @@ class CreateTicketData(BaseModel):
async def api_ticket_make_ticket(data: CreateTicketData, form_id):
form = await get_form(form_id)
if not form:
return {"message": "LNTicket does not exist."}, HTTPStatus.NOT_FOUND
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"LNTicket does not exist."
)
# return {"message": "LNTicket does not exist."}, HTTPStatus.NOT_FOUND
nwords = len(re.split(r"\s+", data["ltext"]))
sats = data["sats"]
nwords = len(re.split(r"\s+", data.ltext))
try:
payment_hash, payment_request = await create_invoice(
wallet_id=form.wallet,
amount=sats,
amount=data.sats,
memo=f"ticket with {nwords} words on {form_id}",
extra={"tag": "lnticket"},
)
except Exception as e:
return {"message": str(e)}, HTTPStatus.INTERNAL_SERVER_ERROR
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(e)
)
# return {"message": str(e)}, HTTPStatus.INTERNAL_SERVER_ERROR
ticket = await create_ticket(
payment_hash=payment_hash, wallet=form.wallet, **data
payment_hash=payment_hash, wallet=form.wallet, data=data
)
if not ticket:
return (
{"message": "LNTicket could not be fetched."},
HTTPStatus.NOT_FOUND,
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="LNTicket could not be fetched."
)
# return (
# {"message": "LNTicket could not be fetched."},
# HTTPStatus.NOT_FOUND,
# )
return
{"payment_hash": payment_hash, "payment_request": payment_request},
HTTPStatus.OK
return {
"payment_hash": payment_hash,
"payment_request": payment_request
}
@lnticket_ext.get("/api/v1/tickets/{payment_hash}")
@lnticket_ext.get("/api/v1/tickets/{payment_hash}", status_code=HTTPStatus.OK)
async def api_ticket_send_ticket(payment_hash):
ticket = await get_ticket(payment_hash)
try:
status = await check_invoice_status(ticket.wallet, payment_hash)
is_paid = not status.pending
except Exception:
return {"paid": False}, HTTPStatus.OK
return {"paid": False}
if is_paid:
wallet = await get_wallet(ticket.wallet)
payment = await wallet.get_payment(payment_hash)
await payment.set_pending(False)
ticket = await set_ticket_paid(payment_hash=payment_hash)
return {"paid": True}, HTTPStatus.OK
return {"paid": True}
return {"paid": False}, HTTPStatus.OK
return {"paid": False}
@lnticket_ext.delete("/api/v1/tickets/{ticket_id}")
@api_check_wallet_key("invoice")
async def api_ticket_delete(ticket_id):
# @api_check_wallet_key("invoice")
async def api_ticket_delete(ticket_id, wallet: WalletTypeInfo = Depends(get_key_type)):
ticket = await get_ticket(ticket_id)
if not ticket:
return {"message": "Paywall does not exist."}, HTTPStatus.NOT_FOUND
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"LNTicket does not exist."
)
# return {"message": "Paywall does not exist."}, HTTPStatus.NOT_FOUND
if ticket.wallet != g.wallet.id:
return {"message": "Not your ticket."}, HTTPStatus.FORBIDDEN
if ticket.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Not your ticket."
)
# return {"message": "Not your ticket."}, HTTPStatus.FORBIDDEN
await delete_ticket(ticket_id)
return "", HTTPStatus.NO_CONTENT
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
# return ""

View File

@ -1,4 +1,5 @@
import hashlib
from lnbits.extensions.offlineshop.models import Item
from fastapi.params import Query
from starlette.requests import Request
@ -13,8 +14,8 @@ from .crud import get_shop, get_item
@offlineshop_ext.get("/lnurl/{item_id}", name="offlineshop.lnurl_response")
async def lnurl_response(item_id: int = Query(...)):
item = await get_item(item_id)
async def lnurl_response(req: Request, item_id: int = Query(...)):
item = await get_item(item_id) # type: Item
if not item:
return {"status": "ERROR", "reason": "Item not found."}
@ -28,7 +29,7 @@ async def lnurl_response(item_id: int = Query(...)):
) * 1000
resp = LnurlPayResponse(
callback=url_for("offlineshop.lnurl_callback", item_id=item.id, _external=True),
callback=req.url_for("offlineshop.lnurl_callback", item_id=item.id),
min_sendable=price_msat,
max_sendable=price_msat,
metadata=await item.lnurlpay_metadata(),
@ -37,9 +38,9 @@ async def lnurl_response(item_id: int = Query(...)):
return resp.dict()
@offlineshop_ext.get("/lnurl/cb/<item_id>")
@offlineshop_ext.get("/lnurl/cb/{item_id}", name="offlineshop.lnurl_callback")
async def lnurl_callback(request: Request, item_id: int):
item = await get_item(item_id)
item = await get_item(item_id) # type: Item
if not item:
return {"status": "ERROR", "reason": "Couldn't find item."}
@ -52,7 +53,7 @@ async def lnurl_callback(request: Request, item_id: int):
min = price * 995
max = price * 1010
amount_received = int(request.args.get("amount") or 0)
amount_received = int(request.query_params.get("amount") or 0)
if amount_received < min:
return LnurlErrorResponse(
reason=f"Amount {amount_received} is smaller than minimum {min}."

View File

@ -14,7 +14,8 @@ from .helpers import totp
shop_counters: Dict = {}
class ShopCounter(BaseModel):
class ShopCounter():
wordlist: List[str]
fulfilled_payments: OrderedDict
counter: int
@ -88,6 +89,11 @@ class Item(BaseModel):
price: int
unit: str
def lnurl(self, req: Request) -> str:
return lnurl_encode(
req.url_for("offlineshop.lnurl_response", item_id=self.id)
)
def values(self, req: Request):
values = self.dict()
values["lnurl"] = lnurl_encode(

View File

@ -1,8 +1,9 @@
import time
from datetime import datetime
from http import HTTPStatus
from typing import List
from fastapi.params import Depends
from fastapi.params import Depends, Query
from starlette.responses import HTMLResponse
from lnbits.decorators import check_user_exists
@ -10,6 +11,7 @@ from lnbits.core.models import Payment, User
from lnbits.core.crud import get_standalone_payment
from . import offlineshop_ext, offlineshop_renderer
from .models import Item
from .crud import get_item, get_shop
from fastapi import Request, HTTPException
@ -20,14 +22,14 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
@offlineshop_ext.get("/print", response_class=HTMLResponse)
async def print_qr_codes(request: Request):
async def print_qr_codes(request: Request, items: List[int] = None):
items = []
for item_id in request.args.get("items").split(","):
item = await get_item(item_id)
for item_id in request.query_params.get("items").split(","):
item = await get_item(item_id) # type: Item
if item:
items.append(
{
"lnurl": item.lnurl,
"lnurl": item.lnurl(request),
"name": item.name,
"price": f"{item.price} {item.unit}",
}
@ -36,8 +38,9 @@ async def print_qr_codes(request: Request):
return offlineshop_renderer().TemplateResponse("offlineshop/print.html", {"request": request,"items":items})
@offlineshop_ext.get("/confirmation")
async def confirmation_code(p: str):
@offlineshop_ext.get("/confirmation/{p}", name="offlineshop.confirmation_code",
response_class=HTMLResponse)
async def confirmation_code(p: str = Query(...)):
style = "<style>* { font-size: 100px}</style>"
payment_hash = p

View File

@ -1,51 +1,48 @@
aiofiles==0.6.0
async-generator==1.10
attrs==20.3.0
aiofiles==0.7.0
anyio==3.3.1
asgiref==3.4.1
asyncio==3.4.3
attrs==21.2.0
bech32==1.2.0
bitstring==3.1.7
blinker==1.4
brotli==1.0.9
cerberus==1.3.3
certifi==2020.12.5
click==7.1.2
ecdsa==0.16.1
embit==0.2.1
environs==9.3.2
bitstring==3.1.9
cerberus==1.3.4
certifi==2021.5.30
charset-normalizer==2.0.6
click==8.0.1
ecdsa==0.17.0
embit==0.4.9
environs==9.3.3
fastapi==0.68.1
h11==0.12.0
h2==4.0.0
hpack==4.0.0
httpcore==0.12.3
httpx==0.17.1
hypercorn==0.11.2
hyperframe==6.0.0
idna==3.1
itsdangerous==1.1.0
jinja2==2.11.3
httpcore==0.13.7
httptools==0.2.0
httpx==0.19.0
idna==3.2
importlib-metadata==4.8.1
jinja2==3.0.1
lnurl==0.3.6
markupsafe==1.1.1
marshmallow==3.11.1
markupsafe==2.0.1
marshmallow==3.13.0
outcome==1.1.0
priority==1.3.0
pydantic==1.8.1
pyngrok==5.0.5
pypng==0.0.20
psycopg2-binary==2.9.1
pydantic==1.8.2
pypng==0.0.21
pyqrcode==1.2.1
pyscss==1.3.7
python-dotenv==0.17.0
quart==0.14.1
quart-compress==0.2.1
quart-cors==0.4.0
quart-trio==0.7.0
python-dotenv==0.19.0
pyyaml==5.4.1
represent==1.6.0.post0
rfc3986==1.4.0
rfc3986==1.5.0
shortuuid==1.0.1
six==1.15.0
six==1.16.0
sniffio==1.2.0
sortedcontainers==2.3.0
sqlalchemy==1.3.23
sqlalchemy-aio==0.16.0
toml==0.10.2
trio==0.16.0
typing-extensions==3.7.4.3
werkzeug==1.0.1
wsproto==1.0.0
sse-starlette==0.6.2
starlette==0.14.2
typing-extensions==3.10.0.2
uvicorn==0.15.0
uvloop==0.16.0
watchgod==0.7
websockets==10.0
zipp==3.5.0