diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..841be02 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +/venv +/env/ +/.env +/test.py +/logs +/.vscode +/.idea +__pycache__ +/nodes.conf +/nodes.txt diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4b02bc0 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: [Someguy123] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +# patreon: # Replace with a single Patreon username +# open_collective: # Replace with a single Open Collective username +# ko_fi: # Replace with a single Ko-fi username +# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +# liberapay: # Replace with a single Liberapay username +# issuehunt: # Replace with a single IssueHunt username +# otechie: # Replace with a single Otechie username +custom: ['https://wallet.hive.blog/~witnesses', 'https://www.privex.io'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.gitignore b/.gitignore index ae810a1..9185972 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ -venv/ -.vscode +/venv/ +/env/ +/.vscode/ +/.idea/ +/.env +__pycache__ test.py -nodes.txt \ No newline at end of file +nodes.txt +nodes.conf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..20e3525 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.8 +VOLUME /app +WORKDIR /app + +RUN pip3 install -U pipenv wheel pip && \ + curl -fsS https://cdn.privex.io/github/shell-core/install.sh | bash >/dev/null + +COPY Pipfile Pipfile.lock /app/ + +RUN pipenv install --ignore-pipfile + +COPY . /app + +ENTRYPOINT [ "/app/run.sh" ] diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..94cb048 --- /dev/null +++ b/Pipfile @@ -0,0 +1,19 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +nest-asyncio = "*" +jupyter = "*" + +[packages] +httpx = "*" +attrs = "*" +colorama = "*" +privex-helpers = "*" +python-dateutil = "*" +python-dotenv = "*" + +[requires] +python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..348cb32 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,1573 @@ +{ + "_meta": { + "hash": { + "sha256": "22790183e40903c3d237db3c2287f7538ef0a402e79d36b22e7bb6fd0fd254bd" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.8" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "accesscontrol": { + "hashes": [ + "sha256:11d4a04ac4516666a4d20d5b4e52a857cd7599751e5f44b494cb7e5f473db4a9", + "sha256:14fb33f98618da65bc52c66c372084cf36e419321596e322327e1eb152c386a8", + "sha256:36f6c0f69dfffe68cb8ecf7f556f84465da6533f97e00d8ae4c395cd869de738", + "sha256:60d5f0e3644a10f846e2f18e1773c52fb8adbeeb2841fbb7844fe13f1937433b", + "sha256:980849a6a44877cd481ab707ff5f9ce8ecbae9d32873ad3da843267446c5a171", + "sha256:c76af61793fddd68844c99a9f0687e03fa89c90df18392a3f199a3868e605b8f", + "sha256:db04b69cac00dfb94b3db0320fc1fa8effa8be547ba29e4fb50605f13fb0db19" + ], + "version": "==4.2" + }, + "acquisition": { + "hashes": [ + "sha256:07fc07841ddbed9f27c95b2495956fedec0c4b4e9a3bf1742861f8f5ca263069", + "sha256:265bff224f01df1db59f4709ec8f34ac1744bf53b6c9d7bb9c336cf7bdaf02f9", + "sha256:440e25d89ad422352fa9f5f1f30d01528c0237ae59f71a7441e21ef5cb05a58f", + "sha256:4431efb4cf0c766d8d121f64ac97579569b491722e71a634b1be6cc231f7b3fa", + "sha256:7a91fdbe7f16b13dcec57fdb2cde390760a5534db0c7742a0c2605e298fa46a4", + "sha256:7ff9056f94c715215bacdd1a3d8fc58a3aa3221d65ee05c32911a40c6e0e6785", + "sha256:8fc32e5a6284b1b027ae93fceecdf65806d06600d4a79cb2b75360e13ade9ceb", + "sha256:a687ec005597ec2dd257c6fdd611d3a459e05e460f0f859599c4f4c00c9663af", + "sha256:cf09a19c76d3fe0db576980321ef14b841dc5ea5f80132c9bf6e54f00714b7e1", + "sha256:dcb318066c3f295b557f79a2e740302edb298f42d62123ffa6e328d79287218e" + ], + "version": "==4.6" + }, + "async-property": { + "hashes": [ + "sha256:53826fd45a67d7d6cca3d7abbc0e8ba951f7c7618c830021fbd3675979b0b67d", + "sha256:f1f105009a6216ed9a13031aa13632754ed8a5c2e329fb8f9f2082d83529eacd" + ], + "version": "==0.2.1" + }, + "attrs": { + "hashes": [ + "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", + "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + ], + "index": "pypi", + "version": "==19.3.0" + }, + "authencoding": { + "hashes": [ + "sha256:166e5b3fd122deb2d662fedaf767cdc262564b1356083a966edda45c39489020", + "sha256:f797189a3927b131f39721a9daae5563d56d7a1724dbf15a0efd8acb6b6acf6a" + ], + "version": "==4.1" + }, + "automat": { + "hashes": [ + "sha256:7979803c74610e11ef0c0d68a2942b152df52da55336e0c9d58daf1831cbdf33", + "sha256:b6feb6455337df834f6c9962d6ccf771515b7d939bca142b29c20c2376bc6111" + ], + "version": "==20.2.0" + }, + "beautifulsoup4": { + "hashes": [ + "sha256:73cc4d115b96f79c7d77c1c7f7a0a8d4c57860d1041df407dd1aae7f07a77fd7", + "sha256:a6237df3c32ccfaee4fd201c8f5f9d9df619b93121d01353a64a73ce8c6ef9a8", + "sha256:e718f2342e2e099b640a34ab782407b7b676f47ee272d6739e60b8ea23829f2c" + ], + "version": "==4.9.1" + }, + "btrees": { + "hashes": [ + "sha256:0ed62e790a114ebbf4d6677537687c5c29264210abe4467f49e9bd4c5d6f1170", + "sha256:1be6ee6d87e2093e09ab51dc56292bda88be722b1c41ce77bb9a7c10ef2ca808", + "sha256:2304588cd0079c6d25543dc7bdfcafbeba07f85a534c3177e207b2fd8b2f4ef3", + "sha256:245160e37f6102e0d1146fc9f811e57c3d5a5ec5e0b1961f5906e9972ff53d1d", + "sha256:2c16d24a39c8f2289b182a99d2717d435f862a6ff6d63081c6dd5b636b6ba456", + "sha256:2d6d5cdd0fe7d93eb8761c6dc3b25148c7c0e737b2987008c713ef15bf3bec64", + "sha256:2f019bda00e9758d62518011974ef7403351dc54b4235f8b8d51f1246fe23449", + "sha256:33dc7cef958c83454245ac170a95920592bea7d42eee35b9eda4d30933e930f4", + "sha256:436f616eb7b677279ff5124c3d3635f6177bea0a8811d820156be325985c7998", + "sha256:51eda1e8b59b2d30d38cc680cd586fff81766058557181c4c11f02130a8145c9", + "sha256:5a15db42ea3844148d9db229ef4e81d5d972cbd34f54d648a0f313cdf838ea21", + "sha256:66d96cb32dd279f04334f4823ba006458831c58c3b09b4e1e82071a29f0e5ca4", + "sha256:6dabf85a930337ebc379a873c8edc53cbf42e93bead7887463df080154d48567", + "sha256:6e3f59c2b22c7186beac13960481b396797701e9daa6e87bb7eac769555f818e", + "sha256:721f75a0808ed80cd3fbe7a24289956bd8b7553fefa3186505a88b7d895faea1", + "sha256:7518233bc5ff3c6d91dc22253e132678fe3ac6bc784c8abf5e23ee44b6b689e8", + "sha256:78db6b07499e76585eef30f96ef6d2240a6c65adbf7961bde6a75af5f314d03f", + "sha256:79a617e445863168b18f0aa0716d73534f73d69b122af8ee754a4f7367e20deb", + "sha256:7a27fd62622f9050a0ab68d0cfda56059c7216f828b7e11ed64237a6dcb1aad3", + "sha256:7ce4a5eb5c135bcb5c06b5bd1ca6fd7fd39d8631306182307ed8bc30d3033846", + "sha256:7d2e209882b4bc679072fad26121475a6afa0b301ab11276f0ffe5e1095098bc", + "sha256:8139ce67347cae9febb60ca3bcdf428d2799d70b01b3e2984f67c44dcd37d566", + "sha256:8a61cc6e9f27582f3b163ca8cdd736f48316215b947a5c9d88326a2fa13e0f4e", + "sha256:8d3157a4334458fc42a58b2d552071d4e3e2a23d89ac98c868bb43f0386049d6", + "sha256:975bf01b7494a580f272c40ae3b0b3961973ad4f5d237ba5d790163b151c8b69", + "sha256:9bb5ab6314c827bf1ef4898664e45f2f5cd2d69e6b1f221a975d90e71470b4cd", + "sha256:a531d4a71a1362fcd41dfb9dae20277f134f90b701c7e5ab8584a2a89619c14f", + "sha256:a9b10e24c3e54ae036cd00cf21ea7d8c76a64f0a9bd497404ef1090b8a5b899e", + "sha256:a9ccf59ed398592fea43c2c7428b35823e80269f075de058ef00234f6015a9d0", + "sha256:ab72103186da7d64f460f40823180f4bd2408b315f7f1cd564f4c10b0ee65982", + "sha256:b0ef13f42392af7d77ee32ec101d8f545b186a61f3415f0fcd72f440ff614e31", + "sha256:b50ebcb7c524c88867e87abfd4a958faa005ed13101fb976613cd2e67241d21b", + "sha256:b91e5d4495a6a5f7b2d7c61785cb670e1329e10cfc5a0e2c5a924bb4d1a65bc4", + "sha256:bfa8c2e95b67af53df9a4a9eea3485452a3242a3cab3f9fdb97952b2729c1202", + "sha256:c31ea09e88faf272bd1c730b8f38a8ca5cc38fd550fa7a48bf69a7bd753e8c96", + "sha256:c769f1f1b5360ce9263c710ec5c18ee8c29f84d7702fba1959fa48db515ac2b0", + "sha256:c7c9fae118fd137826366ebafe94225d2e3c3c01b37511226e7166aa03eb79ef", + "sha256:caf0d93e51bd21bfd7bd3535f177d4b94dcc3c3fe32e41ed2a02c7b1f0a3e3cd", + "sha256:d58cd77a5dda0f9ae1ebb2e20f6ae90b0a6c940afb5575c02af380c3def69d10", + "sha256:ebc7325cd03e46c74917852d47872c84523653458de4ce480f36c1faada0300e", + "sha256:fac25bb42d0b346ef0c4add013e7690133e4921c242ab8323a6680b83a55ea06" + ], + "version": "==4.7.2" + }, + "certifi": { + "hashes": [ + "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", + "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" + ], + "version": "==2020.4.5.1" + }, + "cffi": { + "hashes": [ + "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff", + "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b", + "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac", + "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0", + "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384", + "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26", + "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6", + "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b", + "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e", + "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd", + "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2", + "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66", + "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc", + "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8", + "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55", + "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4", + "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5", + "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d", + "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78", + "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa", + "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793", + "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f", + "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a", + "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f", + "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30", + "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f", + "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3", + "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c" + ], + "markers": "platform_python_implementation == 'CPython'", + "version": "==1.14.0" + }, + "chameleon": { + "hashes": [ + "sha256:5253e1a31ff523368734dd016248b661ab7b75c4fbc1755d089f7a2b26ddf6ef" + ], + "version": "==3.7.1" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "colorama": { + "hashes": [ + "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", + "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" + ], + "index": "pypi", + "version": "==0.4.3" + }, + "constantly": { + "hashes": [ + "sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35", + "sha256:dd2fa9d6b1a51a83f0d7dd76293d734046aa176e384bf6e33b7e44880eb37c5d" + ], + "version": "==15.1.0" + }, + "datetime": { + "hashes": [ + "sha256:371dba07417b929a4fa685c2f7a3eaa6a62d60c02947831f97d4df9a9e70dfd0", + "sha256:5cef605bab8259ff61281762cdf3290e459fbf0b4719951d5fab967d5f2ea0ea" + ], + "version": "==4.3" + }, + "documenttemplate": { + "hashes": [ + "sha256:c1773bd7df3668c7eb788b1879ebc263da33d4aeaf687d4b005035617bcfb1a3", + "sha256:e96c2f1ea9fcb92a42a6d98d6992642e4f32f732738a4c44ec97845149d3a74d" + ], + "version": "==3.2.2" + }, + "extensionclass": { + "hashes": [ + "sha256:0bf8ffae94677a7077d817402ebf631b048fb4da9d8ce523f130ab18276a8038", + "sha256:1b5851c09dd864c43959b686180611cc23d1fd91720aca73cc2eeabbd1ca3640", + "sha256:2dc436ade1c70326eb38e1d7d51a91a4331baf25a4c69c24be7facf28bad62fb", + "sha256:41ecd2b9d0eb90aa8541e5e9a2c4d3ce8ce9e3d844f1fd4c644108de47f0d1b6", + "sha256:6abb6c730b77cbb750527efab97ab4eaeae21dd3a4e5e81266a5b31176722506", + "sha256:97f0f91335e9986dca068881b6c9243ceed7cdb3781e26455dbd7dab90080654", + "sha256:a471c45aaf4661c0cee659eea6e9d9a59d96261f112177b7c92a5cdf75f44e36", + "sha256:ba89cce972effea0f1a108777a1161da31e2bb488721cc43140ba41959ad81ff", + "sha256:c83f1d5b20498a799364ca269f6a5ebc7145d1428620f444088669e23a7efaff", + "sha256:eb0fb6ab42da9914d798b022bc0cc388263fc5cf1209c3a4bd890cfe744f5b91", + "sha256:fa5eed90ca342dc7734b3a3d4ae817d53295b355e12b84ac544be4f9a87b62a6" + ], + "version": "==4.4" + }, + "h11": { + "hashes": [ + "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1", + "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1" + ], + "version": "==0.9.0" + }, + "h2": { + "hashes": [ + "sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5", + "sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14" + ], + "version": "==3.2.0" + }, + "hpack": { + "hashes": [ + "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89", + "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2" + ], + "version": "==3.0.0" + }, + "hstspreload": { + "hashes": [ + "sha256:088884adc70f0efdd3002c86290e2c25c097fa49286feea7120507534f9e8be5", + "sha256:5ff96b5df463afd665db2a6069db8a6ed9f2e791aa186200066f92b8d6989546" + ], + "version": "==2020.5.27" + }, + "httpcore": { + "hashes": [ + "sha256:9850fe97a166a794d7e920590d5ec49a05488884c9fc8b5dba8561effab0c2a0", + "sha256:ecc5949310d9dae4de64648a4ce529f86df1f232ce23dcfefe737c24d21dfbe9" + ], + "version": "==0.9.1" + }, + "httpx": { + "hashes": [ + "sha256:4387a6601839fb71f5aea57042e6b476f24ca118cc05c55f1e52ed8baaf0a45f", + "sha256:99055b98fb3dae18c86597d4f5f4315214924a87c609b53d3cb68492fbb45fcf" + ], + "index": "pypi", + "version": "==0.13.2" + }, + "hyperframe": { + "hashes": [ + "sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40", + "sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f" + ], + "version": "==5.2.0" + }, + "hyperlink": { + "hashes": [ + "sha256:4288e34705da077fada1111a24a0aa08bb1e76699c9ce49876af722441845654", + "sha256:ab4a308feb039b04f855a020a6eda3b18ca5a68e6d8f8c899cbe9e653721d04f" + ], + "version": "==19.0.0" + }, + "idna": { + "hashes": [ + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + ], + "version": "==2.9" + }, + "incremental": { + "hashes": [ + "sha256:717e12246dddf231a349175f48d74d93e2897244939173b01974ab6661406b9f", + "sha256:7b751696aaf36eebfab537e458929e194460051ccad279c72b755a167eebd4b3" + ], + "version": "==17.5.0" + }, + "multimapping": { + "hashes": [ + "sha256:381c4c8a1933a80fedf843a00c1fa91094a7ce7897aa179551f8c2d3ec5e85cb", + "sha256:a1480fd1e0dc97acc67f1ef75725fd0cd4eae618d29bc423deae24ea6fd1fa48" + ], + "version": "==4.1" + }, + "pastedeploy": { + "hashes": [ + "sha256:bc6578735a32c77435a3e0769426983d3df6dc53a7229290c495ec10aefb81a5", + "sha256:e7559878b6e92023041484be9bcb6d767cf4492fc3de7257a5dae76a7cc11a9b" + ], + "version": "==2.1.0" + }, + "persistence": { + "hashes": [ + "sha256:17b6065d2591117a7d4fbb91f20f15ef7401ad91d2cd78b8ea607fa5d30e7c4b", + "sha256:23a22b2e8a2175376da384d031cdd19a38e4588df62bbc208bddadef6bd714a9", + "sha256:2aeadc32b32aa4ed01b3466ec9790e9ab30fc1959d3599ba104e0abfeb8fd233", + "sha256:4a4a881e62546529ab1774e89b7390ee004c7e0b0b76143a8835ca8830c97f38", + "sha256:965d481645d45c4888c412f922801c531d2e3d9a9681795d53f3513e5ecfe474", + "sha256:995df6c3cf889e9818bc8759eafe0e12293fd3a324b125fffa6c20a878be42e0", + "sha256:a3a82f6bbeff8994447526432e100de8994d915012bde871ea02ff56480addef", + "sha256:fda4ade5c715a5fe9c3d379775dfb34f6c671d178dafb0062340107abef0b467" + ], + "version": "==3.0" + }, + "persistent": { + "hashes": [ + "sha256:00cb4bc51b39c93cf8fcf9227a8b43af6114de7f04a8bc7b7c81bbff6706404b", + "sha256:039900643c5afda5cc14e043a8a443c4d9a03a640f2008b5cd3864d585ecc48a", + "sha256:04679b35f3a6d7b75803e7cccf7532eae90799b3624a1597b95a4baf25aa6f05", + "sha256:1130795b850b2d94c6d42468deebdec5f0c725e7cd7690056cfba25940f98b5e", + "sha256:2148d5aabe8b65774a4060e49bc5f34395ed3b256bdf8a8d4359c98d6de6419d", + "sha256:2246a710314830c83d70e9d33a11ab88aa4d085e430ce5e8cbc9a4a7e1173053", + "sha256:25a3c6e476acf0e2363bbaaba1f84945d1f2d1d6223a0c964afdeb8b90cd4941", + "sha256:268f63de09a132fb1e40b355c436a53f07d7bcb20d1e52c822f1da7884b5dceb", + "sha256:28ae6ae103187622679f912af177c20b61d61552797a9693639ca01f067e6206", + "sha256:2c4dac4a11c3d210ec8cbbce3b497e0f2e688779d8973a38df427c36c041e35b", + "sha256:39556f6b54267ec130c9b2581c7f06c942663b152f04b46a042375be99ee3f86", + "sha256:3d292236e775996de4fa06e85f52e71e881e76ce5ff193574b4291707c5e5fc4", + "sha256:486fccca0c9439e620daacc309ff4c1f1bdeb56a23cd2a6e78bb9025f02accee", + "sha256:51e3c56ef370154e3a8a691d7c2d0ce32addfd6b30e214d7edd61ce4d0db911e", + "sha256:5272320f5885fe9cdf40e8afac8270ad035ce3e887a8f3e3d5d344ba30158a06", + "sha256:6426c8c6923f4cb90da68815484f24cd4aa4294826e67455cc1f5f16867171ea", + "sha256:6896d85b295e8d068b6282d93ae5b91b9a3f38d1530370cc478159992a5da958", + "sha256:69f21578ba2524166de9bccecbeeb915e8b4c42c0967dd0ec53369365b2287b0", + "sha256:7f3d76e873b5075b2ee5c50892669689e5f7f256d4051ed52e0366ab5fe55fc7", + "sha256:823970d6ae0dc0425dfb47ec750c5c3e1435ec03e20e04a1f26b278ba755619e", + "sha256:865bf1e98f442c280c84a321e9397ab01c918648c814a18504815107dc33375e", + "sha256:89ea2cadfc8894b0d2cb0094df463ed2781de36d51e9208c0e94a31b46dd8e7c", + "sha256:8a1ebf943f04e82f25ed1d8c5f4b131659189a13edeb879a3ba2dbedae8dcf08", + "sha256:90479df40af2e06de2c0f0b77aacc3829f753479f985146fda480819d504038d", + "sha256:94de7c20fea7870799082beac163978d1a9921b2fc1f538a3abd60ecab0442dd", + "sha256:9bd2ade52408efbb1da3415f6739ef53cc1dc04ac2729bab2f983cf0629e26cd", + "sha256:b2213a301d05820cb6124cc4a0448759101cca32c433905d2ed6e20cda2a63cb", + "sha256:b947ffee1ea4289b839b872ffeedb253c59c2d36cc988b1e15729a8362293401", + "sha256:ba24686c2a8b49fe65448a327e1c2d79f86770c1b8f6ec1acfc00dac0d35526c", + "sha256:bc47506de22521ba94be20b7a1ee856cbf09e27080cb027b3921c9478fb0a814", + "sha256:bf10af7dcabe93a9e96ec0523ef9bb2be6d5fa7e19f9e553abd36cf2d822f1d1", + "sha256:c40224a6ce8bdf7b4ff2bce1441f305f871c5fe46dabfa93d7b2fde66a3332ba", + "sha256:d1db98d1ee033041bf7162fd5880bc219e8e404282638692ea3b59677846acc4", + "sha256:d5daeb9d3a72202e796d90798d4ecadedfd8610eae2da9fdfbddfc8a0e34f498", + "sha256:d65f3fac6faa88048b13bca0978c5b0e5f267ab8ef22d7e8fde9322d4126490e", + "sha256:db9e8ee8354c3861ba25018e539ba058f2e2fa6dc4263aa9e4d0a87675df8394", + "sha256:ddabb5e09f0ede39602bbfb2ebcbbcf9dcb0504b1dae538cf322d67c43b40bdb", + "sha256:f6e0300f20c9349452180f0090ce3b99211e24bea58a6664b945d6322620234e", + "sha256:f83f54355a44cf8ec38c29ce47b378a8c70444e9a745581dbb13d201a24cb546", + "sha256:ff4a1ea5c9d5b93666ba1d9b04c2c5a576c23170c10683f6d54a28f744ad3a6a" + ], + "version": "==4.6.4" + }, + "privex-helpers": { + "hashes": [ + "sha256:c66c8e987b5edf008d4996518b6f7083dbe934bfe64e9f1dd9ebaa4727f52e06", + "sha256:db2c45008235f08f4c2d005599b538ff3cfac92b9f4dc224fedf0bbce0a05bb4" + ], + "index": "pypi", + "version": "==2.12.0" + }, + "privex-loghelper": { + "hashes": [ + "sha256:db9c63fa630746263d35fd10e086ea2230d87628ee802b1856a94dd525fd2a24", + "sha256:f783fb848e281d639f75ae240d0cf048f0ae2c860157e3dad2833b2392230f47" + ], + "version": "==1.0.6" + }, + "pycparser": { + "hashes": [ + "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", + "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" + ], + "version": "==2.20" + }, + "pyhamcrest": { + "hashes": [ + "sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316", + "sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29" + ], + "version": "==2.0.2" + }, + "python-dateutil": { + "hashes": [ + "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", + "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + ], + "index": "pypi", + "version": "==2.8.1" + }, + "python-dotenv": { + "hashes": [ + "sha256:25c0ff1a3e12f4bde8d592cc254ab075cfe734fc5dd989036716fd17ee7e5ec7", + "sha256:3b9909bc96b0edc6b01586e1eed05e71174ef4e04c71da5786370cebea53ad74" + ], + "index": "pypi", + "version": "==0.13.0" + }, + "python-gettext": { + "hashes": [ + "sha256:626b501a51ac892fc3460cf550e60dca121f544eaa46eb69c90ce4682fc7ec02" + ], + "version": "==4.0" + }, + "pytz": { + "hashes": [ + "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", + "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" + ], + "version": "==2020.1" + }, + "requests": { + "hashes": [ + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + ], + "index": "pypi", + "version": "==2.23.0" + }, + "requests-threads": { + "hashes": [ + "sha256:7740be27cc7b425019e2655f06b6c17ee459c3e7f63cb8c4400fea3a61e0b2f1", + "sha256:923ffece26c18a1d20e34c4f037c58c311f1e15b342a1485a84dd968f377efb0" + ], + "index": "pypi", + "version": "==0.1.1" + }, + "restrictedpython": { + "hashes": [ + "sha256:9bd69505147b0ff8c68f4ff5a275975a3ab66fc43cbf3b61a195650ed767cd4e", + "sha256:a080569bffdf53371ae3e754ab1732f43054b1bab904fc100f74ba68ac731abc" + ], + "version": "==5.0" + }, + "rfc3986": { + "hashes": [ + "sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d", + "sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50" + ], + "version": "==1.4.0" + }, + "roman": { + "hashes": [ + "sha256:b1aa46b531f896b9d3aa05ede8208d785626be16b02c0df57c971fcb9940ae63", + "sha256:e55ef65e7960089a89a4c7efe1006bd31b7c826240b6d17659ad1b34bbcfbad5" + ], + "version": "==3.2" + }, + "six": { + "hashes": [ + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + ], + "version": "==1.15.0" + }, + "sniffio": { + "hashes": [ + "sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5", + "sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21" + ], + "version": "==1.1.0" + }, + "soupsieve": { + "hashes": [ + "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", + "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" + ], + "version": "==2.0.1" + }, + "transaction": { + "hashes": [ + "sha256:3b0ad400cb7fa25f95d1516756c4c4557bb78890510f69393ad0bd15869eaa2d", + "sha256:e0397e7733124e23a670cd3eee4096c197b48f1e14730d7cc7da868389f016b7" + ], + "version": "==3.0.0" + }, + "twisted": { + "hashes": [ + "sha256:040eb6641125d2a9a09cf198ec7b83dd8858c6f51f6770325ed9959c00f5098f", + "sha256:147780b8caf21ba2aef3688628eaf13d7e7fe02a86747cd54bfaf2140538f042", + "sha256:158ddb80719a4813d292293ac44ba41d8b56555ed009d90994a278237ee63d2c", + "sha256:2182000d6ffc05d269e6c03bfcec8b57e20259ca1086180edaedec3f1e689292", + "sha256:25ffcf37944bdad4a99981bc74006d735a678d2b5c193781254fbbb6d69e3b22", + "sha256:3281d9ce889f7b21bdb73658e887141aa45a102baf3b2320eafcfba954fcefec", + "sha256:356e8d8dd3590e790e3dba4db139eb8a17aca64b46629c622e1b1597a4a92478", + "sha256:70952c56e4965b9f53b180daecf20a9595cf22b8d0935cd3bd664c90273c3ab2", + "sha256:7408c6635ee1b96587289283ebe90ee15dbf9614b05857b446055116bc822d29", + "sha256:7c547fd0215db9da8a1bc23182b309e84a232364cc26d829e9ee196ce840b114", + "sha256:894f6f3cfa57a15ea0d0714e4283913a5f2511dbd18653dd148eba53b3919797", + "sha256:94ac3d55a58c90e2075c5fe1853f2aa3892b73e3bf56395f743aefde8605eeaa", + "sha256:a58e61a2a01e5bcbe3b575c0099a2bcb8d70a75b1a087338e0c48dd6e01a5f15", + "sha256:c09c47ff9750a8e3aa60ad169c4b95006d455a29b80ad0901f031a103b2991cd", + "sha256:ca3a0b8c9110800e576d89b5337373e52018b41069bc879f12fa42b7eb2d0274", + "sha256:cd1dc5c85b58494138a3917752b54bb1daa0045d234b7c132c37a61d5483ebad", + "sha256:cdbc4c7f0cd7a2218b575844e970f05a1be1861c607b0e048c9bceca0c4d42f7", + "sha256:d267125cc0f1e8a0eed6319ba4ac7477da9b78a535601c49ecd20c875576433a", + "sha256:d72c55b5d56e176563b91d11952d13b01af8725c623e498db5507b6614fc1e10", + "sha256:d95803193561a243cb0401b0567c6b7987d3f2a67046770e1dccd1c9e49a9780", + "sha256:e92703bed0cc21d6cb5c61d66922b3b1564015ca8a51325bd164a5e33798d504", + "sha256:f058bd0168271de4dcdc39845b52dd0a4a2fecf5f1246335f13f5e96eaebb467", + "sha256:f3c19e5bd42bbe4bf345704ad7c326c74d3fd7a1b3844987853bef180be638d4" + ], + "index": "pypi", + "version": "==20.3.0" + }, + "urllib3": { + "hashes": [ + "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", + "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" + ], + "version": "==1.25.9" + }, + "waitress": { + "hashes": [ + "sha256:045b3efc3d97c93362173ab1dfc159b52cfa22b46c3334ffc805dbdbf0e4309e", + "sha256:77ff3f3226931a1d7d8624c5371de07c8e90c7e5d80c5cc660d72659aaf23f38" + ], + "version": "==1.4.3" + }, + "webob": { + "hashes": [ + "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b", + "sha256:aa3a917ed752ba3e0b242234b2a373f9c4e2a75d35291dcbe977649bd21fd108" + ], + "version": "==1.8.6" + }, + "webtest": { + "hashes": [ + "sha256:44ddfe99b5eca4cf07675e7222c81dd624d22f9a26035d2b93dc8862dc1153c6", + "sha256:aac168b5b2b4f200af4e35867cf316712210e3d5db81c1cbdff38722647bb087" + ], + "version": "==2.0.35" + }, + "wsgiproxy2": { + "hashes": [ + "sha256:6d94ff1b1142a5544571912a6745bdb654a854d1ee96a48f0656b1cb254ccb9f", + "sha256:cee2a64abc193cc96c7ff1208b709335e9a4d1316ce84b91501102166d814c9a" + ], + "version": "==0.4.6" + }, + "z3c.pt": { + "hashes": [ + "sha256:5d7f8b69e1243bbb4c4f9ddbb602ee28871a2f588b1775dd6edc2f2b51a112f7", + "sha256:87b6b60673ab3dd2ce0ff5dbac573cc349c10ed48fba14b3182f3b89a61be10f" + ], + "version": "==3.3.0" + }, + "zc.lockfile": { + "hashes": [ + "sha256:307ad78227e48be260e64896ec8886edc7eae22d8ec53e4d528ab5537a83203b", + "sha256:cc33599b549f0c8a248cb72f3bf32d77712de1ff7ee8814312eb6456b42c015f" + ], + "version": "==2.0" + }, + "zconfig": { + "hashes": [ + "sha256:572fb7e88b70feb3231f8e52210120154689d2a613d477cbc184a03c4789d072", + "sha256:a094e492ac31025f5b5a58b32a7c7f03e2e2899c8beb4c1601ea00653bf3ea68" + ], + "version": "==3.5.0" + }, + "zexceptions": { + "hashes": [ + "sha256:b827f701976342debec56f67e200e6e6203e765cf13e7e7a6bec120fda93f9cf", + "sha256:de786add37232c5d57936d7aad88a5fb0da06ab269b2fb031e0f0aced941f2d1" + ], + "version": "==4.1" + }, + "zodb": { + "hashes": [ + "sha256:20155942fa326e89ad8544225bafd74237af332ce9d7c7105a22318fe8269666", + "sha256:7b25ecb452033e1b26be30ec5a65ebb004044c9ed4312d62f94b0d0de8e185b5" + ], + "version": "==5.5.1" + }, + "zodbpickle": { + "hashes": [ + "sha256:07426bb81f7fee4b872e8298e2e560f5b7696fa43d9640d82c1c8dd2fb75979c", + "sha256:0c94b57b19afb8a64af489221173d7ae7331ea1085eac24a3b0e1a33d1cd62ea", + "sha256:13d85de2e050c025571906316e500076d0b7db66f8af716cf803e55b2f4bf116", + "sha256:14a3044959d98f5bab39c081a2974c1fc40bd8f241cc6c1d838dcd567ce6c8c1", + "sha256:159cef791efb1614ca827c43f90c667acc946df3ac2a223cf2be170299cbef73", + "sha256:1c436f2bc74ac8f932bdab0ea418f6faed5794bfc98c066ba57064b5b1247a79", + "sha256:1c4fd0bca3400124bdb7088fce037b837abf623fc3fe7372a259fba82f2d8e51", + "sha256:2075aa83989f502a21d0421cfd0940d723e7f444130fb559368f40df2e8f2ff5", + "sha256:20c072efb83fb640f7b290a41d4cdd012a75962f1424687c407e3e41539416af", + "sha256:2a1435ffd6b10ee84800f779d89d9f298cf13169f5054cc79a592e5d19a55ace", + "sha256:3071fe1cf5b801a5b7d6e9fdd08b86dcfe28a0a95b0377c4dc686966e6625ef0", + "sha256:3c481d1eea5d9c44dd084760bf487b0d9aec28fc5b8d72c0a93305db40971270", + "sha256:4bdfb9077d080c17fa45917ab807dc2a64b45ead15c8a05f9fab72605fda402a", + "sha256:5ed8373265d2465055cd2535f8ac278958361a0f6f5c01fbc96e614fbc173396", + "sha256:61a5351f5a18b45512a90297eefc7eaa4de7c0d2cc7c33a97ad41703dde1bac3", + "sha256:6475f47fc4e8300ba1ae6291521618f77dc4e625d376c0324d41430ef059a49c", + "sha256:6d91a079d4d71cbc57a289ffb66f535545bab675f8ba117426a415e66ddeb128", + "sha256:6fa56012000f855537f485bb5b63fdfcdb0979731e96a44f73671a9c41b65275", + "sha256:77ee3acfc4fa53f73a7d26a197c0e9a574417aa1fa5baa2dcffb0db64c7ef30c", + "sha256:8e9d6d7e9b6e422fa9fee3ebade138c487a11943e8a7c530ee1422592c4a85a5", + "sha256:927e799306598a1891c994a4e5fad9f6c7c7c8cab262dc76ed72b1f381b573d3", + "sha256:956d258a24999f9ec3058eb8f2cb83189a0eb28960657e33939d14b4d7641ef5", + "sha256:973e9fd9b476bb22bd30746054c69166b2b25498800a6e8011b527c1f63b3ea5", + "sha256:99508306afc15fa22265cfa8e6015a447c7ed9e4b2142a77a09037ba6b2fdac5", + "sha256:a0632c746f4633421f4535276d8843ebe6a3ef31421845dc80d529a73c3d9ba0", + "sha256:b0039c6d66bec36c1c45293f2b06f48bb8f3700470b53e41e26ba974a843730e", + "sha256:c041bc2f5b1724d01e81e5a08056cd784235a4e1bbd5dc0b471a1f5594522cfe", + "sha256:c04d3faa2bf4d5eb802617b30214976b648de8d31a848e130d0ae1081ebcee80", + "sha256:c5355599f298cca7afa3637f8279cb14a04ff335334dcfc6ef406450d65e29e6", + "sha256:c7479ddfccd5f3e6d2dd53074e03b111a3841990a439b819811e40ea379159d8", + "sha256:ca44c619e9a5f9090312f0c2ebd8fea35a971bedde117f6e431266bd7c4d432d", + "sha256:cfccdc2032f7e6558a2dab79035a23b6a8d81c1437b925777d9aafaf5b0b150d", + "sha256:d4b1664237527900f0df3a490687e7ae9cf307c5a98f30a7e6bafdcf975719ca", + "sha256:daae681a52c8243ce00ce0c713fab5a602ecd4a8dcc7ef88afb0afcda3f1a48b", + "sha256:dd6a89106803ccbb49673f52edce45af7d16a2c8c96364dc44ab290cd61f2932", + "sha256:e0d9930f46d41f086a763f40a12d014c46bbc5e19e728c8d66d7a06fef616739", + "sha256:e137b217ae0fe37154844c3484db93467c3ff09de5ea58bbbe357a9e31a327ab", + "sha256:e4d35648c879802ad1499d177da2f9f23c345ec19e4304d2377ad6ce7f2b6045", + "sha256:eb12c14cbd04c0c4c97fbfdc0014219f1680e2d16165410eada20b9dff0dc02d", + "sha256:eccd771516d9dbc902da803331cad83e7a1f0e605a703d0e057c3bfedb892d0c" + ], + "version": "==2.0.0" + }, + "zope": { + "hashes": [ + "sha256:c5c2a1778eb840c7b899217f26dd375563523ab3a50568c45229c2c40d91813a", + "sha256:df2b614cb571ab63630b6e940a4cda6e4e1ebda74afaa616f2990123f16b9285" + ], + "index": "pypi", + "version": "==4.4.2" + }, + "zope.annotation": { + "hashes": [ + "sha256:48a238aa77407fe2a593a6c3b14cce5c568e15ce772712fb0eacfe17ada6dd66", + "sha256:b40567cf24a65a4204b19af1c95723a8209cddc2abda3a62632c4953c776b185" + ], + "version": "==4.7.0" + }, + "zope.browser": { + "hashes": [ + "sha256:299d1043c8ac92d76c9c12940c9eeed2e09a11fdc762b0cafbc0f1d128657846", + "sha256:a755801937d82531a7ec1525339fc9a87f58cd642a0042b51b280ac31a067113" + ], + "version": "==2.3" + }, + "zope.browsermenu": { + "hashes": [ + "sha256:0ae541e9aa75b3cc36bb2db116a2a3979055655bb8ebc759cb8cde6358270c66", + "sha256:99f5c3bef9f4d43e9d65ff7d2c6b3de97352fc9d2de3cbbcc47fce631f6fbd0a" + ], + "version": "==4.4" + }, + "zope.browserpage": { + "hashes": [ + "sha256:58ed71e90ccbd49164f51cc85488cf1058420c09505675d4dfc0e8a6bb14099b", + "sha256:69a34e211d19e289d3aaea6d733b172d6acf0982ba46a357fb22c19ff0cdd84b" + ], + "version": "==4.4.0" + }, + "zope.browserresource": { + "hashes": [ + "sha256:07d37806a0b0462afc361c2089956f8c248c18de23394a329becc6baf400e615", + "sha256:91ef320c815e9ad6cc8043c389818506d305b50958e4b47cf4543946dac6a3c6" + ], + "version": "==4.4" + }, + "zope.cachedescriptors": { + "hashes": [ + "sha256:1f4d1a702f2ea3d177a1ffb404235551bb85560100ec88e6c98691734b1d194a", + "sha256:ebf5d6768a7ef0a9e59bdc8a5416ce0e0ae2df86817bdb3b352defc410c9bf6d" + ], + "version": "==4.3.1" + }, + "zope.component": { + "hashes": [ + "sha256:bfbe55d4a93e70a78b10edc3aad4de31bb8860919b7cbd8d66f717f7d7b279ac", + "sha256:d9c7c27673d787faff8a83797ce34d6ebcae26a370e25bddb465ac2182766aca" + ], + "version": "==4.6.1" + }, + "zope.configuration": { + "hashes": [ + "sha256:9f3f4b4c95cbde5a4bfc3c4ae557e527766e0bfe54d57976bac15726d78ae5ad", + "sha256:e9f02bac44405ad1526399d6574b91d792f9694f9c67df8b64e91fe10fcddb3c" + ], + "version": "==4.4.0" + }, + "zope.container": { + "hashes": [ + "sha256:0238c59d96f11249d98a62dd59d43a5b32fe220717a57b9fe640c80d18503b78", + "sha256:0bb82bb6f6c77644ed0a564e9a33ee43d94756b5c343fc31e28a40dff298583a", + "sha256:120cbebfc1e6f294ff87127b130400d356f92123847169d3723cd5cf4dd824a3", + "sha256:1df7772328dc4046ecd0500b4c075fc73615056771e48c80a642949003a95417", + "sha256:1e3eeec9ce05b70561e2665368d5e13a686856ce8a515e1bdbd8a9d14651cf58", + "sha256:28193624ac0d6fc2071aa2d43c8eb25e590ccb03b19777a0608f005908efdbc0", + "sha256:30d44f564a5c5b984f97a10084427f8dd6bbdaa59b88c08bca3fa4b0087b7d07", + "sha256:34235a97da07d98f23071a441436f74da57a0db123d35f091bc0e9863f3a18db", + "sha256:43280b65d2db4a79012650a9bbc443a9d50629ef9a359cce6088811b13a9041f", + "sha256:4e2dac6efebecbff9fe6ddb0a535870d9f9e7e3746f270ecd9e41050dcc1f7a8", + "sha256:6801c2ec8cf64ac6aa758d41e30e56b90e50dea568303f10e9a3c2c7be26d567", + "sha256:6ca635aa033b44bcb1f4a64a132a4417e42102f20da9506b98495d105d14acb8", + "sha256:6e1dd945408c52ec2fe47abd861f539a3ca9db551e6ec763cc177502164cbc75", + "sha256:73af2c5baf8dae2971120773236c84eccc3fee9f581826f6a513287d192a4c04", + "sha256:763321f1b93319d7d531309e063e4edcde3c1d529040a37e82a3374966669fae", + "sha256:7bf28bebf1ea471f88043648cd7920eba54e9365a667dd21b92abb0b636a6453", + "sha256:7fca64a28703166171f0bbb30f089c4f1acb4daf2a8968a6b55cc0aee6c65d0a", + "sha256:8d9aac544fc6292deb3cb5fe7510991235367fb5964ce391fa3a3c939d40f2e3", + "sha256:8ff6ac1b7c855687c4b48c34bcc39f7d0806b233c9c22f020fe26bb6f8ab539d", + "sha256:967b4ba0e8f83f6ecb19c28e52c5b649e3f8ca6ad3cef87706b88dca936a247d", + "sha256:977da5ce7c16a3e0ceb10987e184ecced4ef12c8caadad403d62dfd82a0e7b73", + "sha256:9a892cedd4a8b8925cf46da1324b63743ef4ae27194b2beba368969f8cd9b754", + "sha256:9e6c584629f3495505a690141ff3d1a89d26ad50a32ae02e6a1ff315acd60107", + "sha256:a1c8e14f8ed033a776f6fabee66a3d003bd680430cbf0ab69565e3864ba1adf6", + "sha256:a2d8fecb6ea7825be5058453d27e61b69edc4b5f0f4534e152f8b35497fde5d5", + "sha256:a322a19ff3b9cf642efe33e043e2f3b5c7ed3a2d21e4eacfc8801dea69ba215d", + "sha256:a34b9011e84c2a542548939e6e722f88bc88f65a0734ec2fb00c77a2dabeebb6", + "sha256:ad6d660a8d7b2a67eedbb5304c4880a297435b5e809c4e35349f8b7fdb54d5db", + "sha256:c1babfdbd25965e54c9df52f875d0615198823aa597dec81e7b8679dd8587e3d", + "sha256:c363e09e659f1a81d842cd90569d9cc5715f78b130f476077d8651592095dd19", + "sha256:c4520864292a8f723c355064ffe0df411ddd1447cc14b540fb650582dec3fe07", + "sha256:c81e48f825bc762feb94b3a5c180d68d3a26894211715e31d98818fff195d6f4", + "sha256:cc694abfe1f2b629da779afc03f5c5ba44407bcf603cfc40bb353c9148919f95", + "sha256:ced56f2120b431f95424870bd473a28425c896e6181dd6e4b7616e634c0861e9", + "sha256:d532c2896fea7b059357661a70f4c47b2e61ada287c813b6387ad6928d066025", + "sha256:d66a114dabfd068702656f874703fb970155afd6723f5c45d2a91f34949f8bcf", + "sha256:e2b4940f5bc0b6a3aa83ec8e4b82bc8cd69755a2f28c5f65665e3521498b867f", + "sha256:e3269fc1c47da945cde22691f00f1d4d3186455c3f5ec2b032acb8dde4b20a9b", + "sha256:e9dbc233a7f7077fb9c5a4202d0e60d626b651ba97e8bfc4e9bb28afb3d33d78", + "sha256:fab51eeeb5a4b4a2a572c8bf749f78444f2a6cfdd52effe21e7c0806c750ec68", + "sha256:ff4ead2b6f4f217bb10ea93518fbca259acb28b96a5d78cd3b2415cff737c623" + ], + "version": "==4.4.0" + }, + "zope.contentprovider": { + "hashes": [ + "sha256:12a053375b637c9f61abe7a04b0399565a8ab6fad410e650ca3e4d5c37a07fe2", + "sha256:a2b37ec98f6c18338caec82bb16bceaa401f19a97883cfb367476dffa3b61d54" + ], + "version": "==4.2.1" + }, + "zope.contenttype": { + "hashes": [ + "sha256:3bfe9012f61474d8612a72604d21d83b48f50cf4d18db08eab836e8d08737076", + "sha256:c12d929c67ab3eaef9b8a7fba3d19cce8500c8fd25afed8058c8e15f324cbd5b" + ], + "version": "==4.5.0" + }, + "zope.deferredimport": { + "hashes": [ + "sha256:57b2345e7b5eef47efcd4f634ff16c93e4265de3dcf325afc7315ade48d909e1", + "sha256:9a0c211df44aa95f1c4e6d2626f90b400f56989180d3ef96032d708da3d23e0a" + ], + "version": "==4.3.1" + }, + "zope.deprecation": { + "hashes": [ + "sha256:0d453338f04bacf91bbfba545d8bcdf529aa829e67b705eac8c1a7fdce66e2df", + "sha256:f1480b74995958b24ce37b0ef04d3663d2683e5d6debc96726eff18acf4ea113" + ], + "version": "==4.4.0" + }, + "zope.dottedname": { + "hashes": [ + "sha256:0cec09844d309550359ac1941abfcd9141e213f67f3c19bb8f90360c40787576", + "sha256:f75325c1e247559fcf3d2d7af72ddbb864ddd67ee4bf007ba01232165846aecf" + ], + "version": "==4.3" + }, + "zope.event": { + "hashes": [ + "sha256:69c27debad9bdacd9ce9b735dad382142281ac770c4a432b533d6d65c4614bcf", + "sha256:d8e97d165fd5a0997b45f5303ae11ea3338becfe68c401dd88ffd2113fe5cae7" + ], + "version": "==4.4" + }, + "zope.exceptions": { + "hashes": [ + "sha256:5fa59c3c1044bb9448aeec8328db0bfceaae2a2174e88528d3fe04adf8d47211", + "sha256:cbaaafeb400b76962b4a45860322d807eb5c2bf484e087b0121749f5fba3c3f6" + ], + "version": "==4.3" + }, + "zope.filerepresentation": { + "hashes": [ + "sha256:3fbca4730c871d8e37b9730763c42b69ba44117cf6d0848014495bb301cae2d6", + "sha256:5b2325198dd881ad339f2196292339c7e03f48dc970a8ef84e973a4fef7654a4" + ], + "version": "==5.0.0" + }, + "zope.globalrequest": { + "hashes": [ + "sha256:12ea106410f4ad76fdef862661f6a49a7feeed335fe0c373edc6afce564e583e" + ], + "version": "==1.5" + }, + "zope.hookable": { + "hashes": [ + "sha256:0194b9b9e7f614abba60c90b231908861036578297515d3d6508eb10190f266d", + "sha256:0c2977473918bdefc6fa8dfb311f154e7f13c6133957fe649704deca79b92093", + "sha256:17b8bdb3b77e03a152ca0d5ca185a7ae0156f5e5a2dbddf538676633a1f7380f", + "sha256:29d07681a78042cdd15b268ae9decffed9ace68a53eebeb61d65ae931d158841", + "sha256:36fb1b35d1150267cb0543a1ddd950c0bc2c75ed0e6e92e3aaa6ac2e29416cb7", + "sha256:3aed60c2bb5e812bbf9295c70f25b17ac37c233f30447a96c67913ba5073642f", + "sha256:3cac1565cc768911e72ca9ec4ddf5c5109e1fef0104f19f06649cf1874943b60", + "sha256:3d4bc0cc4a37c3cd3081063142eeb2125511db3c13f6dc932d899c512690378e", + "sha256:3f73096f27b8c28be53ffb6604f7b570fbbb82f273c6febe5f58119009b59898", + "sha256:522d1153d93f2d48aa0bd9fb778d8d4500be2e4dcf86c3150768f0e3adbbc4ef", + "sha256:523d2928fb7377bbdbc9af9c0b14ad73e6eaf226349f105733bdae27efd15b5a", + "sha256:5848309d4fc5c02150a45e8f8d2227e5bfda386a508bbd3160fed7c633c5a2fa", + "sha256:6781f86e6d54a110980a76e761eb54590630fd2af2a17d7edf02a079d2646c1d", + "sha256:6fd27921ebf3aaa945fa25d790f1f2046204f24dba4946f82f5f0a442577c3e9", + "sha256:70d581862863f6bf9e175e85c9d70c2d7155f53fb04dcdb2f73cf288ca559a53", + "sha256:81867c23b0dc66c8366f351d00923f2bc5902820a24c2534dfd7bf01a5879963", + "sha256:81db29edadcbb740cd2716c95a297893a546ed89db1bfe9110168732d7f0afdd", + "sha256:86bd12624068cea60860a0759af5e2c3adc89c12aef6f71cf12f577e28deefe3", + "sha256:9c184d8f9f7a76e1ced99855ccf390ffdd0ec3765e5cbf7b9cada600accc0a1e", + "sha256:acc789e8c29c13555e43fe4bf9fcd15a65512c9645e97bbaa5602e3201252b02", + "sha256:afaa740206b7660d4cc3b8f120426c85761f51379af7a5b05451f624ad12b0af", + "sha256:b5f5fa323f878bb16eae68ea1ba7f6c0419d4695d0248bed4b18f51d7ce5ab85", + "sha256:bd89e0e2c67bf4ac3aca2a19702b1a37269fb1923827f68324ac2e7afd6e3406", + "sha256:c212de743283ec0735db24ec6ad913758df3af1b7217550ff270038062afd6ae", + "sha256:ca553f524293a0bdea05e7f44c3e685e4b7b022cb37d87bc4a3efa0f86587a8d", + "sha256:cab67065a3db92f636128d3157cc5424a145f82d96fb47159c539132833a6d36", + "sha256:d3b3b3eedfdbf6b02898216e85aa6baf50207f4378a2a6803d6d47650cd37031", + "sha256:d9f4a5a72f40256b686d31c5c0b1fde503172307beb12c1568296e76118e402c", + "sha256:df5067d87aaa111ed5d050e1ee853ba284969497f91806efd42425f5348f1c06", + "sha256:e2587644812c6138f05b8a41594a8337c6790e3baf9a01915e52438c13fc6bef", + "sha256:e27fd877662db94f897f3fd532ef211ca4901eb1a70ba456f15c0866a985464a", + "sha256:e427ebbdd223c72e06ba94c004bb04e996c84dec8a0fa84e837556ae145c439e", + "sha256:e583ad4309c203ef75a09d43434cf9c2b4fa247997ecb0dcad769982c39411c7", + "sha256:e760b2bc8ece9200804f0c2b64d10147ecaf18455a2a90827fbec4c9d84f3ad5", + "sha256:ea9a9cc8bcc70e18023f30fa2f53d11ae069572a162791224e60cd65df55fb69", + "sha256:ecb3f17dce4803c1099bd21742cd126b59817a4e76a6544d31d2cca6e30dbffd", + "sha256:ed794e3b3de42486d30444fb60b5561e724ee8a2d1b17b0c2e0f81e3ddaf7a87", + "sha256:ee885d347279e38226d0a437b6a932f207f691c502ee565aba27a7022f1285df", + "sha256:fd5e7bc5f24f7e3d490698f7b854659a9851da2187414617cd5ed360af7efd63", + "sha256:fe45f6870f7588ac7b2763ff1ce98cce59369717afe70cc353ec5218bc854bcc" + ], + "version": "==5.0.1" + }, + "zope.i18n": { + "extras": [ + "zcml" + ], + "hashes": [ + "sha256:9fcc1adb4e5f6188769ab36f6f40a59b567bb5eef91f714584e0dfd0891be5d0" + ], + "version": "==4.7.0" + }, + "zope.i18nmessageid": { + "hashes": [ + "sha256:027635a36dc95ba3f4a8c90688bec18aa207df20f0440d14424efbee156b6f8c", + "sha256:0ad81f3b11da737a2a399233f7e5256fed31cd77192c581fd303e4fe1dad1c03", + "sha256:0b25ada1272d0be89ae370718d69b2138b13b8281fde9f75ef1e051829da3d71", + "sha256:1bb90045335aaf1ed1d99d6d747df61c82b28d376ea9c95044070405fd6de5b4", + "sha256:1daf2c453ebfd3fa5b1b94a3bbd8dbbdfff5f726c796c049dcedbe8fc3cfe0ec", + "sha256:1de20c14fe01724cc47470bbe9280086eafcdaa54774777ae4c346b7fbe435fc", + "sha256:24b126485b3a2dac2e85fa29fbdde9a819847b8a8cfef49c85c68fbe9fe34dc7", + "sha256:2d2923ea33f59b322262b0141f578e84e558d2bdfee01c04c941741293568f95", + "sha256:33fbeadf46f3ebf6a3d757885a622d9f5786a80ba47bc0883c1fd01114eb5a88", + "sha256:35217cca52a79caacedfa2db01c8658e1566e9f64f008b6fb925e80e4d8eb287", + "sha256:3d8e0dbcff33b78cd37da6661a5d6b4112f706dd9d55c849658856bf0db9a88e", + "sha256:423f1358ffef0a71d360c2d2a766176d4fe3fa34f715cd7a3ff157e724d132b1", + "sha256:434cf7181a4e43111b0f0a8dce3bf7204f3f4caac243c54189e6ca8bda31b38f", + "sha256:4e9a7e87c0a920fb14d69cbf947042c1bdaffd64b7ed2e8505d6f7fbe1db7d2c", + "sha256:5255d6fcb50458dfed758520e2e05d254db05d313cffc638bb8bc2abb245e7ef", + "sha256:525b293c628162a9dd8c9e7bad980adafb865720d1789e167e28751078316f38", + "sha256:5773aeb453f6e1147a24099ed071c14db80ee5b56551b8109012d635208a179b", + "sha256:58dae93da63f2a951046f209dd9d02f0c3b87fd6c793011ca00b8575f294bd3f", + "sha256:5b5fa7a6633cc6d98e87c6a88b4e5414b62519a94d9a3237a2c69679543de4dd", + "sha256:6274394dfe204c094d8c00222713a21c29288f779c7ab4e710ca10cdeddc89c2", + "sha256:63d099f11136af790c7ac489f53e930e1d1efbfe72408701a0143e0ce8884de5", + "sha256:6ac39296e651c72aa7910ba9a53cdba00356e40a570170042e7c9da04580703e", + "sha256:71ba3c6850195c8cdec0f66cdcc1cc4d7436744e4d3e27dd52e1fa63ad2b17dc", + "sha256:871aff6215dc1d37058977eeba2bc0e3e6763b79ba9635304b0c747f0ca9e17f", + "sha256:8a7a307a9937e4bfd5c27b6a3895174bdb2d69178326e2feb93669bb6af056b1", + "sha256:8afc9c33474871cbc01ceb7769bf2e7b62642b88e426c272109b67beeb3ce947", + "sha256:9301daa4d0830f61dbe461f7675556c64572aa07c568e4a920b0981d66ea1355", + "sha256:9534142b684c986f5303f469573978e5a340f05ba2eee4f872933f1c38b1b059", + "sha256:b4fc4419a2ead148ce74750d0f2e40fc5c3d282bb45500a28b3563e0d51820e4", + "sha256:b697eb21f0c02271d3ce55038a742166c50f8e67ea68fe039ec826ecfcf16ce0", + "sha256:c4baf1990d8728f438121c873ca367226f930759725d90eb9b450ad7a01aefbe", + "sha256:c54f35cd1fa5ab77b1dd2fbb7ecf2e5450c382c26e3b3ae5730e873a3b271787", + "sha256:c72297dec540a9c7a18fa6bd40085ada284938c0eb103d6870d8d5f287bcd5f9", + "sha256:cccfff0c945757ce0718f382bef1759a69c35653c806bac9ca5cf65cb1c6ee58", + "sha256:dbc4a97402a961319866fa75a5a6f43bc170acabc6453b59fde607141a4bd5f7", + "sha256:e898924f9419c82fa9f1c525b7d328c91210600a28161d2d1b422daccd59a3ee", + "sha256:f37472c89550c152ba7699c3adc591aab790c29dff010e938d6285013c540ee8", + "sha256:f431874af602cd9596102b58b001fb687a6f73a9e026546a441bfa0caec78013", + "sha256:f90069087102401229e3a70396a512e02b65a4528e6c84d12da5d9bf43ced9a7", + "sha256:fb7271c1bfb182b6fe7144c099ce92f5f30540bcb82aa467d9a2407c7358c0eb" + ], + "version": "==5.0.1" + }, + "zope.interface": { + "hashes": [ + "sha256:0103cba5ed09f27d2e3de7e48bb320338592e2fabc5ce1432cf33808eb2dfd8b", + "sha256:14415d6979356629f1c386c8c4249b4d0082f2ea7f75871ebad2e29584bd16c5", + "sha256:1ae4693ccee94c6e0c88a4568fb3b34af8871c60f5ba30cf9f94977ed0e53ddd", + "sha256:1b87ed2dc05cb835138f6a6e3595593fea3564d712cb2eb2de963a41fd35758c", + "sha256:269b27f60bcf45438e8683269f8ecd1235fa13e5411de93dae3b9ee4fe7f7bc7", + "sha256:27d287e61639d692563d9dab76bafe071fbeb26818dd6a32a0022f3f7ca884b5", + "sha256:39106649c3082972106f930766ae23d1464a73b7d30b3698c986f74bf1256a34", + "sha256:40e4c42bd27ed3c11b2c983fecfb03356fae1209de10686d03c02c8696a1d90e", + "sha256:461d4339b3b8f3335d7e2c90ce335eb275488c587b61aca4b305196dde2ff086", + "sha256:4f98f70328bc788c86a6a1a8a14b0ea979f81ae6015dd6c72978f1feff70ecda", + "sha256:558a20a0845d1a5dc6ff87cd0f63d7dac982d7c3be05d2ffb6322a87c17fa286", + "sha256:562dccd37acec149458c1791da459f130c6cf8902c94c93b8d47c6337b9fb826", + "sha256:5e86c66a6dea8ab6152e83b0facc856dc4d435fe0f872f01d66ce0a2131b7f1d", + "sha256:60a207efcd8c11d6bbeb7862e33418fba4e4ad79846d88d160d7231fcb42a5ee", + "sha256:645a7092b77fdbc3f68d3cc98f9d3e71510e419f54019d6e282328c0dd140dcd", + "sha256:6874367586c020705a44eecdad5d6b587c64b892e34305bb6ed87c9bbe22a5e9", + "sha256:74bf0a4f9091131de09286f9a605db449840e313753949fe07c8d0fe7659ad1e", + "sha256:7b726194f938791a6691c7592c8b9e805fc6d1b9632a833b9c0640828cd49cbc", + "sha256:8149ded7f90154fdc1a40e0c8975df58041a6f693b8f7edcd9348484e9dc17fe", + "sha256:8cccf7057c7d19064a9e27660f5aec4e5c4001ffcf653a47531bde19b5aa2a8a", + "sha256:911714b08b63d155f9c948da2b5534b223a1a4fc50bb67139ab68b277c938578", + "sha256:a5f8f85986197d1dd6444763c4a15c991bfed86d835a1f6f7d476f7198d5f56a", + "sha256:a744132d0abaa854d1aad50ba9bc64e79c6f835b3e92521db4235a1991176813", + "sha256:af2c14efc0bb0e91af63d00080ccc067866fb8cbbaca2b0438ab4105f5e0f08d", + "sha256:b054eb0a8aa712c8e9030065a59b5e6a5cf0746ecdb5f087cca5ec7685690c19", + "sha256:b0becb75418f8a130e9d465e718316cd17c7a8acce6fe8fe07adc72762bee425", + "sha256:b1d2ed1cbda2ae107283befd9284e650d840f8f7568cb9060b5466d25dc48975", + "sha256:ba4261c8ad00b49d48bbb3b5af388bb7576edfc0ca50a49c11dcb77caa1d897e", + "sha256:d1fe9d7d09bb07228650903d6a9dc48ea649e3b8c69b1d263419cc722b3938e8", + "sha256:d7804f6a71fc2dda888ef2de266727ec2f3915373d5a785ed4ddc603bbc91e08", + "sha256:da2844fba024dd58eaa712561da47dcd1e7ad544a257482392472eae1c86d5e5", + "sha256:dcefc97d1daf8d55199420e9162ab584ed0893a109f45e438b9794ced44c9fd0", + "sha256:dd98c436a1fc56f48c70882cc243df89ad036210d871c7427dc164b31500dc11", + "sha256:e74671e43ed4569fbd7989e5eecc7d06dc134b571872ab1d5a88f4a123814e9f", + "sha256:eb9b92f456ff3ec746cd4935b73c1117538d6124b8617bc0fe6fda0b3816e345", + "sha256:ebb4e637a1fb861c34e48a00d03cffa9234f42bef923aec44e5625ffb9a8e8f9", + "sha256:ef739fe89e7f43fb6494a43b1878a36273e5924869ba1d866f752c5812ae8d58", + "sha256:f40db0e02a8157d2b90857c24d89b6310f9b6c3642369852cdc3b5ac49b92afc", + "sha256:f68bf937f113b88c866d090fea0bc52a098695173fc613b055a17ff0cf9683b6", + "sha256:fb55c182a3f7b84c1a2d6de5fa7b1a05d4660d866b91dbf8d74549c57a1499e8" + ], + "version": "==5.1.0" + }, + "zope.lifecycleevent": { + "hashes": [ + "sha256:2be120f09ff185ac5e3294741e30a77678bd23fd7718a76fe373fc91bc13b732", + "sha256:7ec39087cc1524e55557e7d9dc6295eb1b95b09b125e293c0e2dd068574f0aee" + ], + "version": "==4.3" + }, + "zope.location": { + "hashes": [ + "sha256:4e5cbfb5e397820e6c21c2ac66cc44e7946bdaa4af336e4dbb613fea818a068c", + "sha256:a720f9e3c8a51d5007ed6fcd47e1834df02671d85dbfd1062a0d808de8bf80ac" + ], + "version": "==4.2" + }, + "zope.pagetemplate": { + "hashes": [ + "sha256:63cbc646c85c276c586bb8cebd45ca8762f5c426a0fdee18db0d4f12e2511d8c", + "sha256:7d8664d16eaa8abd56aa6703f491222c4a8d5386177e7809d7ec71e8ecda3f92" + ], + "version": "==4.5.0" + }, + "zope.processlifetime": { + "hashes": [ + "sha256:d39f420ad5248291172c5dece8019e503e227edd9de59768e99c3419524507cf", + "sha256:e7a543b0f243a58b1abe64f86376e6c15657cae4e652dbf8de10252bba212939" + ], + "version": "==2.3.0" + }, + "zope.proxy": { + "hashes": [ + "sha256:00573dfa755d0703ab84bb23cb6ecf97bb683c34b340d4df76651f97b0bab068", + "sha256:092049280f2848d2ba1b57b71fe04881762a220a97b65288bcb0968bb199ec30", + "sha256:0cbd27b4d3718b5ec74fc65ffa53c78d34c65c6fd9411b8352d2a4f855220cf1", + "sha256:17fc7e16d0c81f833a138818a30f366696653d521febc8e892858041c4d88785", + "sha256:19577dfeb70e8a67249ba92c8ad20589a1a2d86a8d693647fa8385408a4c17b0", + "sha256:207aa914576b1181597a1516e1b90599dc690c095343ae281b0772e44945e6a4", + "sha256:219a7db5ed53e523eb4a4769f13105118b6d5b04ed169a283c9775af221e231f", + "sha256:2b50ea79849e46b5f4f2b0247a3687505d32d161eeb16a75f6f7e6cd81936e43", + "sha256:5903d38362b6c716e66bbe470f190579c530a5baf03dbc8500e5c2357aa569a5", + "sha256:5c24903675e271bd688c6e9e7df5775ac6b168feb87dbe0e4bcc90805f21b28f", + "sha256:5ef6bc5ed98139e084f4e91100f2b098a0cd3493d4e76f9d6b3f7b95d7ad0f06", + "sha256:61b55ae3c23a126a788b33ffb18f37d6668e79a05e756588d9e4d4be7246ab1c", + "sha256:63ddb992931a5e616c87d3d89f5a58db086e617548005c7f9059fac68c03a5cc", + "sha256:6943da9c09870490dcfd50c4909c0cc19f434fa6948f61282dc9cb07bcf08160", + "sha256:6ad40f85c1207803d581d5d75e9ea25327cd524925699a83dfc03bf8e4ba72b7", + "sha256:6b44433a79bdd7af0e3337bd7bbcf53dd1f9b0fa66bf21bcb756060ce32a96c1", + "sha256:6bbaa245015d933a4172395baad7874373f162955d73612f0b66b6c2c33b6366", + "sha256:7007227f4ea85b40a2f5e5a244479f6a6dfcf906db9b55e812a814a8f0e2c28d", + "sha256:74884a0aec1f1609190ec8b34b5d58fb3b5353cf22b96161e13e0e835f13518f", + "sha256:7d25fe5571ddb16369054f54cdd883f23de9941476d97f2b92eb6d7d83afe22d", + "sha256:7e162bdc5e3baad26b2262240be7d2bab36991d85a6a556e48b9dfb402370261", + "sha256:814d62678dc3a30f4aa081982d830b7c342cf230ffc9d030b020cb154eeebf9e", + "sha256:8878a34c5313ee52e20aa50b03138af8d472bae465710fb954d133a9bfd3c38d", + "sha256:a66a0d94e5b081d5d695e66d6667e91e74d79e273eee95c1747717ba9cb70792", + "sha256:a69f5cbf4addcfdf03dda564a671040127a6b7c34cf9fe4973582e68441b63fa", + "sha256:b00f9f0c334d07709d3f73a7cb8ae63c6ca1a90c790a63b5e7effa666ef96021", + "sha256:b6ed71e4a7b4690447b626f499d978aa13197a0e592950e5d7020308f6054698", + "sha256:bdf5041e5851526e885af579d2f455348dba68d74f14a32781933569a327fddf", + "sha256:be034360dd34e62608419f86e799c97d389c10a0e677a25f236a971b2f40dac9", + "sha256:cc8f590a5eed30b314ae6b0232d925519ade433f663de79cc3783e4b10d662ba", + "sha256:cd7a318a15fe6cc4584bf3c4426f092ed08c0fd012cf2a9173114234fe193e11", + "sha256:cf19b5f63a59c20306e034e691402b02055c8f4e38bf6792c23cad489162a642", + "sha256:cfc781ce442ec407c841e9aa51d0e1024f72b6ec34caa8fdb6ef9576d549acf2", + "sha256:dea9f6f8633571e18bc20cad83603072e697103a567f4b0738d52dd0211b4527", + "sha256:e4a86a1d5eb2cce83c5972b3930c7c1eac81ab3508464345e2b8e54f119d5505", + "sha256:e7106374d4a74ed9ff00c46cc00f0a9f06a0775f8868e423f85d4464d2333679", + "sha256:e98a8a585b5668aa9e34d10f7785abf9545fe72663b4bfc16c99a115185ae6a5", + "sha256:f64840e68483316eb58d82c376ad3585ca995e69e33b230436de0cdddf7363f9", + "sha256:f8f4b0a9e6683e43889852130595c8854d8ae237f2324a053cdd884de936aa9b", + "sha256:fc45a53219ed30a7f670a6d8c98527af0020e6fd4ee4c0a8fb59f147f06d816c" + ], + "version": "==4.3.5" + }, + "zope.ptresource": { + "hashes": [ + "sha256:06f776c6d25bf847302c69219ceb9ab2e42a28cf0aec2b984ba32c94c72a2c6a", + "sha256:c4cd51ecc3ae43a0b518f325db6886c44114032df56c15bd75c67d607fabe011" + ], + "version": "==4.2.0" + }, + "zope.publisher": { + "hashes": [ + "sha256:18a0f0521b36af7cb46a9f7d4fe09dd68913eb9b835d4a64d26fe8f8928675c9", + "sha256:c4e2009ca0fd3f8964f91c5e780b3e272882a92cc040f4744970453babb9b522" + ], + "version": "==5.2.0" + }, + "zope.schema": { + "hashes": [ + "sha256:20fbbce8a0726ba34f0e3958676498feebb818f06575193254e139d8d7214f26", + "sha256:b8c80c481bdabbd3911218e215aa58f9885dae6b1f182fa9748beefe423acd39" + ], + "version": "==6.0.0" + }, + "zope.security": { + "hashes": [ + "sha256:052c12a83a0fcf1d7602c2ee8d4894479124044b1358b46002a74febf034bf2b", + "sha256:08ae8bede07977106ee84d564486dbf33d92683580b00255fb844da0944ad723", + "sha256:10d4489fa88a24e67163aef44fc4910c8bbf591f461696ba724cbfc88ee18e86", + "sha256:18bd61729ef6a68ac549bc809f962f827dc0e7f64f158c0047efbe49c77800ee", + "sha256:1f90e0f82eef1d4c75dff7004ed4949272476760977e1664db4d9738cadf7601", + "sha256:29f10f920eff06e86c7a2226689b2320221a0f148157fc324b170b427a5e702a", + "sha256:2b571408149d8b1228f9567350d07298bfbbbbbe2016f64488c0dfa7c7e774a7", + "sha256:2ec21a6dae1303356bac5b42094a1a0d6f69bc937dd499c77c7b31805d23e818", + "sha256:340f098aebb149fa38d9186cd39a7188422e2bf772b5aa7125a3b6832cbd6977", + "sha256:42e99ad7eb22d0721a7f4c1d4e8c919abeb2d095d344011df034716fdd271da8", + "sha256:43671a892b7e2dcdf38e5b0c965c63cc2d49ce83be656bcbab6acc2c55c8c96f", + "sha256:445edb1069c175de49830d354497e38eaab86b2c3a762785266acc699d95e4f9", + "sha256:44f50e6f1899fc2520dc20b952d2d8e662b793d99718d34eabb383a708db5cb4", + "sha256:450e8d7cdf85b488f54e350afae7d1cffe684c7e71ec36a6c05fdb42d31078e4", + "sha256:4d215f4e1f45749328572a4fb5da79b9b73b51a426731704df9c604403383536", + "sha256:50679f6b26843f355f881cdb05aaf587280bc00b11b8c795d4463919d8616069", + "sha256:54e4366dcbf7d48289d3c62d594762a9b24df2b01e99f3138656fed4bbdf4748", + "sha256:558362ad4b6d5054bbbc6fe2f15cfb38bc613530a5dcd541145627ba58f59e5b", + "sha256:55f88cf128e9799949f5296118d1aad0e4604f56be83029569a3e27a91e7b15d", + "sha256:5ca878c90d5d96d45acf1ed981b34ebc285f99f338f8a4656b6414d19d116370", + "sha256:5d745eb420abf6a26bc5cef53019605108f699495e7bb0c28f850cdcc84f2ea9", + "sha256:68343c4f3bfb2b3335830af774c3556095870f2d3f591074759fc4e05336a9ea", + "sha256:82c7f8ad6352c4d876f5fd1ab2a0c92e175d26b7d7b04bb75b3a80ff4c2e8210", + "sha256:84489f93ce98db4c87110f37168ece4fa93ba095b68c7b8703c76f2ec8528ba8", + "sha256:8b2e21bb66389e66f694d038e9ca37c664a5d5f7dcae8cd0993d790ae18b77af", + "sha256:8b320f6f2a899a87b0dddb881867f5a4de7d252a544448601e592d6f3edd0485", + "sha256:9c517611e85ec9d2eb62f3f0e3287dca46d1d75b411854bccad32d61dd957fc5", + "sha256:ae33695309fcefd476a96a94561061316ab5544ab82f6b445b18dc9edb88cc76", + "sha256:b0e9a5fa1619e068e2a48fc1655dbb7809bf7b2b52feea5b398da12817fb04be", + "sha256:b823d64267ab978b74f5e5e75318fa520633527b9e5df3740924cda3d4adf0ad", + "sha256:ba07141c0d491c0c5499459f97cd092c5ada9e5597063710a504f2dd98650175", + "sha256:bcd9baffb5bbfc0f6ceec2ece9381ff0327b420843d412107d467dc4df4c7953", + "sha256:c5407b173bbac319cad1e83b5d2fc40a3c326cbc4d13af89bd34bb4873e4756c", + "sha256:c8131e1b972a5709d0860d146ce38d733ae4e0248b27c4be8fdb7a943ffaf288", + "sha256:db8d294378819ec2ead3db2b0170964a6650b2d669ca67d61bb77215bc159b0f", + "sha256:debdac73d297ad5ed7a0e943ec5e263bd6d4593da78640d6da41b83876411757", + "sha256:e68d19c6090ea2bca017cb7841ccd10833699d2174070072b35055452c333153", + "sha256:e893c8c6e6b14c04b12be4c0dc279dba232c076f8ef25de95ffeb02ed663b788", + "sha256:eade096f141437a47f1ad240eadb08e96f6aead34a28450dc3aa0e1237e18e1e", + "sha256:f72f726ff16af218bb677b8acf6a54f866cdc550dfdd3d12125122cb2e299940" + ], + "version": "==5.1.1" + }, + "zope.sequencesort": { + "hashes": [ + "sha256:2c638c07fc1c28d1946a087c065c45319e281eb590786b11412f38f0454313bd", + "sha256:927447eb677bcb59d48ee9a23bbccbabcc7bf70f918865918303b53a49695364" + ], + "version": "==4.1.2" + }, + "zope.site": { + "hashes": [ + "sha256:07126a8044a5a6b99bb3fb491e095ba42e99cc452c32c5b990470a3b86cea55a", + "sha256:c8605332f5bf64477f11e0db076bab04542da697d799abdd42ef7f656252753e" + ], + "version": "==4.3.0" + }, + "zope.size": { + "hashes": [ + "sha256:6f3eb687c9181e3b7400c5cd4d4209a2f676475b7b85c99ee11de2404b3493ec", + "sha256:82341c9c56ca2838016adfd3d0f51b26a8d5186df210ea851069d51cb6cc14db" + ], + "version": "==4.3" + }, + "zope.structuredtext": { + "hashes": [ + "sha256:59e8cac73e4d7e35b92411bb5d14cfef0ac651f495cebdf83f063f396abca29e", + "sha256:b104e76b090179f11f0462ef7efb4d31378b0e4e20b1d844eae05d2da5e97d00" + ], + "version": "==4.3" + }, + "zope.tal": { + "hashes": [ + "sha256:04a7b1dfce776b829946666ad5cd4a1d9363d6f337790e8d425bafd49bc6acac", + "sha256:6c4149bc2273941a95e1ed80e682f01b3c62bcf5034ce2f0157a5aa81fa89635" + ], + "version": "==4.4" + }, + "zope.tales": { + "hashes": [ + "sha256:f6a8ad290b5729fea0a6c2f713afb1c3b2a703c0e3144ea7364b473bb3cdc182", + "sha256:fc8d29e629d18f426c3d925ad8b87737de14be2582588dcc925f883ee0cd4fb2" + ], + "version": "==5.0.2" + }, + "zope.testbrowser": { + "hashes": [ + "sha256:1c2ca8d89b52b545fc1de5e01a37df1c5901f28cc205cbd149f56346293d7d52", + "sha256:acbab12971b8b80bc72fca53f1a6654ba5a5c424949bf74fbca3a193487d5cd3" + ], + "version": "==5.5.1" + }, + "zope.testing": { + "hashes": [ + "sha256:32a9613fc8ed8f4992a854d1a05412615695bc90632c7aa858e9988b3827ee19", + "sha256:d66be8d1de37e8536ca58a1d9f4d89a68c9cc75cc0e788a175c8a20ae26003ea" + ], + "version": "==4.7" + }, + "zope.traversing": { + "hashes": [ + "sha256:1c05453726f574870121ccac90182b0ffc570b36dab5c60b9457369dd2018d34", + "sha256:332efeedbc8b8f79a5bd630a0778ee3bd91f992d38d9667a779c9cf718847f8f" + ], + "version": "==4.4.1" + }, + "zope.viewlet": { + "hashes": [ + "sha256:2b37893767393c7df8e718d69d1881c0f1872caafa15fbdaae08554ac566fd77", + "sha256:2d892af313d7a60f7fbf58e20e6584ff9c896dd7bdfcb7bbd0cf415862d790ea" + ], + "version": "==4.2.1" + } + }, + "develop": { + "appnope": { + "hashes": [ + "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0", + "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71" + ], + "markers": "sys_platform == 'darwin'", + "version": "==0.1.0" + }, + "attrs": { + "hashes": [ + "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", + "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + ], + "index": "pypi", + "version": "==19.3.0" + }, + "backcall": { + "hashes": [ + "sha256:38ecd85be2c1e78f77fd91700c76e14667dc21e2713b63876c0eb901196e01e4", + "sha256:bbbf4b1e5cd2bdb08f915895b51081c041bac22394fdfcfdfbe9f14b77c08bf2" + ], + "version": "==0.1.0" + }, + "bleach": { + "hashes": [ + "sha256:2bce3d8fab545a6528c8fa5d9f9ae8ebc85a56da365c7f85180bfe96a35ef22f", + "sha256:3c4c520fdb9db59ef139915a5db79f8b51bc2a7257ea0389f30c846883430a4b" + ], + "version": "==3.1.5" + }, + "decorator": { + "hashes": [ + "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760", + "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7" + ], + "version": "==4.4.2" + }, + "defusedxml": { + "hashes": [ + "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93", + "sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5" + ], + "version": "==0.6.0" + }, + "entrypoints": { + "hashes": [ + "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", + "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" + ], + "version": "==0.3" + }, + "ipykernel": { + "hashes": [ + "sha256:731adb3f2c4ebcaff52e10a855ddc87670359a89c9c784d711e62d66fccdafae", + "sha256:a8362e3ae365023ca458effe93b026b8cdadc0b73ff3031472128dd8a2cf0289" + ], + "version": "==5.3.0" + }, + "ipython": { + "hashes": [ + "sha256:5b241b84bbf0eb085d43ae9d46adf38a13b45929ca7774a740990c2c242534bb", + "sha256:f0126781d0f959da852fb3089e170ed807388e986a8dd4e6ac44855845b0fb1c" + ], + "markers": "python_version >= '3.3'", + "version": "==7.14.0" + }, + "ipython-genutils": { + "hashes": [ + "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8", + "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8" + ], + "version": "==0.2.0" + }, + "ipywidgets": { + "hashes": [ + "sha256:13ffeca438e0c0f91ae583dc22f50379b9d6b28390ac7be8b757140e9a771516", + "sha256:e945f6e02854a74994c596d9db83444a1850c01648f1574adf144fbbabe05c97" + ], + "version": "==7.5.1" + }, + "jedi": { + "hashes": [ + "sha256:cd60c93b71944d628ccac47df9a60fec53150de53d42dc10a7fc4b5ba6aae798", + "sha256:df40c97641cb943661d2db4c33c2e1ff75d491189423249e989bcea4464f3030" + ], + "version": "==0.17.0" + }, + "jinja2": { + "hashes": [ + "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", + "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" + ], + "version": "==2.11.2" + }, + "jsonschema": { + "hashes": [ + "sha256:4e5b3cf8216f577bee9ce139cbe72eca3ea4f292ec60928ff24758ce626cd163", + "sha256:c8a85b28d377cc7737e46e2d9f2b4f44ee3c0e1deac6bf46ddefc7187d30797a" + ], + "version": "==3.2.0" + }, + "jupyter": { + "hashes": [ + "sha256:3e1f86076bbb7c8c207829390305a2b1fe836d471ed54be66a3b8c41e7f46cc7", + "sha256:5b290f93b98ffbc21c0c7e749f054b3267782166d72fa5e3ed1ed4eaf34a2b78", + "sha256:d9dc4b3318f310e34c82951ea5d6683f67bed7def4b259fafbfe4f1beb1d8e5f" + ], + "index": "pypi", + "version": "==1.0.0" + }, + "jupyter-client": { + "hashes": [ + "sha256:3a32fa4d0b16d1c626b30c3002a62dfd86d6863ed39eaba3f537fade197bb756", + "sha256:cde8e83aab3ec1c614f221ae54713a9a46d3bf28292609d2db1b439bef5a8c8e" + ], + "version": "==6.1.3" + }, + "jupyter-console": { + "hashes": [ + "sha256:6f6ead433b0534909df789ea64f0a14cdf9b6b2360757756f08182be4b9e431b", + "sha256:b392155112ec86a329df03b225749a0fa903aa80811e8eda55796a40b5e470d8" + ], + "version": "==6.1.0" + }, + "jupyter-core": { + "hashes": [ + "sha256:394fd5dd787e7c8861741880bdf8a00ce39f95de5d18e579c74b882522219e7e", + "sha256:a4ee613c060fe5697d913416fc9d553599c05e4492d58fac1192c9a6844abb21" + ], + "version": "==4.6.3" + }, + "markupsafe": { + "hashes": [ + "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", + "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", + "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", + "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", + "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", + "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", + "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", + "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", + "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", + "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", + "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", + "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", + "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", + "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", + "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", + "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", + "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", + "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", + "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", + "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", + "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", + "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", + "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", + "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", + "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", + "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", + "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", + "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", + "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", + "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", + "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", + "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", + "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" + ], + "version": "==1.1.1" + }, + "mistune": { + "hashes": [ + "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e", + "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4" + ], + "version": "==0.8.4" + }, + "nbconvert": { + "hashes": [ + "sha256:21fb48e700b43e82ba0e3142421a659d7739b65568cc832a13976a77be16b523", + "sha256:f0d6ec03875f96df45aa13e21fd9b8450c42d7e1830418cccc008c0df725fcee" + ], + "version": "==5.6.1" + }, + "nbformat": { + "hashes": [ + "sha256:049af048ed76b95c3c44043620c17e56bc001329e07f83fec4f177f0e3d7b757", + "sha256:276343c78a9660ab2a63c28cc33da5f7c58c092b3f3a40b6017ae2ce6689320d" + ], + "version": "==5.0.6" + }, + "nest-asyncio": { + "hashes": [ + "sha256:75dad56eaa7078e2e29c6630f114077fc5060069658d74545b0409e63ca8a028", + "sha256:766ee832cdef108497a70dd729cc4ff56d16a8d3e08404ae138bdf15913f66f9" + ], + "index": "pypi", + "version": "==1.3.3" + }, + "notebook": { + "hashes": [ + "sha256:3edc616c684214292994a3af05eaea4cc043f6b4247d830f3a2f209fa7639a80", + "sha256:47a9092975c9e7965ada00b9a20f0cf637d001db60d241d479f53c0be117ad48" + ], + "version": "==6.0.3" + }, + "packaging": { + "hashes": [ + "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", + "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" + ], + "version": "==20.4" + }, + "pandocfilters": { + "hashes": [ + "sha256:b3dd70e169bb5449e6bc6ff96aea89c5eea8c5f6ab5e207fc2f521a2cf4a0da9" + ], + "version": "==1.4.2" + }, + "parso": { + "hashes": [ + "sha256:158c140fc04112dc45bca311633ae5033c2c2a7b732fa33d0955bad8152a8dd0", + "sha256:908e9fae2144a076d72ae4e25539143d40b8e3eafbaeae03c1bfe226f4cdf12c" + ], + "version": "==0.7.0" + }, + "pexpect": { + "hashes": [ + "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937", + "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c" + ], + "markers": "sys_platform != 'win32'", + "version": "==4.8.0" + }, + "pickleshare": { + "hashes": [ + "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca", + "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56" + ], + "version": "==0.7.5" + }, + "prometheus-client": { + "hashes": [ + "sha256:983c7ac4b47478720db338f1491ef67a100b474e3bc7dafcbaefb7d0b8f9b01c", + "sha256:c6e6b706833a6bd1fd51711299edee907857be10ece535126a158f911ee80915" + ], + "version": "==0.8.0" + }, + "prompt-toolkit": { + "hashes": [ + "sha256:563d1a4140b63ff9dd587bda9557cffb2fe73650205ab6f4383092fb882e7dc8", + "sha256:df7e9e63aea609b1da3a65641ceaf5bc7d05e0a04de5bd45d05dbeffbabf9e04" + ], + "version": "==3.0.5" + }, + "ptyprocess": { + "hashes": [ + "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0", + "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f" + ], + "markers": "os_name != 'nt'", + "version": "==0.6.0" + }, + "pygments": { + "hashes": [ + "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", + "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" + ], + "version": "==2.6.1" + }, + "pyparsing": { + "hashes": [ + "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", + "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" + ], + "version": "==2.4.7" + }, + "pyrsistent": { + "hashes": [ + "sha256:28669905fe725965daa16184933676547c5bb40a5153055a8dee2a4bd7933ad3" + ], + "version": "==0.16.0" + }, + "python-dateutil": { + "hashes": [ + "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", + "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + ], + "index": "pypi", + "version": "==2.8.1" + }, + "pyzmq": { + "hashes": [ + "sha256:07fb8fe6826a229dada876956590135871de60dbc7de5a18c3bcce2ed1f03c98", + "sha256:13a5638ab24d628a6ade8f794195e1a1acd573496c3b85af2f1183603b7bf5e0", + "sha256:15b4cb21118f4589c4db8be4ac12b21c8b4d0d42b3ee435d47f686c32fe2e91f", + "sha256:21f7d91f3536f480cb2c10d0756bfa717927090b7fb863e6323f766e5461ee1c", + "sha256:2a88b8fabd9cc35bd59194a7723f3122166811ece8b74018147a4ed8489e6421", + "sha256:342fb8a1dddc569bc361387782e8088071593e7eaf3e3ecf7d6bd4976edff112", + "sha256:4ee0bfd82077a3ff11c985369529b12853a4064320523f8e5079b630f9551448", + "sha256:54aa24fd60c4262286fc64ca632f9e747c7cc3a3a1144827490e1dc9b8a3a960", + "sha256:58688a2dfa044fad608a8e70ba8d019d0b872ec2acd75b7b5e37da8905605891", + "sha256:5b99c2ae8089ef50223c28bac57510c163bfdff158c9e90764f812b94e69a0e6", + "sha256:5b9d21fc56c8aacd2e6d14738021a9d64f3f69b30578a99325a728e38a349f85", + "sha256:5f1f2eb22aab606f808163eb1d537ac9a0ba4283fbeb7a62eb48d9103cf015c2", + "sha256:6ca519309703e95d55965735a667809bbb65f52beda2fdb6312385d3e7a6d234", + "sha256:87c78f6936e2654397ca2979c1d323ee4a889eef536cc77a938c6b5be33351a7", + "sha256:8952f6ba6ae598e792703f3134af5a01af8f5c7cf07e9a148f05a12b02412cea", + "sha256:931339ac2000d12fe212e64f98ce291e81a7ec6c73b125f17cf08415b753c087", + "sha256:956775444d01331c7eb412c5fb9bb62130dfaac77e09f32764ea1865234e2ca9", + "sha256:97b6255ae77328d0e80593681826a0479cb7bac0ba8251b4dd882f5145a2293a", + "sha256:aaa8b40b676576fd7806839a5de8e6d5d1b74981e6376d862af6c117af2a3c10", + "sha256:af0c02cf49f4f9eedf38edb4f3b6bb621d83026e7e5d76eb5526cc5333782fd6", + "sha256:b08780e3a55215873b3b8e6e7ca8987f14c902a24b6ac081b344fd430d6ca7cd", + "sha256:ba6f24431b569aec674ede49cad197cad59571c12deed6ad8e3c596da8288217", + "sha256:bafd651b557dd81d89bd5f9c678872f3e7b7255c1c751b78d520df2caac80230", + "sha256:bfff5ffff051f5aa47ba3b379d87bd051c3196b0c8a603e8b7ed68a6b4f217ec", + "sha256:cf5d689ba9513b9753959164cf500079383bc18859f58bf8ce06d8d4bef2b054", + "sha256:dcbc3f30c11c60d709c30a213dc56e88ac016fe76ac6768e64717bd976072566", + "sha256:f9d7e742fb0196992477415bb34366c12e9bb9a0699b8b3f221ff93b213d7bec", + "sha256:faee2604f279d31312bc455f3d024f160b6168b9c1dde22bf62d8c88a4deca8e" + ], + "version": "==19.0.1" + }, + "qtconsole": { + "hashes": [ + "sha256:89442727940126c65c2f94a058f1b4693a0f5d4c4b192fd6518ba3b11f4791aa", + "sha256:fd48bf1051d6e69cec1f9e2596cfaa94e3c726c70c5d848681ebce10c029f5fd" + ], + "version": "==4.7.4" + }, + "qtpy": { + "hashes": [ + "sha256:2db72c44b55d0fe1407be8fba35c838ad0d6d3bb81f23007886dc1fc0f459c8d", + "sha256:fa0b8363b363e89b2a6f49eddc162a04c0699ae95e109a6be3bb145a913190ea" + ], + "version": "==1.9.0" + }, + "send2trash": { + "hashes": [ + "sha256:60001cc07d707fe247c94f74ca6ac0d3255aabcb930529690897ca2a39db28b2", + "sha256:f1691922577b6fa12821234aeb57599d887c4900b9ca537948d2dac34aea888b" + ], + "version": "==1.5.0" + }, + "six": { + "hashes": [ + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + ], + "version": "==1.15.0" + }, + "terminado": { + "hashes": [ + "sha256:4804a774f802306a7d9af7322193c5390f1da0abb429e082a10ef1d46e6fb2c2", + "sha256:a43dcb3e353bc680dd0783b1d9c3fc28d529f190bc54ba9a229f72fe6e7a54d7" + ], + "version": "==0.8.3" + }, + "testpath": { + "hashes": [ + "sha256:60e0a3261c149755f4399a1fff7d37523179a70fdc3abdf78de9fc2604aeec7e", + "sha256:bfcf9411ef4bf3db7579063e0546938b1edda3d69f4e1fb8756991f5951f85d4" + ], + "version": "==0.4.4" + }, + "tornado": { + "hashes": [ + "sha256:0fe2d45ba43b00a41cd73f8be321a44936dc1aba233dee979f17a042b83eb6dc", + "sha256:22aed82c2ea340c3771e3babc5ef220272f6fd06b5108a53b4976d0d722bcd52", + "sha256:2c027eb2a393d964b22b5c154d1a23a5f8727db6fda837118a776b29e2b8ebc6", + "sha256:5217e601700f24e966ddab689f90b7ea4bd91ff3357c3600fa1045e26d68e55d", + "sha256:5618f72e947533832cbc3dec54e1dffc1747a5cb17d1fd91577ed14fa0dc081b", + "sha256:5f6a07e62e799be5d2330e68d808c8ac41d4a259b9cea61da4101b83cb5dc673", + "sha256:c58d56003daf1b616336781b26d184023ea4af13ae143d9dda65e31e534940b9", + "sha256:c952975c8ba74f546ae6de2e226ab3cc3cc11ae47baf607459a6728585bb542a", + "sha256:c98232a3ac391f5faea6821b53db8db461157baa788f5d6222a193e9456e1740" + ], + "version": "==6.0.4" + }, + "traitlets": { + "hashes": [ + "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44", + "sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7" + ], + "version": "==4.3.3" + }, + "wcwidth": { + "hashes": [ + "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1", + "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1" + ], + "version": "==0.1.9" + }, + "webencodings": { + "hashes": [ + "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", + "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" + ], + "version": "==0.5.1" + }, + "widgetsnbextension": { + "hashes": [ + "sha256:079f87d87270bce047512400efd70238820751a11d2d8cb137a5a5bdbaf255c7", + "sha256:bd314f8ceb488571a5ffea6cc5b9fc6cba0adaf88a9d2386b93a489751938bcd" + ], + "version": "==3.5.1" + } + } +} diff --git a/README.md b/README.md index 38b4890..fb2f7fe 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,69 @@ -# Steem node RPC scanner +# Hive / Steem-based RPC node scanner -by [@someguy123](https://steemit.com/@someguy123) +by [@someguy123](https://peakd.com/@someguy123) -![Screenshot of RPC Scanner](https://i.imgur.com/B9EShPn.png) +![Screenshot of RPC Scanner app.py](https://cdn.privex.io/github/rpc-scanner/rpcscanner_list_may2020.png) -A fast and easy to use Python script which scans [Steem](https://www.steem.io) RPC nodes -asynchronously using request-threads and Twisted's Reactor. +A fast and easy to use Python script which scans [Hive](https://www.hive.io), [Steem](https://www.steem.io), + and other forks' RPC nodes asynchronously using [HTTPX](https://github.com/encode/httpx) and + native Python AsyncIO. **Features:** - Colorized output for easy reading - Tests a node's reliability during data collection, with multiple retries on error - Reports the average response time, and average amount of retries needed for basic calls - - Detects a node's Steem version + - Detects a node's Blockchain version - Show the node's last block number and block time - - Can determine whether a node is using Jussi, or if it's a raw steemd node - - Can scan a list of 10 nodes in as little as 20 seconds thanks to Twisted Reactor + request-threads + - Can determine whether a node is using Jussi, or if it's a raw `steemd` node + - Can scan a list of 20 nodes in as little as 10 seconds thanks to native Python AsyncIO plus + the [HTTPX AsyncIO requests library](https://github.com/encode/httpx) -Python 3.7.0 or higher recommended +Python 3.8.0 or higher strongly recommended + +Python 3.7.x may or may not work # Install +### Easy way + +```sh +git clone https://github.com/Someguy123/steem-rpc-scanner.git +cd steem-rpc-scanner + +./run.sh install ``` + +### Manual install (if the easy way isn't working) + +```sh +# You may need to install the default python version for your distro, for newer python versions +# to work properly (e.g. 'pip' and 'venv' may only be available as python3-pip and python3-venv) +apt install -y python3 python3-dev +apt install -y python3-pip python3-venv +# Python 3.8+ is recommended, if available on your system. +apt install -y python3.8 python3.8-dev +# If you don't have 3.8 available, python 3.7 may work. +apt install -y python3.7 python3.7-dev + +# Install pipenv using the newest version of Python on your system +python3.8 -m pip install -U pipenv + +# Clone the repo git clone https://github.com/Someguy123/steem-rpc-scanner.git cd steem-rpc-scanner -python3 -m venv venv -source venv/bin/activate -pip3 install -r requirements.txt -cp nodes.txt.example nodes.txt +# Create a virtualenv + install dependencies using pipenv +pipenv install +# Activate the virtualenv +pipenv shell +# Copy the example nodes.conf file into nodes.conf +cp example.nodes.conf nodes.conf ``` # Usage +### Scan a list of nodes and output their health info as a colourful table + For most people, the defaults are fine, so you can simply run: ``` @@ -58,8 +90,136 @@ optional arguments: -f NODEFILE specify a custom file to read nodes from (default: nodes.txt) ``` -# License +### Scan an individual node with UNIX return codes + +![Screenshot of RPC Scanner health.py](https://cdn.privex.io/github/rpc-scanner/rpcscanner_health_may2020.png) + +RPCScanner can easily be integrated with monitoring scripts by using `./health.py scan`, which returns a standard UNIX +error code based on whether that RPC is working properly or not. + +**Example 1** - Scanning fully functioning RPC node + +``` + +user@host ~/rpcscanner $ ./run.sh health -q scan "https://hived.privex.io/" + +Node: http://hived.privex.io/ +Status: PERFECT +Network: Hive +Version: 0.23.0 +Block: 43810613 +Time: 2020-05-29T00:30:24 (0:00:00 ago) +Plugins: 8 / 8 +PluginList: ['condenser_api.get_followers', 'bridge.get_trending_topics', 'condenser_api.get_accounts', 'condenser_api.get_witness_by_account', 'condenser_api.get_blog', 'condenser_api.get_content', 'condenser_api.get_account_history', 'account_history_api.get_account_history'] +PassedStages: 3 / 3 +Retries: 0 +Score: 50 (out of 50) + +user@host ~/rpcscanner $ echo $? +0 +``` + +As you can see, `hived.privex.io` got a perfect score of `20`, and thus it signalled the UNIX return code `0`, which means +"everything was okay". + +**Example 2** - Scanning a misbehaving RPC node + +``` +user@host ~/rpcscanner $ ./run.sh health -q scan "https://steemd.privex.io/" + +Node: http://steemd.privex.io/ +Status: BAD +Network: Steem +Version: error +Block: 43536277 +Time: 2020-05-20T13:59:57 (8 days, 10:31:40 ago) +Plugins: 4 / 8 +PluginList: ['condenser_api.get_account_history', 'condenser_api.get_witness_by_account', 'condenser_api.get_accounts', 'account_history_api.get_account_history'] +PassedStages: 2 / 3 +Retries: 0 +Score: 2 (out of 50) + +user@host ~/rpcscanner $ echo $? +8 -GNU AGPL 3.0 +``` + +Unfortunately, `steemd.privex.io` didn't do anywhere near as well as `hived.privex.io` - it scored a rather low `7 / 20`, with +only 4 of the 8 RPC calls working properly which were tested. + +This resulted in `health.py` signalling return code `8` instead (non-zero), which tells a calling program / script that +something went wrong during execution of this script. + +In this case, `8` is the default setting for `BAD_RETURN_CODE`, giving a clear signal to the caller that it's trying to tell it +"the passed RPC node's score is below the threshold and you should stop using it!". + +You can change the numeric return code used for both "good" and "bad" results from the individual node scanner by setting +`GOOD_RETURN_CODE` and/or `BAD_RETURN_CODE` respectively in `.env`: + +```env +# There isn't much reason to change GOOD_RETURN_CODE from the default of 0. But the option is there if you want it. +GOOD_RETURN_CODE=0 +# We can change BAD_RETURN_CODE from the default of 8, to 99 for example. +# Any integer value from 0 to 254 can generally be used. +BAD_RETURN_CODE=99 + +``` + +#### Making use of these return codes in an external script + +![Screenshot of extras/check_nodes.sh and py_check_nodes.py running](https://i.imgur.com/cm4DPVN.png) + +Included in the [extras folder of the repo](https://github.com/Someguy123/steem-rpc-scanner/tree/master/extras), are two +example scripts - one in plain old Bash (the default terminal shell of most Linux distro's and macOS), and a python script, +intended for use on Python 3. + +Both scripts do effectively the same thing - they load `nodes.txt`, skipping any commented out nodes, then check whether each +one is fully functional or not by calling `health.py scan NODE`, and check for a non-zero return code. Then outputting +either a green `UP NODE http://example.com` or a red `DOWN NODE http://example.com`. + +Pictured above is a screenshot of both the bash example, and the python example - running with the same node list, and same +version of this RPC Scanner. + +Handling program return codes is generally going to be the easiest in **shell scripting languages**, including Bash - as most +shell scripting languages are built around the UNIX methodology - everything is a file, language syntax is really just executing +programs with arguments, and return codes from those programs power the logic syntax etc. + +The most basic shell script would be a simple ``if`` call, using ``/path/to/health.py scan http://somenode`` as the ``if`` test. +Most shells such as Bash will read the return (exit) code of the program, treating 0 as "true" and everything else as "false". + +#### Basic shell script example + +```shell script +#!/usr/bin/env bash + +if /opt/rpcscanner/health.py scan "https://hived.privex.io" &> /dev/null; then + echo "hived.privex.io is UP :)" +else + echo "hived.privex.io is DOWN!!!" +fi +``` + + +# License -See file LICENSE \ No newline at end of file +[GNU AGPL 3.0](https://github.com/Someguy123/steem-rpc-scanner/blob/master/LICENSE) + +See file [LICENSE](https://github.com/Someguy123/steem-rpc-scanner/blob/master/LICENSE) + +# Common environment settings + + - `RPC_TIMEOUT` (default: `3`) Amount of seconds to wait for a response from an RPC node before giving up. + - `MAX_TRIES` (default: `3`) Maximum number of attempts to run each call against an RPC node. Note that this + number includes the initial try - meaning that setting `MAX_TRIES=1` will disable automatic retries for RPC calls. + + DO NOT set this to `0` or the scanner will simply think all nodes are broken. Setting `MAX_TRIES=0` may however be useful + if you need to simulate how an external application handles "DEAD" results from the scanner. + - `RETRY_DELAY` (default: `2.0`) Number of seconds to wait between retrying failed RPC calls. Can be a decimal number of seconds, + e.g. `0.15` would result in a 150ms retry delay. + - `PUB_PREFIX` (default: `STM`) The first 3 characters at the start of a public key on the network(s) you're testing. This + is used by `rpcscanner.MethodTests.MethodTests` for thorough "plugin tests" which validate that an account's public + keys look correct. + - `GOOD_RETURN_CODE` (default: `0`) The integer exit code returned by certain parts of RPCScanner, e.g. `health.py scan [node]` + when the given RPC node(s) are functioning fully. + - `BAD_RETURN_CODE` (default: `0`) The integer exit code returned by certain parts of RPCScanner, e.g. `health.py scan [node]` + when the given RPC node(s) are severely unstable or missing vital plugins. diff --git a/app.py b/app.py index c50cde6..d34d30a 100755 --- a/app.py +++ b/app.py @@ -8,27 +8,29 @@ Python 3.7.0 or higher recommended """ -from os.path import join +import dotenv +dotenv.load_dotenv() -from twisted.internet.defer import inlineCallbacks -from twisted.internet.task import react -from privex.loghelper import LogHelper +import asyncio from privex.helpers import ErrHelpParser -from rpcscanner import RPCScanner, settings, BASE_DIR, load_nodes +from rpcscanner import RPCScanner, settings, load_nodes, set_logging_level import logging import signal +log = logging.getLogger('rpcscanner.app') + + parser = ErrHelpParser(description='Scan RPC nodes from a list of URLs to determine their last block, ' 'version, reliability, and response time.') -parser.add_argument('-v', dest='verbose', action='store_true', default=False, help='display debugging') -parser.add_argument('-q', dest='quiet', action='store_true', default=False, help='only show warnings or worse') -parser.add_argument('-f', dest='nodefile', default='nodes.txt', - help='specify a custom file to read nodes from (default: nodes.txt)') -parser.add_argument('--account', dest='account', default='someguy123', - help='Steem username used for tests requiring an account to lookup') -parser.add_argument('--plugins', action='store_true', dest='plugins', default=False, - help='Run thorough plugin testing after basic filter tests complete.') -parser.set_defaults(verbose=False, quiet=False, plugins=False, account='someguy123') +parser.add_argument('-v', dest='verbose', action='store_true', help='display debugging') +parser.add_argument('-q', dest='quiet', action='store_true', help='only show warnings or worse') +parser.add_argument('-f', dest='nodefile', help=f'specify a custom file to read nodes from (default: {settings.node_file})') +parser.add_argument('--account', dest='account', help='Steem username used for tests requiring an account to lookup') +parser.add_argument('--plugins', action='store_true', dest='plugins', help='Run thorough plugin testing after basic filter tests complete.') +parser.set_defaults( + verbose=settings.verbose, quiet=settings.quiet, plugins=settings.plugins, + account=settings.test_account, nodefile=settings.node_file +) args = parser.parse_args() # Copy values of command line args into the application's settings. @@ -37,33 +39,28 @@ debug_level = logging.INFO -if settings.verbose: +if settings.quiet: + debug_level = logging.CRITICAL +elif settings.verbose: print('Verbose mode enabled.') debug_level = logging.DEBUG -elif settings.quiet: - debug_level = logging.WARNING else: print("For more verbose logging (such as detailed scanning actions), use `./app.py -v`") - print("For less output, use -q for quiet mode (display only warnings and errors)") + print("For less output, use -q for quiet mode (display only critical errors)") -f = logging.Formatter('[%(asctime)s]: %(funcName)-18s : %(levelname)-8s:: %(message)s') -lh = LogHelper(handler_level=debug_level, formatter=f) -lh.add_console_handler() -log = lh.get_logger() +set_logging_level(debug_level) -# s = requests.Session() - -@inlineCallbacks -def scan(reactor): +async def scan(): node_list = load_nodes(settings.node_file) - rs = RPCScanner(reactor, nodes=node_list) - yield from rs.scan_nodes() + rs = RPCScanner(nodes=node_list) + await rs.scan_nodes() rs.print_nodes() if __name__ == "__main__": - # Make CTRL-C work properly with Twisted's Reactor + # Make CTRL-C work properly with Twisted's Reactor / AsyncIO # https://stackoverflow.com/a/4126412/2648583 signal.signal(signal.SIGINT, signal.default_int_handler) - react(scan) + asyncio.run(scan()) + diff --git a/example.nodes.conf b/example.nodes.conf new file mode 100644 index 0000000..6585385 --- /dev/null +++ b/example.nodes.conf @@ -0,0 +1,85 @@ +########################################################################################## +# This is an example RPC scanner node list file. +# +# An RPC node should be formatted as a standard http / https URL, and may contain +# a port specified after the domain / IP using standard colon syntax: +# +# http://some.rpc.example.org:8091 +# +# If no port is specified, then standard HTTP ports will be used, i.e. port 80 for +# 'http' URLs, and port 443 for 'https' URLs. +# +# If an RPC node only works via a certain URL path, you may add it to the end of the URL, +# though if there isn't a specific path, you should leave the URL bare (no ending slash): +# +# https://another.example.org:4433/my/rpc +# +# The main RPC scanner code uses regex to detect valid URLs which requires a node +# URL line to start with http:// or https:// - and a URL can only contain the following +# characters: +# +# a-z A-Z 0-9 . / _ : - +# +# This means any line which doesn't start with http:// or https:// can be considered +# a comment - and any characters which don't fall into the character set listed above +# placed after a URL (including spaces) will start an in-line comment. +# +# Personally I believe it's best to use '#' for comments, since # is one of the most +# common characters used for comments in config files and programming languages. +# +# But you can also use ';' (semicolon) '"' (double quote) or even ' (apostrophe) to +# start a comment inside of a node list file if you prefer. +# +########################################################################################## + +########################################################################################## +# Hive RPCs # +########################################################################################## + +### @privex / @someguy123 RPCs and Seeds #### +https://hived.privex.io +https://direct.hived.privex.io +https://hiveseed-fin.privex.io +https://hiveseed-se.privex.io +############################################# + +https://anyx.io # @anyx RPC +https://hived.hive-engine.com # @aggroed RPC +https://techcoderx.com # @techcoderx RPC + +### @wehmoen / @3speak RPC ################## +https://hive.3speak.online +https://fin.hive.3speak.online +############################################# + +https://api.pharesim.me # @pharesim RPC +https://rpc.ausbit.dev # @ausbitbank RPC +https://rpc.esteem.app # @good-karma / @esteem RPC + +######### +# Hive RPCs (raw appbase - non-jussi) +######### + +http://direct.hived.privex.io:8291 +http://hived.hive-engine.com:8091 +http://direct.hived.privex.io:8293 + +########################################################################################## +# Steem RPCs # +########################################################################################## +https://api.steemit.com # Steemit Inc. +https://api.justyy.com # @justyy (steem) +https://steemd.privex.io # @privex (may be discontinued) +https://direct.steemd.privex.io # @privex (may be discontinued) +#https://steemd-appbase.steemit.com +#https://steemd.steemitstage.com +#https://rpc.buildteam.io +#https://rpc.steemviz.com +#https://rpc.steemliberator.com +#https://rpc.curiesteem.com +#https://steemd.minnowsupportproject.org + +########################################################################################## +# Whaleshares RPCs # +########################################################################################## +https://pubrpc.whaleshares.io diff --git a/extras/check_nodes.sh b/extras/check_nodes.sh new file mode 100755 index 0000000..fc1830b --- /dev/null +++ b/extras/check_nodes.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +################################################################################ +# This script is part of https://github.com/Someguy123/steem-rpc-scanner +# +# This is a very basic Bash script which shows how to easily call +# rpcscanner/health.py from a shell script, and interpret it's return +# code to decide whether the node is working or not. +# +################################################################################ +BOLD="" RED="" GREEN="" YELLOW="" BLUE="" MAGENTA="" CYAN="" WHITE="" RESET="" +if [ -t 1 ]; then + BOLD="$(tput bold)" RED="$(tput setaf 1)" GREEN="$(tput setaf 2)" YELLOW="$(tput setaf 3)" BLUE="$(tput setaf 4)" + MAGENTA="$(tput setaf 5)" CYAN="$(tput setaf 6)" WHITE="$(tput setaf 7)" RESET="$(tput sgr0)" +fi + +# directory where the script is located, so we can source files regardless of where PWD is +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# SCAN_DIR represents where the rpcscanner folder is. We default to 1 level above the folder containing this script. +: "${SCAN_DIR="$(cd "${DIR}/.." && pwd)"}" +: "${NODE_LIST="${SCAN_DIR}/nodes.conf"}" + +# We set the PYTHONPATH to the basefolder of rpcscanner, this allows project-level python imports +# i.e. 'from rpcscanner.core import something' to work correctly. +export PYTHONPATH="${SCAN_DIR}" + +# Read only valid URLs from nodes.conf into the array 'NODES' - ignore things like comments starting with # +mapfile -t NODES < <(sed -En 's/^(https?\:\/\/[a-zA-Z0-9./_:-]+).*/\1/p' "$NODE_LIST") + +#NODES=( +# "https://hived.privex.io" "https://rpc.ausbit.dev" "https://anyx.io" +#) + +check_node() { "$SCAN_DIR"/health.py scan "$1"; } + +node_is_healthy() { check_node "$1" &>/dev/null; } + +for n in "${NODES[@]}"; do + if node_is_healthy "$n"; then + echo "${BOLD}${GREEN}UP NODE${RESET} $n" + else + echo "${BOLD}${RED}DOWN NODE${RESET} $n" + fi +done diff --git a/extras/py_check_nodes.py b/extras/py_check_nodes.py new file mode 100755 index 0000000..352b5fe --- /dev/null +++ b/extras/py_check_nodes.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +################################################################################ +# This script is part of https://github.com/Someguy123/steem-rpc-scanner +# +# This is a very basic Python 3 script which shows how to easily call +# rpcscanner/health.py from a python script, and interpret it's return +# code to decide whether the node is working or not. +# +################################################################################ +import subprocess +import re +from os.path import dirname, abspath, join + +BOLD, RED, GREEN = '\033[1m', '\033[31m', '\033[32m' +RESET = '\033[39m' + +# Absolute path to the folder ABOVE the folder containing this script (i.e. where the RPC scanner code is) +BASE_DIR = dirname(dirname(abspath(__file__))) + +# Use regex to cleanly extract only valid URLs - ignore things like comments and broken URLs +RE_FIND_NODES = re.compile(r'^(https?://[a-zA-Z0-9./_:-]+).*?', re.MULTILINE) + +# Open the nodes.txt file from the rpcscanner folder and read it into an array of lines +with open(join(BASE_DIR, 'nodes.txt')) as fp: + _nodes = fp.read() + +# Filter excess whitespace from each node line, remove blank lines, and remove commented nodes +nodes = RE_FIND_NODES.findall(_nodes) +nodes = [n.strip() for n in nodes if len(n.strip()) > 0] + + +# Very simple function which simply runs 'health.py scan (node)', sends it's stdout/stderr to /dev/null, +# then returns the exit code as an integer. +def test_node(node: str) -> int: + p = subprocess.run( + [join(BASE_DIR, 'health.py'), 'scan', node], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + return p.returncode + + +# Test each node using health.py, print a green "UP NODE (node)" or red "DOWN NODE (node)" depending on whether +# the exit code was zero or not. +for n in nodes: + rc = test_node(n) + status = f"{BOLD}{GREEN}UP NODE" if rc == 0 else f"{BOLD}{RED}DOWN NODE" + print(f"{status:<25}{RESET}{n}") + diff --git a/health.py b/health.py index 0ba79fa..1713070 100755 --- a/health.py +++ b/health.py @@ -4,25 +4,28 @@ Designed for use in bash scripts """ +import dotenv +dotenv.load_dotenv() + import argparse +import asyncio +import logging import sys import textwrap import signal from datetime import datetime from typing import Tuple -from twisted.internet.defer import inlineCallbacks -from twisted.internet.task import react from privex.helpers import ErrHelpParser, empty -from rpcscanner import load_nodes, settings, RPCScanner -from rpcscanner.RPCScanner import NodeStatus +from rpcscanner import load_nodes, settings, RPCScanner, set_logging_level, clear_handlers +from rpcscanner.RPCScanner import NodeStatus, TOTAL_STAGES_TRACKED -MAX_SCORE = 20 +MAX_SCORE = 50 # How many normal tries are there? BASE_TRIES = 3 settings.plugins = True -settings.quiet = True +# settings.quiet = True help_text = textwrap.dedent('''\ @@ -65,9 +68,17 @@ epilog=help_text ) -parser.add_argument('-s', dest='min_score', type=int, default=MAX_SCORE - 5, +parser.add_argument('-s', dest='min_score', type=int, default=MAX_SCORE - 10, help=f'Minimum score required before assuming a node is good (1 to {MAX_SCORE})') +parser.add_argument('-q', '--quiet', dest='quiet', action='store_true', default=settings.quiet, + help=f'Quiet logging mode') + +parser.add_argument('-v', '--verbose', dest='verbose', action='store_true', default=False, + help=f'Verbose logging mode') + +parser.set_defaults(verbose=settings.verbose, quiet=settings.quiet) + subparser = parser.add_subparsers() @@ -77,7 +88,7 @@ def scan(opt): # Make CTRL-C work properly with Twisted's Reactor # https://stackoverflow.com/a/4126412/2648583 signal.signal(signal.SIGINT, signal.default_int_handler) - react(_scan, (opt.node, opt.min_score,)) + asyncio.run(_scan(opt.node, opt.min_score)) def list_nodes(opt): @@ -86,7 +97,8 @@ def list_nodes(opt): # Make CTRL-C work properly with Twisted's Reactor # https://stackoverflow.com/a/4126412/2648583 signal.signal(signal.SIGINT, signal.default_int_handler) - react(_list_nodes, (opt.detailed, opt.min_score,)) + asyncio.run(_list_nodes(opt.detailed, opt.min_score)) + # react(_list_nodes, (opt.detailed, opt.min_score,)) p_scan = subparser.add_parser('scan', description='Scan an individual node') @@ -101,21 +113,31 @@ def list_nodes(opt): args = parser.parse_args() +if args.quiet: + settings.quiet = True + settings.verbose = False + clear_handlers('rpcscanner', None) + set_logging_level(logging.CRITICAL, None) +elif args.verbose: + settings.quiet = False + settings.verbose = True + clear_handlers('rpcscanner', None) + set_logging_level(logging.DEBUG, None) + def iso_timestr(dt: datetime) -> str: """Convert datetime object into ISO friendly ``2010-03-03Z21:16:45``""" return str(dt.isoformat()).split('.')[0] -@inlineCallbacks -def _list_nodes(reactor, detailed, min_score): +async def _list_nodes(detailed, min_score): node_list = load_nodes(settings.node_file) - rs = RPCScanner(reactor, nodes=node_list) - yield from rs.scan_nodes(True) + rs = RPCScanner(nodes=node_list) + await rs.scan_nodes(True) if detailed: print('(Detailed Mode. This msg and row header are sent to stderr for easy removal)', file=sys.stderr) - print('Node Status Score Version Block Time Plugins', file=sys.stderr) + print('Node Status Score Version Block Time Plugins', file=sys.stderr) for n in rs.node_objs: score, _, status_name = score_node(min_score, n) if score < min_score: @@ -124,15 +146,14 @@ def _list_nodes(reactor, detailed, min_score): if detailed: p_tr, p_tot = n.plugin_counts dt = iso_timestr(n.block_time) if not empty(n.block_time) else 'Unknown' - print(f'{n.host} {status_name} {score} {n.version} {n.current_block} {dt} {p_tr}/{p_tot} ') + print(f'{n.host:<30} {status_name:<15} {score:<10} {n.version:<10} {n.current_block:<15} {dt:<15} {p_tr}/{p_tot} ') continue print(n.host) -@inlineCallbacks -def _scan(reactor, node, min_score): - rs = RPCScanner(reactor, nodes=[node]) - yield from rs.scan_nodes(True) +async def _scan(node, min_score): + rs = RPCScanner(nodes=[node]) + await rs.scan_nodes(True) n = rs.get_node(node) plug_tried, plug_total = n.plugin_counts @@ -143,14 +164,19 @@ def _scan(reactor, node, min_score): print("Node: {}\nStatus: DEAD".format(node)) return sys.exit(1) dt = iso_timestr(n.block_time) if not empty(n.block_time) else 'Unknown' + time_behind = "N/A" + if n.time_behind: + time_behind = str(n.time_behind).split('.')[0] print(f""" Node: {node} Status: {status_name} +Network: {n.network} Version: {n.version} Block: {n.current_block} -Time: {dt} +Time: {dt} ({time_behind} ago) Plugins: {plug_tried} / {plug_total} PluginList: {n.plugins} +PassedStages: {n.status} / {TOTAL_STAGES_TRACKED} Retries: {n.total_retries} Score: {score} (out of {MAX_SCORE}) """) @@ -182,19 +208,39 @@ def score_node(min_score: int, n: NodeStatus) -> Tuple[int, int, str]: # If a node has a status of "dead", it immediately scores zero points and returns a bad status. if status <= 0: return 0, 1, 'DEAD' - elif status == 1: # Unstable nodes lose 5 points + elif status == 1: # Unstable nodes lose 10 points + score += MAX_SCORE - 10 + elif status == 2: # Unreliable nodes lose 5 points score += MAX_SCORE - 5 - elif status >= 2: # Stable nodes start with full points + elif status >= 3: # Stable nodes start with full points score += MAX_SCORE - # Nodes lose half a score point for every retry needed - if n.total_retries > 0: score -= (n.total_tries / 2) + # Nodes lose two score points for every retry needed + if n.total_retries > 0: score -= (n.total_retries * 2) - # Nodes lose 2 points for each plugin that's responding incorrectly - if plug_tried < plug_total: score -= (plug_total - plug_tried) * 2 + # Nodes lose 4 points for each plugin that's responding incorrectly + if plug_tried < plug_total: score -= (plug_total - plug_tried) * 4 + + # Out-of-sync nodes lose between 5% and 80% of the max score depending on how far behind they are + if n.time_behind: + pre_score = int(score) + if n.time_behind.total_seconds() > 86400: score -= int(MAX_SCORE * 0.8) + elif n.time_behind.total_seconds() > 3600: score -= int(MAX_SCORE * 0.5) + elif n.time_behind.total_seconds() > 600: score -= int(MAX_SCORE * 0.3) + elif n.time_behind.total_seconds() > 300: score -= int(MAX_SCORE * 0.15) + elif n.time_behind.total_seconds() > 60: score -= int(MAX_SCORE * 0.10) + elif n.time_behind.total_seconds() > 30: score -= int(MAX_SCORE * 0.05) + # If the node we're scoring was brought below a 10% score due to these time penalties, then we check it's score prior + # to the time penalty, and boost the score up to as high as 20%, depending on how well the node previously scored. + # This ensures that nodes which are functional, but just severely out of sync, aren't marked as completely dead. + if score < int(MAX_SCORE * 0.1): + if pre_score > int(MAX_SCORE * 0.8): score = int(MAX_SCORE * 0.2) + if pre_score > int(MAX_SCORE * 0.5): score = int(MAX_SCORE * 0.1) + if pre_score > int(MAX_SCORE * 0.2): score = int(MAX_SCORE * 0.05) score = int(score) - return_code = 1 if score < min_score else 0 + score = 0 if score < 0 else score + return_code = settings.BAD_RETURN_CODE if score < min_score else settings.GOOD_RETURN_CODE status_name = 'BAD' if score < min_score else 'GOOD' status_name = 'PERFECT' if score >= MAX_SCORE else status_name return score, return_code, status_name diff --git a/logs/.gitignore b/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/nodes.txt.example b/nodes.txt.example deleted file mode 100644 index 632c9dc..0000000 --- a/nodes.txt.example +++ /dev/null @@ -1,12 +0,0 @@ -https://steemd.privex.io -https://direct.steemd.privex.io -https://api.steemit.com -https://steemd-appbase.steemit.com -https://steemd.steemitstage.com -https://rpc.buildteam.io -https://gtg.steem.house:8090 -https://rpc.steemviz.com -https://rpc.steemliberator.com -#https://rpc.curiesteem.com -https://steemd.minnowsupportproject.org -http://steemseed-fin.privex.io:8091 \ No newline at end of file diff --git a/rpcscanner/MethodTests.py b/rpcscanner/MethodTests.py index c2a10af..391e9a4 100644 --- a/rpcscanner/MethodTests.py +++ b/rpcscanner/MethodTests.py @@ -1,11 +1,14 @@ -import twisted.internet.reactor -from twisted.internet.defer import inlineCallbacks +import asyncio -from rpcscanner.core import PUB_PREFIX +# import twisted.internet.reactor +from privex.helpers import DictObject, empty_if +# from twisted.internet.defer import inlineCallbacks + +from rpcscanner.settings import PUB_PREFIX from rpcscanner.rpc import rpc from rpcscanner.exceptions import ValidationError from rpcscanner import settings -from typing import List, Dict, Tuple +from typing import List, Dict, Tuple, Union, Awaitable, Coroutine import logging log = logging.getLogger(__name__) @@ -17,151 +20,197 @@ class MethodTests: Basic usage: - >>> mt = MethodTests('https://steemd.privex.io', reactor) + >>> mt = MethodTests('https://steemd.privex.io') >>> try: - ... res, time_taken, total_retries = yield mt.test('account_history_api.get_account_history') + ... res, time_taken, total_retries = await mt.test('account_history_api.get_account_history') >>> except Exception: ... log.exception('Account history test failed for steemd.privex.io') """ - def __init__(self, host: str, reactor: twisted.internet.reactor): - self.host, self.reactor = host, reactor + def __init__(self, host: str): + self.host = host self.test_acc = settings.test_account.lower().strip() self.test_post = settings.test_post.strip() - self.METHOD_MAP = { - 'account_history_api.get_account_history': self.test_account_history, - 'condenser_api.get_account_history': self.test_condenser_history, - 'condenser_api.get_accounts': self.test_condenser_account, - 'condenser_api.get_blog': self.test_get_blog, - 'condenser_api.get_content': self.test_get_content, - 'condenser_api.get_followers': self.test_get_followers, - 'condenser_api.get_witness_by_account': self.test_condenser_witness, - 'bridge.get_trending_topics': self.test_bridge_trending_topics - } - - @inlineCallbacks - def test(self, api_name): + self.loop = asyncio.get_event_loop() + + async def test(self, api_name: str) -> Tuple[Union[list, dict], float, int]: """Call a test method by the API method name""" log.debug(f'MethodTests.test now calling API {api_name}') - res = yield self.METHOD_MAP[api_name]() + res = await METHOD_MAP[api_name](self, self.host) # log.debug(f'MethodTest.test got result for {api_name}: {res}') return res + + async def test_all(self, whitelist: List[str] = None, blacklist: List[str] = None) -> Union[DictObject, Dict[str, dict]]: + """ + Tests all supported RPC methods by :class:`.MethodTests` against :attr:`.host`, optionally specifying either a + ``whitelist`` or ``blacklist`` + + This method returns a :class:`.DictObject` containing three keys:: + + - ``methods`` - A :class:`.dict` of JSONRPC string methods mapped to booleans, with ``True`` meaning the + method was tested without any problems, while ``False`` means that an error occurred while testing this method. + + - ``errors`` - A :class:`.dict` which contains JSONRPC string methods mapped to the :class:`.Exception` which they raised. + Only methods which raised an exception (i.e. those marked ``False`` in the ``methods`` dict) will be present here. + + - ``results`` - A :class:`.dict` which contains JSONRPC string methods mapped to the result their class testing method returned. + This is usually a ``Tuple[Union[list,dict], float, int]``, which contains ``(response, time_taken_sec, tries)`` + + :param List[str] whitelist: A list of JSONRPC methods to exclusively test, e.g. + ``['account_history_api.get_account_history', 'condenser_api.get_accounts']`` + :param List[str] blacklist: A list of JSONRPC methods to skip testing, e.g. ``['bridge.get_trending_topics']`` + :return Union[DictObject,Dict[str, dict]] res: A :class:`.DictObject` containing three :class:`.dict`'s: ``methods``, ``errors``, + and ``results``. Full explanation of returned object in main pydoc body for this + method :meth:`.test_all` + """ + res = DictObject(methods={}, errors={}, results={}) + tasks = [] + for meth, func in METHOD_MAP.items(): + if whitelist is not None and meth not in whitelist: + log.debug("Skipping RPC method %s against host %s as method is not present in whitelist.", meth, self.host) + continue + if blacklist is not None and meth in blacklist: + log.debug("Skipping RPC method %s against host %s as method is present in blacklist.", meth, self.host) + continue + tasks.append(self.loop.create_task(self._test_meth(func, meth))) + for t in tasks: + status, result, error, meth = await t + res.methods[meth] = status + if result is not None: res.results[meth] = result + if error is not None: res.errors[meth] = error + return res + + async def _test_meth(self, func: Union[Coroutine, Awaitable, callable], meth: str): + + status, result, error = False, None, None + try: + log.debug("Testing RPC method %s against host %s", meth, self.host) + result = await func(self, self.host) + status = True + log.debug("Successfully ran RPC method %s against host %s", meth, self.host) + except Exception as e: + log.exception("Error while testing method %s on host %s", meth, self.host) + status = False + error = e + + return status, result, error, meth # @retry_on_err(max_retries=MAX_TRIES) - @inlineCallbacks - def test_account_history(self): + async def test_account_history(self, host=None, *args, **kwargs) -> Tuple[Union[list, dict], float, int]: """Test a node for functioning account_history_api account history""" + host = empty_if(host, self.host) mtd = 'account_history_api.get_account_history' params = dict(account=self.test_acc, start=-1, limit=100) - res, tt, tr = yield rpc(self.reactor, self.host, mtd, params) + res, tt, tr = await rpc(host=host, method=mtd, params=params) - log.debug(f'History check if result from {self.host} has history key') + log.debug(f'History check if result from {host} has history key') if 'history' not in res: - raise ValidationError(f"JSON key 'history' not found in RPC query for node {self.host}") + raise ValidationError(f"JSON key 'history' not found in RPC query for node {host}") self._check_hist(res['history']) return res, tt, tr - @inlineCallbacks - def test_bridge_trending_topics(self): + async def test_bridge_trending_topics(self, host=None, *args, **kwargs) -> Tuple[Union[list, dict], float, int]: """Test a node for functioning bridge.get_trending_topics""" + host = empty_if(host, self.host) mtd = 'bridge.get_trending_topics' count = 10 params = {"limit": count} - res, tt, tr = yield rpc(self.reactor, self.host, mtd, params) + res, tt, tr = await rpc(host=host, method=mtd, params=params) - log.debug(f'bridge.get_trending_topics check if result from {self.host} has valid trending topics') + log.debug(f'bridge.get_trending_topics check if result from {host} has valid trending topics') for a in res: if len(a) != 2: - raise ValidationError(f"Community result contained {len(a)} items (expected 2) in bridge.get_trending_topics response from {self.host}") + raise ValidationError(f"Community result contained {len(a)} items (expected 2) in bridge.get_trending_topics response from {host}") if 'hive-' not in a[0]: - raise ValidationError(f"Invalid community '{a[0]}' in bridge.get_trending_topics response from {self.host}") + raise ValidationError(f"Invalid community '{a[0]}' in bridge.get_trending_topics response from {host}") return res, tt, tr - @inlineCallbacks - def test_get_blog(self): + + async def test_get_blog(self, host=None, *args, **kwargs) -> Tuple[Union[list, dict], float, int]: """Test a node for functioning full node get_blog""" + host = empty_if(host, self.host) mtd = 'condenser_api.get_blog' params = [self.test_acc, -1, 10] - res, tt, tr = yield rpc(self.reactor, self.host, mtd, params) + res, tt, tr = await rpc(host=host, method=mtd, params=params) - log.debug(f'get_blog check if result from {self.host} has blog, entry_id, comment, and comment.body') + log.debug(f'get_blog check if result from {host} has blog, entry_id, comment, and comment.body') self._check_blog(res) return res, tt, tr - @inlineCallbacks - def test_get_content(self): + async def test_get_content(self, host=None, *args, **kwargs) -> Tuple[Union[list, dict], float, int]: """Test a node for functioning full node get_content""" + host = empty_if(host, self.host) mtd = 'condenser_api.get_content' params = [self.test_acc, self.test_post] - res, tt, tr = yield rpc(self.reactor, self.host, mtd, params) + res, tt, tr = await rpc(host=host, method=mtd, params=params) - log.debug(f'get_content check if result from {self.host} has title, author and body') + log.debug(f'get_content check if result from {host} has title, author and body') self._check_blog_item(res) return res, tt, tr - @inlineCallbacks - def test_get_followers(self): + async def test_get_followers(self, host=None, *args, **kwargs) -> Tuple[Union[list, dict], float, int]: """Test a node for functioning full node get_followers""" + host = empty_if(host, self.host) mtd = 'condenser_api.get_followers' count = 10 params = [self.test_acc, None, "blog", count] - res, tt, tr = yield rpc(self.reactor, self.host, mtd, params) + res, tt, tr = await rpc(host=host, method=mtd, params=params) - - log.debug(f'Length check if result from {self.host} has at least {count} results') + log.debug(f'Length check if result from {host} has at least {count} results') follow_len = len(res) if follow_len < count: - raise ValidationError(f"Too little followers. Only {follow_len} follower results (<{count}) for {self.host}") + raise ValidationError(f"Too little followers. Only {follow_len} follower results (<{count}) for {host}") - log.debug(f'get_followers check if result from {self.host} has valid follower items') + log.debug(f'get_followers check if result from {host} has valid follower items') for follower in res: self._check_follower(follower) return res, tt, tr + # @retry_on_err(max_retries=MAX_TRIES) - @inlineCallbacks - def test_condenser_history(self): + async def test_condenser_history(self, host=None, *args, **kwargs) -> Tuple[Union[list, dict], float, int]: """Test a node for functioning condenser_api account history""" + host = empty_if(host, self.host) mtd = 'condenser_api.get_account_history' params = [self.test_acc, -100, 100] - res, tt, tr = yield rpc(self.reactor, self.host, mtd, params) + res, tt, tr = await rpc(host=host, method=mtd, params=params) self._check_hist(res) return res, tt, tr # @retry_on_err(max_retries=MAX_TRIES) - @inlineCallbacks - def test_condenser_account(self): + async def test_condenser_account(self, host=None, *args, **kwargs) -> Tuple[Union[list, dict], float, int]: """Test a node for functioning condenser_api get_accounts query""" + host = empty_if(host, self.host) mtd, params = 'condenser_api.get_accounts', [ [self.test_acc], ] - res, tt, tr = yield rpc(self.reactor, self.host, mtd, params) + res, tt, tr = await rpc(host=host, method=mtd, params=params) # Normal python exceptions such as IndexError should be thrown if the data isn't formatted correctly acc = res[0] - log.debug(f'Checking if result from {self.host} has user {self.test_acc}') + log.debug(f'Checking if result from {host} has user {self.test_acc}') if acc['name'].lower().strip() != self.test_acc: - raise ValidationError(f"Account {acc['name']} was returned, but expected {self.test_acc} for node {self.host}") - log.debug(f'Success - result from {self.host} has user {self.test_acc}') + raise ValidationError(f"Account {acc['name']} was returned, but expected {self.test_acc} for node {host}") + log.debug(f'Success - result from {host} has user {self.test_acc}') return res, tt, tr # @retry_on_err(max_retries=MAX_TRIES) - @inlineCallbacks - def test_condenser_witness(self): + async def test_condenser_witness(self, host=None, *args, **kwargs) -> Tuple[Union[list, dict], float, int]: """Test a node for functioning witness lookup (get_witness_by_account)""" + host = empty_if(host, self.host) mtd, params = 'condenser_api.get_witness_by_account', [self.test_acc] - res, tt, tr = yield rpc(self.reactor, self.host, mtd, params) + res, tt, tr = await rpc(host=host, method=mtd, params=params) if res['owner'].lower().strip() != self.test_acc: - raise ValidationError(f"Witness {res['owner']} was returned, but expected {self.test_acc} for node {self.host}") + raise ValidationError(f"Witness {res['owner']} was returned, but expected {self.test_acc} for node {host}") prf = res['signing_key'][0:3] if prf != PUB_PREFIX: - raise ValidationError(f"Signing key prefix was {prf} but expected {PUB_PREFIX} for node {self.host}") + raise ValidationError(f"Signing key prefix was {prf} but expected {PUB_PREFIX} for node {host}") return res, tt, tr @@ -206,8 +255,6 @@ def _check_blog(self, response: List[dict], count=10): if 'body' not in item['comment']: raise ValidationError(f"JSON key 'body' not found in 'comment' dict from RPC query for node {self.host}") - - def _check_hist(self, response: dict): """Small helper function to verify an RPC response contains valid account history records""" @@ -221,4 +268,20 @@ def _check_hist(self, response: dict): log.debug(f'Length check if result from {self.host} has at least 5 results') hist_len = len(res) if hist_len < 5: - raise ValidationError(f"Too little history. Only {hist_len} history results (<5) for {self.host}") \ No newline at end of file + raise ValidationError(f"Too little history. Only {hist_len} history results (<5) for {self.host}") + + +METHOD_MAP = { + 'account_history_api.get_account_history': MethodTests.test_account_history, + 'condenser_api.get_account_history': MethodTests.test_condenser_history, + 'condenser_api.get_accounts': MethodTests.test_condenser_account, + 'condenser_api.get_blog': MethodTests.test_get_blog, + 'condenser_api.get_content': MethodTests.test_get_content, + 'condenser_api.get_followers': MethodTests.test_get_followers, + 'condenser_api.get_witness_by_account': MethodTests.test_condenser_witness, + 'bridge.get_trending_topics': MethodTests.test_bridge_trending_topics +} + + +if len(settings.TEST_PLUGINS_LIST) == 0: + settings.TEST_PLUGINS_LIST = tuple(METHOD_MAP.keys()) diff --git a/rpcscanner/RPCScanner.py b/rpcscanner/RPCScanner.py index 04e49b6..6f65073 100644 --- a/rpcscanner/RPCScanner.py +++ b/rpcscanner/RPCScanner.py @@ -1,15 +1,16 @@ -from dataclasses import dataclass, field -from datetime import datetime -from typing import List, Tuple - -import twisted.internet.reactor +import asyncio import logging +from asyncio import Task +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import List, Tuple, Dict, Coroutine, Union, Awaitable, Optional + +import pytz from colorama import Fore from dateutil.parser import parse -from privex.helpers import empty -from twisted.internet.defer import inlineCallbacks +from privex.helpers import empty, Dictable, DictObject, T, empty_if, convert_datetime from rpcscanner.MethodTests import MethodTests -from rpcscanner.core import TEST_PLUGINS_LIST +from rpcscanner.settings import TEST_PLUGINS_LIST from rpcscanner.rpc import rpc, identify_node from rpcscanner.exceptions import ServerDead from rpcscanner import settings @@ -18,7 +19,7 @@ @dataclass -class NodeStatus: +class NodeStatus(Dictable): host: str raw: dict timing: dict @@ -29,6 +30,7 @@ class NodeStatus: current_block: int = None block_time: datetime = None version: str = None + network: str = None _statuses = { 0: "Dead", @@ -73,6 +75,13 @@ def plugin_counts(self) -> Tuple[int, int]: """Returns as a tuple: how many plugins worked, and how many were tested""" return len(self.plugins), len(TEST_PLUGINS_LIST) + @property + def time_behind(self) -> Optional[timedelta]: + if empty(self.block_time): return None + dt = convert_datetime(self.block_time).replace(tzinfo=pytz.UTC) + now = datetime.utcnow().replace(tzinfo=pytz.UTC) + return now - dt + def __post_init__(self): bt = self.block_time if not empty(bt): @@ -82,46 +91,76 @@ def __post_init__(self): self.block_time = parse(bt) -class RPCScanner: +NETWORK_COINS = DictObject( + STEEM='Steem', SBD='Steem', SP='Steem', + HIVE='Hive', HBD='Hive', HP='Hive', + GOLOS='Golos', GBG='Golos', GP='Golos', + WLS='Whaleshares' +) + +TOTAL_STAGES_TRACKED = 3 +"""Amount of :class:`.RPCScanner` stages that count towards a node's status number""" + + +def _find_key(obj: dict, key: T, search='in', case_sensitive: bool = False) -> Optional[T]: + if not case_sensitive: key = key.lower() + for k in obj.keys(): + lk = k if case_sensitive else k.lower() + if search == 'in' and key in lk: return k + if search.startswith('end') and lk.endswith(key): return k + if search.startswith('start') and lk.startswith(key): return k + return None + - def __init__(self, reactor: twisted.internet.reactor, nodes: list): +class RPCScanner: + nodes: List[str] + loop: asyncio.AbstractEventLoop + node_status: Dict[str, dict] + up_nodes: List[Tuple[str, str, Task]] + conf_nodes: List[Tuple[str, Task]] + prop_nodes: List[Tuple[str, Task]] + ver_nodes: List[Tuple[str, Task]] + + def __init__(self, nodes: list, loop: asyncio.AbstractEventLoop = None): self.conf_nodes = [] self.prop_nodes = [] self.ver_nodes = [] - self.reactor = reactor + # self.reactor = reactor self.node_status = {} self.ident_nodes = [] self.up_nodes = [] self.nodes = nodes self.req_success = 0 + if loop is None: + loop = asyncio.get_event_loop() + self.loop = loop - @inlineCallbacks - def scan_nodes(self, quiet=False): + async def scan_nodes(self, quiet=False): def p(*args): if not quiet: print(*args) - reactor = self.reactor + # reactor = self.reactor p('Scanning nodes... Please wait...') p('{}[Stage 1 / 4] Identifying node types (jussi/appbase){}'.format(Fore.GREEN, Fore.RESET)) for node in self.nodes: self.node_status[node] = dict( raw={}, timing={}, tries={}, plugins=[], current_block='error', block_time='error', version='error', - srvtype='err' + srvtype='err', network='err' ) - self.ident_nodes.append((node, identify_node(reactor, node))) + self.ident_nodes.append((node, self.add_task(identify_node(node)))) - yield from self.identify_nodes() + await self.identify_nodes() p('{}[Stage 2 / 4] Filtering out bad nodes{}'.format(Fore.GREEN, Fore.RESET)) - yield from self.filter_badnodes() + await self.filter_badnodes() p('{}[Stage 3 / 4] Obtaining steemd versions {}'.format(Fore.GREEN, Fore.RESET)) - yield from self.scan_versions() + await self.scan_versions() p('{}[Stage 4 / 4] Checking current block / block time{}'.format(Fore.GREEN, Fore.RESET)) - yield from self.scan_block_info() + await self.scan_block_info() if settings.plugins: p('{}[Thorough Plugin Check] User specified --plugins. Now running thorough plugin tests for ' @@ -134,31 +173,31 @@ def p(*args): log.info(f'Skipping node {host} as it appears to be dead.') continue log.info(f'{Fore.BLUE} > Running plugin tests for node {host} ...{Fore.RESET}') - mt = MethodTests(host, reactor) + mt = MethodTests(host) for plugin in TEST_PLUGINS_LIST: - pt_list.append((host, self.plugin_test(host, plugin, mt))) + pt_list.append((host, self.add_task(self.plugin_test(host, plugin, mt)))) finished = [] for host, pt in pt_list: - yield from pt + await pt if host not in finished: finished.append(host) log.info(f'{Fore.GREEN} (+) Finished plugin tests for node {host} ... {Fore.RESET}') - @inlineCallbacks - def plugin_test(self, host: str, plugin_name: str, mt: MethodTests): + async def plugin_test(self, host: str, plugin_name: str, mt: MethodTests): ns = self.node_status[host] try: log.debug(f' >>> Testing {plugin_name} for node {host} ...') - res = yield mt.test(plugin_name) + res, time_secs, tries = await mt.test(plugin_name) ns['plugins'].append(plugin_name) + ns['tries'][f'plugin_{plugin_name}'] = tries + ns['timing'][f'plugin_{plugin_name}'] = time_secs log.debug(f'{Fore.GREEN} +++ The API {plugin_name} is functioning for node {host}{Fore.RESET}') return res except Exception as e: log.error( f'{Fore.RED} !!! The API {plugin_name} test failed for node {host}: {type(e)} {str(e)} {Fore.RESET}') - @inlineCallbacks - def identify_nodes(self): + async def identify_nodes(self): """ Scans each node listed in :py:attr:`.ident_nodes` to attempt to identify whether the node is behind Jussi, the node is pure appbase, or the node only supports websockets. @@ -169,11 +208,10 @@ def identify_nodes(self): by :py:meth:`.filter_badnodes` """ - reactor = self.reactor for host, id_data in self.ident_nodes: ns = self.node_status[host] try: - c = yield id_data + c = await id_data ident, ident_time, ident_tries = c log.info(Fore.GREEN + 'Successfully obtained server type for node %s' + Fore.RESET, host) @@ -182,11 +220,17 @@ def identify_nodes(self): ns['tries']['ident'] = ident_tries if ns['srvtype'] == 'jussi': log.info('Server {} is JUSSI'.format(host)) - self.up_nodes.append((host, ns['srvtype'], rpc(reactor, host, 'get_dynamic_global_properties'))) - if ns['srvtype'] == 'appbase': + meth = 'condenser_api.get_dynamic_global_properties' + elif ns['srvtype'] == 'appbase': log.info('Server {} is APPBASE (no jussi)'.format(host)) - self.up_nodes.append( - (host, ns['srvtype'], rpc(reactor, host, 'condenser_api.get_dynamic_global_properties'))) + meth = 'condenser_api.get_dynamic_global_properties' + elif ns['srvtype'] == 'legacy': + log.info('Server {} is LEGACY ??? (no jussi)'.format(host)) + meth = 'database_api.get_dynamic_global_properties' + else: + raise ServerDead(f"Unknown server type {ns['srvtype']}") + self.up_nodes.append( + (host, ns['srvtype'], self.rpc_task(host, meth))) self.req_success += 1 except ServerDead as e: log.error(Fore.RED + '[ident jussi]' + str(e) + Fore.RESET) @@ -196,8 +240,32 @@ def identify_nodes(self): log.warning(Fore.RED + 'Unknown error occurred (ident jussi)...' + Fore.RESET) log.warning('[%s] %s', type(e), str(e)) - @inlineCallbacks - def filter_badnodes(self): + def add_task(self, coro: Union[Awaitable, Coroutine]) -> Task: + """Helper method which creates an AsyncIO task from a passed coroutine using :attr:`.loop`""" + return self.loop.create_task(coro) + + def add_tasks(self, *tasks) -> List[Task]: + """Helper method which creates a list of AsyncIO tasks from passed coroutines using :attr:`.loop`""" + added_tasks = [] + for t in tasks: + added_tasks.append(self.loop.create_task(t)) + return added_tasks + + def rpc_tasks(self, host: str, *calls: str, params: List[Union[dict, list]] = None) -> List[Task]: + tasks = [] + calls = list(calls) + for i, c in enumerate(calls): + if not empty(params, itr=True) and len(params) > i: + tasks.append(self.add_task(rpc(host, c, params[i]))) + else: + tasks.append(self.add_task(rpc(host, c))) + return tasks + + def rpc_task(self, host: str, call: str, params: Union[dict, list] = None) -> Task: + t = self.rpc_tasks(host, call, params=params) + return t[0] + + async def filter_badnodes(self): """ Loads the dynamic properties for each host listed in :py:attr:`.up_nodes` to verify they're functioning. @@ -207,21 +275,19 @@ def filter_badnodes(self): prop_nodes = self.prop_nodes conf_nodes = self.conf_nodes ver_nodes = self.ver_nodes - reactor = self.reactor for host, srvtype, blkdata in self.up_nodes: ns = self.node_status[host] try: - c = yield blkdata + c = await blkdata # if it didn't except, then we're probably fine. we don't care about the block data # because it will be outdated due to bad nodes. will get it later - if srvtype == 'jussi': - conf_nodes.append((host, rpc(reactor, host, 'get_config'))) - prop_nodes.append((host, rpc(reactor, host, 'get_dynamic_global_properties'))) - ver_nodes.append((host, rpc(reactor, host, 'get_version'))) - if srvtype == 'appbase': - conf_nodes.append((host, rpc(reactor, host, 'condenser_api.get_config'))) - prop_nodes.append((host, rpc(reactor, host, 'condenser_api.get_dynamic_global_properties'))) - ver_nodes.append((host, rpc(reactor, host, 'condenser_api.get_version'))) + x, y = 'condenser_api.get_dynamic_global_properties', 'condenser_api.get_version' + if srvtype == 'legacy': + x, y = 'database_api.get_dynamic_global_properties', 'database_api.get_config' + tsk = self.rpc_tasks(host, x, y) + ns['raw']['init_props'] = blkdata + prop_nodes.append((host, tsk[0])) + ver_nodes.append((host, tsk[1])) log.info(Fore.GREEN + 'Node %s seems fine' + Fore.RESET, host) except ServerDead as e: log.error(Fore.RED + '[badnodefilter]' + str(e) + Fore.RESET) @@ -232,8 +298,7 @@ def filter_badnodes(self): log.warning('[%s] %s', type(e), str(e)) return prop_nodes, conf_nodes - @inlineCallbacks - def scan_block_info(self): + async def scan_block_info(self): """ Scans each host in :py:attr:`.prop_nodes` (populated by :py:meth:`.filter_badnodes` ) to obtain: - Current block number (head_block_number) @@ -244,17 +309,18 @@ def scan_block_info(self): for host, prdata in self.prop_nodes: ns = self.node_status[host] try: - # head_block_number - # time (UTC) - props, props_time, props_tries = yield prdata + # 'head_block_number', 'time' (UTC), 'current_supply' + props, props_time, props_tries = await prdata log.debug(Fore.GREEN + 'Successfully obtained props' + Fore.RESET) ns['raw']['props'] = props ns['timing']['props'] = props_time ns['tries']['props'] = props_tries ns['current_block'] = props.get('head_block_number', 'Unknown') ns['block_time'] = props.get('time', 'Unknown') + # Obtain the native network coin from the current_supply, and use it to try and identify what chain this is. + coin = props.get('current_supply', 'UNKNOWN UNKNOWN').split()[1].upper() + ns['network'] = NETWORK_COINS.get(coin, 'Unknown') self.req_success += 1 - except ServerDead as e: log.error(Fore.RED + '[load props]' + str(e) + Fore.RESET) # log.error(str(e)) @@ -264,8 +330,7 @@ def scan_block_info(self): log.warning(Fore.RED + 'Unknown error occurred (prop)...' + Fore.RESET) log.warning('[%s] %s', type(e), str(e)) - @inlineCallbacks - def scan_versions(self): + async def scan_versions(self): """ Scans each host in :py:attr:`.ver_nodes` (populated by :py:meth:`.filter_badnodes`) to obtain the Steem version number of each node. @@ -275,15 +340,20 @@ def scan_versions(self): for host, cfdata in self.ver_nodes: ns = self.node_status[host] try: - # config, config_time, config_tries = rpc(node, 'get_config') - c = yield cfdata + c = await cfdata config, config_time, config_tries = c log.info(Fore.GREEN + 'Successfully obtained version for node %s' + Fore.RESET, host) ns['raw']['config'] = config ns['timing']['config'] = config_time ns['tries']['config'] = config_tries - ns['version'] = config.get('blockchain_version', 'Unknown') + ns['version'] = 'Unknown' + # For legacy Steem-based networks, we scan the output of get_config for a key ending with blockchain_version + if ns['srvtype'] == 'legacy': + k = _find_key(config, 'blockchain_version', search='ends') + ns['version'] = empty_if(k, 'Unknown', config.get(k, 'Unknown')) + else: # For more modern Steem-based networks, we can just grab blockchain_version from get_version + ns['version'] = config.get('blockchain_version', 'Unknown') self.req_success += 1 except ServerDead as e: log.error(Fore.RED + '[load config]' + str(e) + Fore.RESET) @@ -295,30 +365,43 @@ def scan_versions(self): @property def node_objs(self) -> List[NodeStatus]: + """Return all node info from :attr:`.node_status` as a list of :class:`.NodeStatus` instances""" return [NodeStatus(host=h, **n) for h, n in self.node_status.items()] def get_node(self, node: str) -> NodeStatus: + """Retrieve node info for an individual node from :attr:`.node_status` as a :class:`.NodeStatus` instances""" n = self.node_status[node] return NodeStatus(host=node, **n) def print_nodes(self): + """ + Pretty print the node status information from :attr:`.node_status` in a colour coded table, with cleanly + padded columns for easy readability. + """ list_nodes = self.node_status - print(Fore.BLUE, '(S) - SSL, (H) - HTTP : (A) - normal appbase (J) - jussi', Fore.RESET) + print(Fore.BLUE, '(S) - SSL, (H) - HTTP : (A) - appbase (J) - jussi (L) - legacy', Fore.RESET) print(Fore.BLUE, end='', sep='') - fmt_params = ['Server', 'Status', 'Head Block', 'Block Time', 'Version', 'Res Time', 'Avg Retries'] - fmt_str = '{:<45}{:<10}{:<15}{:<25}{:<15}{:<10}{:<15}' + fmt_params = ['Server', 'Status', 'Head Block', 'Block Time', 'Version', 'Network', 'Res Time', 'Avg Retries'] + fmt_str = '{:<45}{:<20}{:<15}{:<25}{:<15}{:<15}{:<10}{:<15}' if settings.plugins: fmt_str += '{:<15}' - fmt_params.append('Plugin Tests') + fmt_params.append('API Tests') print(fmt_str.format(*fmt_params)) print(Fore.RESET, end='', sep='') for host, data in list_nodes.items(): statuses = { 0: Fore.RED + "DEAD", - 1: Fore.YELLOW + "UNSTABLE", - 2: Fore.GREEN + "Online", + 1: Fore.LIGHTRED_EX + "UNSTABLE", + 2: Fore.YELLOW + "Unreliable", + 3: Fore.GREEN + "Online", } + data = DictObject(data) + ns = self.get_node(host) + # Decide on the node's status based on how many test stages the status = statuses[len(data['raw'])] + + # Calculate the average response time of this node by totalling the timing seconds, and dividing them + # by the amount of individual timing events avg_res = 'error' if len(data['timing']) > 0: time_total = 0.0 @@ -326,7 +409,9 @@ def print_nodes(self): time_total += time avg_res = time_total / len(data['timing']) avg_res = '{:.2f}'.format(avg_res) - + + # Calculate the average tries required per successful call by summing up the total amount of tries, + # and dividing that by the length of the 'tries' dict (individual calls / tests that were tried) avg_tries = 'error' if len(data['tries']) > 0: tries_total = 0 @@ -334,32 +419,45 @@ def print_nodes(self): tries_total += tries avg_tries = tries_total / len(data['tries']) avg_tries = '{:.2f}'.format(avg_tries) + + if ns.time_behind: + if ns.time_behind.total_seconds() >= 60: + status = f"{Fore.LIGHTRED_EX}Out-of-sync" + + # If there were any moderate errors while testing the node, change the status from green to yellow, and + # change the status to the error state if 'err_reason' in data: status = Fore.YELLOW + data['err_reason'] + + # Replace the long http:// | https:// URI prefix with a short, clean character in brackets host = host.replace('https://', '(S)') host = host.replace('http://', '(H)') - if data['srvtype'] == 'jussi': - host = "{}(J){} {}".format(Fore.GREEN, Fore.RESET, host) - elif data['srvtype'] == 'appbase': - host = "{}(A){} {}".format(Fore.BLUE, Fore.RESET, host) - else: - host = "{}(?){} {}".format(Fore.RED, Fore.RESET, host) - fmt_params = [ - host, status, data['current_block'], data['block_time'], - data['version'], avg_res, avg_tries - ] - fmt_str = '{:<55}{:<15}{:<15}{:<25}{:<15}{:<10}{:<15}' + + # Select the appropriate coloured host type symbol based on the node's detected 'srvtype' + def_stype = f"{Fore.RED}(?){Fore.RESET}" + host_stypes = DictObject( + jussi=f"{Fore.GREEN}(J){Fore.RESET}", appbase=f"{Fore.BLUE}(A){Fore.RESET}", legacy=f"{Fore.MAGENTA}(L){Fore.RESET}" + ) + host = f"{host_stypes.get(data.srvtype, def_stype)} {host}" + + # Glue the columns together with right space padding to form the node row + fmt_str = f'{host:<55}{status:<25}{data.current_block:<15}{data.block_time:<25}' \ + f'{data.version:<15}{data.network:<15}{avg_res:<10}{avg_tries:<15}' + # If plugin scanning was enabled, generate and append the working vs. total plugin stat column + # to the fmt_str row. if settings.plugins: - fmt_str += '{:<15}' plg, ttl_plg = len(data['plugins']), len(TEST_PLUGINS_LIST) f_plugins = f'{plg} / {ttl_plg}' - if plg < (ttl_plg // 2): f_plugins = f'{Fore.RED}{f_plugins}' + if plg <= (ttl_plg // 3): f_plugins = f'{Fore.RED}{f_plugins}' + elif plg <= (ttl_plg // 2): f_plugins = f'{Fore.LIGHTRED_EX}{f_plugins}' elif plg < ttl_plg: f_plugins = f'{Fore.YELLOW}{f_plugins}' elif plg == ttl_plg: f_plugins = f'{Fore.GREEN}{f_plugins}' - fmt_params.append(f'{f_plugins}{Fore.RESET}') - print(fmt_str.format(*fmt_params), Fore.RESET) + # fmt_params.append(f'{f_plugins}{Fore.RESET}') + fmt_str += f'{f_plugins:<15}{Fore.RESET}' + # print(fmt_str.format(*fmt_params), Fore.RESET) + print(fmt_str, Fore.RESET) diff --git a/rpcscanner/__init__.py b/rpcscanner/__init__.py index d96cb47..4c7d874 100644 --- a/rpcscanner/__init__.py +++ b/rpcscanner/__init__.py @@ -1,5 +1,98 @@ +""" + +Using RPCScanner API within apps +-------------------------------- + +**High level usage with :class:`.RPCScanner`** + +Basic usage of :class:`.RPCScanner` with :meth:`rpcscanner.RPCScanner.RPCScanner.scan_nodes` :: + + >>> from rpcscanner import RPCScanner, settings, MethodTests + >>> # By default, testing each individual plugin when running scan_nodes is disabled. + >>> # You can enable plugin testing by changing settings.plugins to True + >>> settings.plugins = True + >>> # Instantiate RPCScanner with a list of RPC nodes - http or https, non-standard ports are supported + >>> # via colon format, e.g. http://my.example.rpc:8091 + >>> scanner = RPCScanner(['https://hived.privex.io', 'https://hived.hive-engine.com']) + >>> # scan_nodes is the key method of the class, which handles calling the 4 to 5 methods that handle the + >>> # various stages of scanning the passed nodes + >>> await scanner.scan_nodes(quiet=True) + >>> # Use .get_node to get a NodeStatus dataclass object, which allows easy querying of various information about a scanned node. + >>> nd = scanner.get_node('https://hived.privex.io') + >>> nd.version + '0.23.0' + >>> nd.plugins + ['bridge.get_trending_topics', 'condenser_api.get_account_history', + 'condenser_api.get_content', 'condenser_api.get_followers', + 'condenser_api.get_witness_by_account', 'condenser_api.get_accounts', + 'account_history_api.get_account_history', 'condenser_api.get_blog'] + >>> nd.timing + {'ident': 0.3845839500427246, + 'config': 0.4896061420440674, + 'props': 0.49631738662719727} + +**Mid-level usage with direct plugin testing via :class:`.MethodTests`** + +Testing individual supported RPC methods with :meth:`.MethodTests.test`:: + + >>> mt = MethodTests('https://hived.privex.io') + >>> res, time_taken_sec, tries = await mt.test('account_history_api.get_account_history') + >>> res['history'][0] + [1540217, { + 'trx_id': '0000000000000000000000000000000000000000', 'block': 43795325, 'trx_in_block': 4294967295, + 'op_in_trx': 0, 'virtual_op': 1, 'timestamp': '2020-05-28T11:43:48', + 'op': { + 'type': 'producer_reward_operation', + 'value': {'producer': 'someguy123', 'vesting_shares': {'amount': '469463734', 'precision': 6, 'nai': '@@000000037'}} + } + }] + >>> time_taken_sec + 0.3937380313873291 + >>> tries + 1 + +Testing all supported RPC methods with :meth:`.MethodTests.test_all`:: + + >>> plugtests = await mt.test_all() + >>> plugtests.methods + {'account_history_api.get_account_history': True, 'condenser_api.get_account_history': True, + 'condenser_api.get_accounts': True, 'condenser_api.get_blog': True, 'condenser_api.get_content': True, + 'condenser_api.get_followers': True, 'condenser_api.get_witness_by_account': True, 'bridge.get_trending_topics': True} + >>> plugtests.errors + {} + >>> plugtests.results['condenser_api.get_witness_by_account'] + ( + {'id': 11578, 'owner': 'someguy123', 'created': '2016-08-09T00:03:18', 'url': 'https://peakd.com/@someguy123/', ...}, + 0.7117502689361572, + 1 + ) + +**Lower level usage** + +Low level testing using :func:`rpcscanner.rpc.rpc` function call:: + + >>> from rpcscanner.rpc import rpc + >>> rd, timing, tries = await rpc('https://hived.privex.io', 'condenser_api.get_dynamic_global_properties', []) + >>> rd + {'head_block_number': 43797319, + 'head_block_id': '029c4b47edeaec18774032a3a5aee5b8dddc0319', + 'time': '2020-05-28T13:23:42', + 'current_witness': 'someguy123', + 'total_pow': 514415, + 'num_pow_witnesses': 172, + 'virtual_supply': '376318066.671 HIVE', + ... + } + >>> timing + 0.4440031051635742 + >>> tries + 1 + + +""" from rpcscanner.core import * -from rpcscanner.rpc import NodePlug -from rpcscanner.MethodTests import MethodTests +from rpcscanner.rpc import NodePlug, identify_node +from rpcscanner.MethodTests import MethodTests, METHOD_MAP from rpcscanner.exceptions import * -from rpcscanner.RPCScanner import RPCScanner +from rpcscanner.RPCScanner import RPCScanner, NodeStatus, NETWORK_COINS, TOTAL_STAGES_TRACKED + diff --git a/rpcscanner/core.py b/rpcscanner/core.py index 571b184..fc57eaa 100644 --- a/rpcscanner/core.py +++ b/rpcscanner/core.py @@ -1,48 +1,77 @@ # from dataclasses import dataclass -from os.path import dirname, abspath, join import logging -from typing import List - +import sys +import re +from os.path import dirname, abspath, join, exists, isabs +from os import makedirs +from typing import List, Optional +from privex.loghelper import LogHelper from rpcscanner import settings +from rpcscanner.settings import BASE_DIR, LOG_LEVEL, LOG_DIR log = logging.getLogger(__name__) -BASE_DIR = dirname(dirname(abspath(__file__))) + +if not exists(LOG_DIR): + makedirs(LOG_DIR) + + +LOG_FORMATTER = logging.Formatter('[%(asctime)s]: %(name)-35s -> %(funcName)-20s : %(levelname)-8s:: %(message)s') + + +def clear_handlers(*loggers: Optional[str]): + """Remove all log handlers (e.g. console, file) for a given logger name""" + loggers = ['rpcscanner'] if len(loggers) == 0 else loggers + + for lg in loggers: + lgr = logging.getLogger(lg) + for h in lgr.handlers: + lgr.removeHandler(h) + lgr.handlers.clear() + + +def set_logging_level(level: int, *loggers: Optional[str], formatter=LOG_FORMATTER): + lgs = [] + loggers = ['rpcscanner'] if len(loggers) == 0 else loggers + level = logging.getLevelName(str(level).upper()) if isinstance(level, str) else level + + for lg in loggers: + l_handler = LogHelper(lg, handler_level=level, formatter=formatter) + l_handler.add_console_handler(level=level, stream=sys.stderr) + lgs.append(l_handler) + return lgs + + +def setup_loggers(*loggers, console=True, file_dbg=True, file_err=True): + loggers = ['rpcscanner'] if len(loggers) == 0 else loggers + + for lg in loggers: + _lh = LogHelper(lg, formatter=LOG_FORMATTER, handler_level=LOG_LEVEL) + con, tfh_dbg, tfh_err = None, None, None + if console: con = _lh.add_console_handler(level=LOG_LEVEL, stream=sys.stderr) + if file_dbg: + tfh_dbg = _lh.add_timed_file_handler( + join(LOG_DIR, 'debug.log'), when='D', interval=1, backups=14, level=LOG_LEVEL + ) + if file_err: + tfh_err = _lh.add_timed_file_handler( + join(LOG_DIR, 'error.log'), when='D', interval=1, backups=14, level=logging.WARNING + ) + yield con, tfh_dbg, tfh_err, lg -RPC_TIMEOUT = 5 -MAX_TRIES = 3 -PUB_PREFIX = 'STM' # Used as part of the thorough plugin tests for checking correct keys are returned +con_handler, _, _, _ = list(setup_loggers())[0] -TEST_PLUGINS_LIST = ( - 'condenser_api.get_account_history', - 'account_history_api.get_account_history', - 'condenser_api.get_witness_by_account', - 'condenser_api.get_accounts', - 'condenser_api.get_blog', - 'condenser_api.get_content', - 'condenser_api.get_followers', - 'bridge.get_trending_topics', -) +RE_FIND_NODES = re.compile(r'^(https?://[a-zA-Z0-9./_:-]+).*?', re.MULTILINE) def load_nodes(file: str) -> List[str]: # nodes to be specified line by line. format: http://gtg.steem.house:8090 - node_list = open(join(BASE_DIR, file), 'r').readlines() - node_list = [n.strip() for n in node_list] - # Allow nodes to be commented out with # symbol - return [n for n in node_list if n[0] != '#'] - - -# @dataclass -# class ScannerSettings: -# verbose: bool = False -# quiet: bool = False -# plugins: bool = False -# node_file: str = 'nodes.txt' -# test_account: str = 'someguy123' -# -# -# settings = ScannerSettings() + npath = join(BASE_DIR, file) if not isabs(file) else file + log.error("Loading nodes from full path: %s", npath) + with open(npath, 'r') as fh: + nodes = fh.read() + node_list = RE_FIND_NODES.findall(nodes) + return [n.strip() for n in node_list] diff --git a/rpcscanner/rpc.py b/rpcscanner/rpc.py index 84f1040..c5f7651 100644 --- a/rpcscanner/rpc.py +++ b/rpcscanner/rpc.py @@ -1,23 +1,18 @@ +import asyncio import json import logging import time -import twisted.internet.reactor -from typing import Union, Tuple, Iterable +import httpx +from typing import Union, Tuple, Iterable, Mapping, Optional from colorama import Fore -from requests.adapters import HTTPAdapter -from twisted.internet import defer -from twisted.internet.defer import inlineCallbacks -from twisted.internet.task import deferLater from rpcscanner.exceptions import ServerDead -from rpcscanner.core import MAX_TRIES, RPC_TIMEOUT -from requests_threads import AsyncSession +from rpcscanner.settings import MAX_TRIES, RPC_TIMEOUT, RETRY_DELAY log = logging.getLogger(__name__) -s = AsyncSession(n=50) +# s = AsyncSession(n=50) -@inlineCallbacks -def rpc(reactor, host: str, method: str, params: Union[dict, list] = None) -> Tuple[Iterable, float, int]: +async def rpc(host: str, method: str, params: Union[dict, list] = None) -> Tuple[Union[list, dict], float, int]: """ Handles an RPC request, with automatic re-trying and timing. @@ -30,9 +25,9 @@ def rpc(reactor, host: str, method: str, params: Union[dict, list] = None) -> Tu tries=MAX_TRIES, host=host, method=method ) + Fore.RESET) # d = defer.Deferred() - np = NodePlug(reactor) + np = NodePlug() try: - d = yield np.try_node(host, method, params) + d = await np.try_node(host, method, params) except ServerDead as e: log.debug('caught in rpc and raised') raise e @@ -41,8 +36,7 @@ def rpc(reactor, host: str, method: str, params: Union[dict, list] = None) -> Tu return d -@inlineCallbacks -def identify_node(reactor, host): +async def identify_node(host): """ Detects a server type :returns: tuple (servtype, time_taken_sec, tries) @@ -54,9 +48,9 @@ def identify_node(reactor, host): tries=MAX_TRIES, host=host ) + Fore.RESET) # d = defer.Deferred() - np = NodePlug(reactor) + np = NodePlug() try: - d = yield np.ident_jussi(host) + d = await np.ident_jussi(host) log.debug('Successfully identified %s', host) except ServerDead as e: log.debug('caught in identify_node and raised') @@ -66,8 +60,7 @@ def identify_node(reactor, host): return d -@inlineCallbacks -def _rpc(host: str, method: str, params=None): +async def _rpc(host: str, method: str, params=None): params = [] if params is None else params headers = { # 'Host': domain, @@ -79,35 +72,38 @@ def _rpc(host: str, method: str, params=None): "jsonrpc": "2.0", "id": 1, } - s.mount(host, HTTPAdapter(max_retries=1)) - res = yield s.post(host, data=json.dumps(payload), headers=headers, timeout=(2, RPC_TIMEOUT)) - res.raise_for_status() - # print(res.text[0:10]) - res = res.json() - if 'result' not in res: + # s.mount(host, HTTPAdapter(max_retries=1)) + async with httpx.AsyncClient() as s: + res = await s.post(host, data=json.dumps(payload), headers=headers, timeout=RPC_TIMEOUT) + res.raise_for_status() + # print(res.text[0:10]) + j = res.json() + res.close() + await s.aclose() + + if 'result' not in j: # print(res) raise Exception('No result') - return res['result'] + return j['result'] class NodePlug: - def __init__(self, reactor: twisted.internet.reactor): - self.reactor = reactor - - @defer.inlineCallbacks - def try_node(self, host, method, params=None): + def __init__(self): + # self.reactor = reactor + pass + + async def try_node(self, host, method, params=None): params = [] if params is None else params # self.reactor = reactor try: - tn = yield self._try_node(host, method, params) + tn = await self._try_node(host, method, params) return tn except Exception as e: log.debug('caught in try_node and raised') raise e - @defer.inlineCallbacks - def _try_node(self, host, method, params: Union[dict, list] = None, tries=0): + async def _try_node(self, host, method, params: Union[dict, list] = None, tries=0): params = [] if params is None else params if tries >= MAX_TRIES: log.debug('SERVER IS DEAD') @@ -117,7 +113,7 @@ def _try_node(self, host, method, params: Union[dict, list] = None, tries=0): log.debug('{} {} attempt {}'.format(host, method, tries)) start = time.time() tries += 1 - res = yield _rpc(host, method, params) + res = await _rpc(host, method, params) end = time.time() runtime = end - start @@ -130,21 +126,40 @@ def _try_node(self, host, method, params: Union[dict, list] = None, tries=0): if 'HTTPError' in str(type(e)) and '426 Client Error' in str(e): raise ServerDead('Server {} only supports websockets'.format(host)) log.info('%s [%s] %s attempt %d failed. Message: %s %s %s', Fore.RED, method, host, tries, type(e), str(e), Fore.RESET) - dl = yield deferLater(self.reactor, 10, self._try_node, host, method, params, tries) - return dl + # dl = yield deferLater(self.reactor, 10, self._try_node, host, method, params, tries) + await asyncio.sleep(RETRY_DELAY) + return await self._try_node(host, method=method, params=params, tries=tries) - @defer.inlineCallbacks - def ident_jussi(self, host): + async def ident_jussi(self, host): # self.reactor = reactor try: - tn = yield self._ident_jussi(host) + tn = await self._ident_jussi(host) return tn except Exception as e: log.debug('caught in ident_jussi and raised') raise e - - @defer.inlineCallbacks - def _ident_jussi(self, host, tries=0): + + @staticmethod + def _ident_response(res: Union[str, dict]) -> Optional[str]: + j = {} + if isinstance(res, str): + try: + j = json.loads(str(res)) + except json.JSONDecodeError: + j = {} + if isinstance(res, dict): + j = dict(res) + res = json.dumps(j) + + if 'error' in j and 'message' in j['error']: + if j['error']['message'] == 'End Of File:stringstream': + return 'appbase' + if 'jussi_num' in j: return 'jussi' + if 'end of file:stringstream' in res.lower(): return 'appbase' + if 'could not call api' in res.lower(): return 'legacy' + return None + + async def _ident_jussi(self, host, tries=0): if tries >= MAX_TRIES: log.debug('[ident_jussi] SERVER IS DEAD') raise ServerDead('{} did not respond properly after {} tries'.format(host, tries)) @@ -152,18 +167,20 @@ def _ident_jussi(self, host, tries=0): log.debug('{} ident_jussi attempt {}'.format(host, tries)) start = time.time() tries += 1 - res = yield s.get(host) - res.raise_for_status() - j = res.json() - + srvtype = 'err' + try: + async with httpx.AsyncClient() as s: + res = await s.get(host) + res.raise_for_status() + j = res.json() + srvtype = self._ident_response(j) + except httpx.HTTPError as e: + if '426 Client Error' in str(e): raise e + srvtype = self._ident_response(str(e.response.content)) + if srvtype is None: + raise e end = time.time() runtime = end - start - srvtype = 'err' - if 'jussi_num' in j: - srvtype = 'jussi' - elif 'error' in j and 'message' in j['error']: - if j['error']['message'] == 'End Of File:stringstream': - srvtype = 'appbase' # if we made it this far, we're fine :) results = [srvtype, runtime, tries] log.debug(Fore.GREEN + '[{}] Successful request for ident_jussi'.format(host) + Fore.RESET) @@ -172,5 +189,5 @@ def _ident_jussi(self, host, tries=0): if 'HTTPError' in str(type(e)) and '426 Client Error' in str(e): raise ServerDead('Server {} only supports websockets'.format(host)) log.debug('%s [ident_jussi] %s attempt %d failed. Message: %s %s %s', Fore.RED, host, tries, type(e), str(e), Fore.RESET) - dl = yield deferLater(self.reactor, 10, self._ident_jussi, host, tries) - return dl + await asyncio.sleep(RETRY_DELAY) + return await self._ident_jussi(host=host, tries=tries) diff --git a/rpcscanner/settings.py b/rpcscanner/settings.py index 52c9f58..7245885 100644 --- a/rpcscanner/settings.py +++ b/rpcscanner/settings.py @@ -2,9 +2,68 @@ The settings in this file will normally be overwritten by the CLI tool, from either a .env file, or arguments passed on the CLI. """ -verbose: bool = False -quiet: bool = False -plugins: bool = False -node_file: str = 'nodes.txt' -test_account: str = 'someguy123' -test_post: str = 'announcement-soft-fork-0-22-2-released-steem-in-a-box-update' +import logging +from os import getenv as env, getcwd +from os.path import dirname, abspath, join +from privex.helpers import env_bool, env_int, env_csv, env_cast +import dotenv + +BASE_DIR = dirname(dirname(abspath(__file__))) + +dotenv.load_dotenv() +dotenv.load_dotenv(join(BASE_DIR, '.env')) +dotenv.load_dotenv(join(getcwd(), '.env')) + +DEBUG = env_bool('DEBUG', False) + +verbose: bool = env_bool('VERBOSE', DEBUG) +quiet: bool = env_bool('QUIET', False) + +LOG_DIR = join(BASE_DIR, 'logs') + +# Valid environment log levels (from least to most severe) are: +# DEBUG, INFO, WARNING, ERROR, FATAL, CRITICAL +LOG_LEVEL = env('LOG_LEVEL', None) +LOG_LEVEL = logging.getLevelName(str(LOG_LEVEL).upper()) if LOG_LEVEL is not None else None + +if LOG_LEVEL is None: + LOG_LEVEL = logging.DEBUG if DEBUG or verbose else logging.INFO + LOG_LEVEL = logging.CRITICAL if quiet else LOG_LEVEL + +RPC_TIMEOUT = env_int('RPC_TIMEOUT', 3) +MAX_TRIES = env_int('MAX_TRIES', 3) +RETRY_DELAY = env_cast('RETRY_DELAY', cast=float, env_default=2.0) +PUB_PREFIX = env('PUB_PREFIX', 'STM') # Used as part of the thorough plugin tests for checking correct keys are returned + + +TEST_PLUGINS_LIST = env_csv('TEST_PLUGIN_LIST', []) +""" +Controls which plugins are tested by :class:`.RPCScanner` when :attr:`rpcscanner.settings.plugins` is +set to ``True``. + +If the TEST_PLUGINS_LIST is empty, it will be populated automatically when the module container :class:`.MethodTests` +is loaded, which will replace it with a tuple containing :attr:`rpcscanner.MethodTests.METHOD_MAP`. +""" + +EXTRA_PLUGINS_LIST = env_csv('EXTRA_PLUGINS_LIST', []) +""" +Additional RPC methods to test - add to your ``.env`` as comma separated RPC method names. + +Will be appended to ``TEST_PLUGIN_LIST`` + +Example ``.env`` entry:: + + EXTRA_PLUGINS_LIST=condenser_api.some_method,block_api.another_method + + +""" +TEST_PLUGINS_LIST = tuple(TEST_PLUGINS_LIST + EXTRA_PLUGINS_LIST) + +GOOD_RETURN_CODE = env_int('GOOD_RETURN_CODE', 0) +BAD_RETURN_CODE = env_int('BAD_RETURN_CODE', 8) + + +plugins: bool = env_bool('PLUGINS', False) +node_file: str = env('NODE_FILE', 'nodes.conf') +test_account: str = env('TEST_ACCOUNT', 'someguy123') +test_post: str = env('TEST_POST', 'announcement-soft-fork-0-22-2-released-steem-in-a-box-update') diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..4f81b91 --- /dev/null +++ b/run.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +############################################################################# +# # +# Production runner script for: # +# # +# Hive/Steem RPC Scanner # +# (C) 2020 Someguy123. GNU AGPL v3 # +# # +# Someguy123 Blog: https://peakd.com/@someguy123 # +# Privex Site: https://www.privex.io/ # +# # +# Github Repo: https://github.com/Someguy123/steem-rpc-scanner # +# # +############################################################################# + +BOLD="" RED="" GREEN="" YELLOW="" BLUE="" MAGENTA="" CYAN="" WHITE="" RESET="" +if [ -t 1 ]; then BOLD="$(tput bold)" RED="$(tput setaf 1)" GREEN="$(tput setaf 2)" RESET="$(tput sgr0)"; fi + +OUR_APP="Hive/Steem RPC Scanner" GH_REPO="https://github.com/Someguy123/steem-rpc-scanner" + +# Error handling function for ShellCore +_sc_fail() { echo >&2 -e "\n${BOLD}${RED} [!!!] Failed to load or install Privex ShellCore...${RESET}\n\n" && exit 1; } + +# Run ShellCore auto-install if we can't detect an existing ShellCore load.sh file. +[[ -f "${HOME}/.pv-shcore/load.sh" ]] || [[ -f "/usr/local/share/pv-shcore/load.sh" ]] || + { + echo -e "${GREEN} >>> Auto-installing Privex ShellCore ( https://github.com/Privex/shell-core ) ...${RESET}" + curl -fsS https://cdn.privex.io/github/shell-core/install.sh | bash >/dev/null + echo -e "${BOLD}${GREEN} [+++] ShellCore successfully installed :)${RESET}" + } || _sc_fail + +# Attempt to load the local install of ShellCore first, then fallback to global install if it's not found. +[[ -d "${HOME}/.pv-shcore" ]] && source "${HOME}/.pv-shcore/load.sh" || + source "/usr/local/share/pv-shcore/load.sh" || _sc_fail + +# Quietly automatically update Privex ShellCore every 14 days (default) +autoupdate_shellcore + +###### +# Directory where the script is located, so we can source files regardless of where PWD is +###### + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:${PATH}" +export PATH="${HOME}/.local/bin:${PATH}" +export PYTHONUNBUFFERED=1 PIPENV_VERBOSITY=-1 +cd "$DIR" + +[[ -f .env ]] && source .env || true + +# Override these defaults inside of `.env` +#: ${HOST='127.0.0.1'} +#: ${PORT='8484'} +#: ${GU_WORKERS='10'} # Number of Gunicorn worker processes + +case "$1" in + health | HEALTH | check | CHECK) + pipenv run ./health.py "${@:2}" + exit $? + ;; + scan | SCAN | list | LIST | rpcs | RPCS | all | ALL) + pipenv run ./app.py "${@:2}" + exit $? + ;; + # prod*) + # pipenv run hypercorn -b "${HOST}:${PORT}" -w "$GU_WORKERS" wsgi + # ;; + update | upgrade) + msg ts bold green " >> Updating files from Github" + git pull + msg ts bold green " >> Updating Python packages" + pipenv update --ignore-pipfile + msg ts bold green " +++ Finished" + echo + ;; + install | setup | init) + msg ts bold green " >> Updating files from Github" + git pull + msg ts bold green " >> Installing any missing packages" + pkg_not_found python3 python3.8 + pkg_not_found python3 python3.7 + pkg_not_found python3 python3 + pkg_not_found pip3 python3.8-pip + pkg_not_found pip3 python3.7-pip + pkg_not_found pip3 python3-pip + if not has_command pipenv; then + PY_VER="" + [ -z "$PY_VER" ] && has_binary python3.8 && PY_VER="python3.8" || true + [ -z "$PY_VER" ] && has_binary python3.7 && PY_VER="python3.7" || true + [ -z "$PY_VER" ] && PY_VER="python3" || true + sudo -H "$PY_VER" -m pip install -U pipenv + fi + msg ts bold green " >> Creating virtualenv / Installing Python packages" + pipenv install --ignore-pipfile + [[ -f "${DIR}/nodes.conf" ]] || { + msg ts green " >> Copying example.nodes.conf -> nodes.conf" + cp -v "${DIR}/example.nodes.conf" "${DIR}/nodes.conf" + } + msg + msg ts bold green " +++ Finished" + echo + ;; + *) + echo "Runner script for Someguy123's $OUR_APP" + echo "" + msg bold red "Unknown command.\n" + msg bold green "$OUR_APP - (C) 2020 Someguy123 / Privex Inc." + msg bold green " Website: https://www.privex.io/ \n Source: ${GH_REPO}\n" + msg green "Available run.sh commands:\n" + msg yellow "\t health [-q|-v] [scan|list] [-d] (rpc) - Return health data for an individual RPC, " \ + "or all RPCs listed in the node list config file" + msg yellow "\t scan|list [-q|-v|--plugins] [-f nodes.conf] - Scan all RPCs in the node list config, " \ + "outputting their status information with a pretty printed colourful table." + msg + ;; +esac