commit 39d0e41f295db1777eae73834704e262788c15df Author: SporeStack Date: Thu Feb 10 21:47:57 2022 +0000 5.2.1 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..56ca3d7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# https://editorconfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[Makefile] +indent_style=tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc79d98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.pyc +.venv +venv +build +dist +*.egg-info +.eggs +__pycache__ +.pytest_cache +.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b17fd30 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: 8fe62d14e0b4d7d845a7022c5c2c3ae41bdd3f26 # frozen: v4.1.0 + hooks: + - id: trailing-whitespace +- repo: https://github.com/psf/black + rev: fc0be6eb1e2a96091e6f64009ee5e9081bf8b6c6 # frozen: 22.1.0 + hooks: + - id: black +- repo: https://github.com/PyCQA/isort + rev: c5e8fa75dda5f764d20f66a215d71c21cfa198e1 # frozen: 5.10.1 + hooks: + - id: isort +- repo: https://github.com/myint/autoflake + rev: 7a53fdafc82c33f446915b60fcac947c51279260 # frozen: v1.4 + hooks: + - id: autoflake +- repo: https://github.com/asottile/pyupgrade + rev: e695ecd365119ab4e5463f6e49bea5f4b7ca786b # frozen: v2.31.0 + hooks: + - id: pyupgrade + args: [--py37-plus] +- repo: https://github.com/asottile/setup-cfg-fmt + rev: 58b14248db425913ea7502c0b1af9d6653403e07 # frozen: v1.20.0 + hooks: + - id: setup-cfg-fmt +- repo: https://github.com/jackdewinter/pymarkdown + rev: cf71b3c9cb0c361c4a17eacb80ed52432b57b420 # frozen: 0.9.4 + hooks: + - id: pymarkdown + args: [--disable-rules=MD013, --set=plugins.md024.siblings_only=$!True, scan] diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..c85d572 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,39 @@ +pipeline: + python-3.7: + group: test + image: python:3.7 + commands: + - pip install pipenv==2022.1.8 + - pipenv install --dev --deploy + - pipenv run almake test-pytest # We only test with pytest on 3.7 + +# More than three jobs seems to cause issues with Woodpecker? +# python-3.8: +# group: test +# image: python:3.8-slim +# commands: +# - pip install pipenv==2021.11.23 +# - pipenv install --dev --deploy +# - pipenv run almake test +# - pipenv run almake build-dist +# - sha256sum dist/* + + python-3.9: + group: test + image: python:3.9 + commands: + - pip install pipenv==2022.1.8 pre-commit==2.17.0 + - pipenv install --dev --deploy + - pipenv run almake test + - pipenv run almake build-dist + - sha256sum dist/* + + python-3.10: + group: test + image: python:3.10 + commands: + - pip install pipenv==2022.1.8 pre-commit==2.17.0 + - pipenv install --dev --deploy + - pipenv run almake test + - pipenv run almake build-dist + - sha256sum dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..136ae60 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Nothing yet. + +### Fixed + +- Nothing yet. + +## [5.2.1 - 2022-02-10] + +### Added + +- New, 32 character machine ID format. (Old, 64 hex character format still supported.) +- CHANGELOG.md in Keep a Changelog format. + +## [5.2.0 - 2022-01-31] + +### Added + +- `sporestack rebuild` command. + +## [5.1.2 - 2021-10-18] + +### Added + +- Send `sporestack-python/version` in Use-Agent header. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..68a49da --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b34c40c --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +test: + pre-commit run --all-files + python -m pflake8 . + python -m mypy --strict . + $(MAKE) test-pytest + +test-pytest: + python -m pytest --cov=sporestack --cov-fail-under=49 --cov-report=term --durations=3 --cache-clear + +build-dist: + rm dist/* || true + # This should result in a reproducible wheel. + SOURCE_DATE_EPOCH=1309379017 python -m build --no-isolation + python -m twine check --strict dist/* + +servedocs: + pdoc sporestack + +publish: build-dist + # The sdist isn't reproducible, but the wheel is. + python -m twine upload dist/* diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..ccc75a7 --- /dev/null +++ b/Pipfile @@ -0,0 +1,30 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +sporestack = {editable = true, path = "."} + +[dev-packages] +flake8 = "~=4.0" +pyproject-flake8 = "==0.0.1a2" +flake8-noqa = "~=1.2" +pep8-naming = "~=0.12.1" +mypy = "==0.931" +pytest = "~=6.2" +pytest-cov = "~=3.0" + +types-requests = "~=2.25" + +# Building +wheel = "~=0.37.0" +build = "~=0.7.0" +# Publishing +twine = "~=3.4" + +# Docs +pdoc = "~=9.0" + +# Python `make` implementation +almost-make = "~=0.5.1" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..15fbcdd --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,681 @@ +{ + "_meta": { + "hash": { + "sha256": "a28c5484b41e439216180f4b486774887c8a49cf9b36f8997ed098d7462ceb2e" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", + "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" + ], + "version": "==2021.10.8" + }, + "charset-normalizer": { + "hashes": [ + "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45", + "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c" + ], + "markers": "python_version >= '3'", + "version": "==2.0.11" + }, + "click": { + "hashes": [ + "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3", + "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b" + ], + "markers": "python_version >= '3.6'", + "version": "==8.0.3" + }, + "idna": { + "hashes": [ + "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", + "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + ], + "markers": "python_version >= '3'", + "version": "==3.3" + }, + "pydantic": { + "hashes": [ + "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3", + "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398", + "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1", + "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65", + "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4", + "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16", + "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2", + "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c", + "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6", + "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce", + "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9", + "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3", + "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034", + "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c", + "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a", + "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77", + "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b", + "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6", + "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f", + "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721", + "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37", + "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032", + "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d", + "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed", + "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6", + "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054", + "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25", + "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46", + "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5", + "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c", + "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070", + "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1", + "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7", + "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d", + "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145" + ], + "markers": "python_full_version >= '3.6.1'", + "version": "==1.9.0" + }, + "pysocks": { + "hashes": [ + "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299", + "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", + "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0" + ], + "version": "==1.7.1" + }, + "requests": { + "extras": [ + "socks" + ], + "hashes": [ + "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", + "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==2.27.1" + }, + "segno": { + "hashes": [ + "sha256:79d1d7b9c893243411acd031682e0ece007fbd632885c6c650186871be572111", + "sha256:b8e90823b7ab5249044d22f022291bb06e112104779d6339baf0997fad656c9a" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==1.4.1" + }, + "sporestack": { + "editable": true, + "path": "." + }, + "typer": { + "hashes": [ + "sha256:63c3aeab0549750ffe40da79a1b524f60e08a2cbc3126c520ebf2eeaf507f5dd", + "sha256:d81169725140423d072df464cad1ff25ee154ef381aaf5b8225352ea187ca338" + ], + "markers": "python_version >= '3.6'", + "version": "==0.4.0" + }, + "typing-extensions": { + "hashes": [ + "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e", + "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b" + ], + "markers": "python_version >= '3.6'", + "version": "==4.0.1" + }, + "urllib3": { + "hashes": [ + "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", + "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.8" + } + }, + "develop": { + "almost-make": { + "hashes": [ + "sha256:57a3fd147074a041f6b8735e239a4d425bf3944b3c68a52e6225dab25caef4e0", + "sha256:b227ad53d27f767ea600cc97b5165bef94e6f4ec24ccefe4dc52ed532f6b6f71" + ], + "index": "pypi", + "version": "==0.5.1" + }, + "astunparse": { + "hashes": [ + "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872", + "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8" + ], + "markers": "python_version < '3.9'", + "version": "==1.6.3" + }, + "attrs": { + "hashes": [ + "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", + "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==21.4.0" + }, + "bleach": { + "hashes": [ + "sha256:0900d8b37eba61a802ee40ac0061f8c2b5dee29c1927dd1d233e075ebf5a71da", + "sha256:4d2651ab93271d1129ac9cbc679f524565cc8a1b791909c4a51eac4446a15994" + ], + "markers": "python_version >= '3.6'", + "version": "==4.1.0" + }, + "build": { + "hashes": [ + "sha256:1aaadcd69338252ade4f7ec1265e1a19184bf916d84c9b7df095f423948cb89f", + "sha256:21b7ebbd1b22499c4dac536abc7606696ea4d909fd755e00f09f3c0f2c05e3c8" + ], + "index": "pypi", + "version": "==0.7.0" + }, + "certifi": { + "hashes": [ + "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", + "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" + ], + "version": "==2021.10.8" + }, + "charset-normalizer": { + "hashes": [ + "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45", + "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c" + ], + "markers": "python_version >= '3'", + "version": "==2.0.11" + }, + "colorama": { + "hashes": [ + "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", + "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.4.4" + }, + "coverage": { + "extras": [ + "toml" + ], + "hashes": [ + "sha256:012157499ec4f135fc36cd2177e3d1a1840af9b236cbe80e9a5ccfc83d912a69", + "sha256:0a34d313105cdd0d3644c56df2d743fe467270d6ab93b5d4a347eb9fec8924d6", + "sha256:11e61c5548ecf74ea1f8b059730b049871f0e32b74f88bd0d670c20c819ad749", + "sha256:152cc2624381df4e4e604e21bd8e95eb8059535f7b768c1fb8b8ae0b26f47ab0", + "sha256:1b4285fde5286b946835a1a53bba3ad41ef74285ba9e8013e14b5ea93deaeafc", + "sha256:27a94db5dc098c25048b0aca155f5fac674f2cf1b1736c5272ba28ead2fc267e", + "sha256:27ac7cb84538e278e07569ceaaa6f807a029dc194b1c819a9820b9bb5dbf63ab", + "sha256:2a491e159294d756e7fc8462f98175e2d2225e4dbe062cca7d3e0d5a75ba6260", + "sha256:2bc85664b06ba42d14bb74d6ddf19d8bfc520cb660561d2d9ce5786ae72f71b5", + "sha256:32168001f33025fd756884d56d01adebb34e6c8c0b3395ca8584cdcee9c7c9d2", + "sha256:3c4ce3b647bd1792d4394f5690d9df6dc035b00bcdbc5595099c01282a59ae01", + "sha256:433b99f7b0613bdcdc0b00cc3d39ed6d756797e3b078d2c43f8a38288520aec6", + "sha256:4578728c36de2801c1deb1c6b760d31883e62e33f33c7ba8f982e609dc95167d", + "sha256:51372e24b1f7143ee2df6b45cff6a721f3abe93b1e506196f3ffa4155c2497f7", + "sha256:5d008e0f67ac800b0ca04d7914b8501312c8c6c00ad8c7ba17754609fae1231a", + "sha256:649df3641eb351cdfd0d5533c92fc9df507b6b2bf48a7ef8c71ab63cbc7b5c3c", + "sha256:6e78b1e25e5c5695dea012be473e442f7094d066925604be20b30713dbd47f89", + "sha256:72d9d186508325a456475dd05b1756f9a204c7086b07fffb227ef8cee03b1dc2", + "sha256:7d82c610a2e10372e128023c5baf9ce3d270f3029fe7274ff5bc2897c68f1318", + "sha256:7ee317486593193e066fc5e98ac0ce712178c21529a85c07b7cb978171f25d53", + "sha256:7eed8459a2b81848cafb3280b39d7d49950d5f98e403677941c752e7e7ee47cb", + "sha256:823f9325283dc9565ba0aa2d240471a93ca8999861779b2b6c7aded45b58ee0f", + "sha256:85c5fc9029043cf8b07f73fbb0a7ab6d3b717510c3b5642b77058ea55d7cacde", + "sha256:86c91c511853dfda81c2cf2360502cb72783f4b7cebabef27869f00cbe1db07d", + "sha256:8e0c3525b1a182c8ffc9bca7e56b521e0c2b8b3e82f033c8e16d6d721f1b54d6", + "sha256:987a84ff98a309994ca77ed3cc4b92424f824278e48e4bf7d1bb79a63cfe2099", + "sha256:9ed3244b415725f08ca3bdf02ed681089fd95e9465099a21c8e2d9c5d6ca2606", + "sha256:a189036c50dcd56100746139a459f0d27540fef95b09aba03e786540b8feaa5f", + "sha256:a4748349734110fd32d46ff8897b561e6300d8989a494ad5a0a2e4f0ca974fc7", + "sha256:a5d79c9af3f410a2b5acad91258b4ae179ee9c83897eb9de69151b179b0227f5", + "sha256:a7596aa2f2b8fa5604129cfc9a27ad9beec0a96f18078cb424d029fdd707468d", + "sha256:ab4fc4b866b279740e0d917402f0e9a08683e002f43fa408e9655818ed392196", + "sha256:bde4aeabc0d1b2e52c4036c54440b1ad05beeca8113f47aceb4998bb7471e2c2", + "sha256:c72bb4679283c6737f452eeb9b2a0e570acaef2197ad255fb20162adc80bea76", + "sha256:c8582e9280f8d0f38114fe95a92ae8d0790b56b099d728cc4f8a2e14b1c4a18c", + "sha256:ca29c352389ea27a24c79acd117abdd8a865c6eb01576b6f0990cd9a4e9c9f48", + "sha256:ce443a3e6df90d692c38762f108fc4c88314bf477689f04de76b3f252e7a351c", + "sha256:da1a428bdbe71f9a8c270c7baab29e9552ac9d0e0cba5e7e9a4c9ee6465d258d", + "sha256:e67ccd53da5958ea1ec833a160b96357f90859c220a00150de011b787c27b98d", + "sha256:e8071e7d9ba9f457fc674afc3de054450be2c9b195c470147fbbc082468d8ff7", + "sha256:fff16a30fdf57b214778eff86391301c4509e327a65b877862f7c929f10a4253" + ], + "markers": "python_version >= '3.7'", + "version": "==6.3" + }, + "docutils": { + "hashes": [ + "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c", + "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.18.1" + }, + "flake8": { + "hashes": [ + "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d", + "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d" + ], + "index": "pypi", + "version": "==4.0.1" + }, + "flake8-noqa": { + "hashes": [ + "sha256:629b87b542f9b4cbd7ee6de10b2c669e460a200145a7577b98092b7e94373153" + ], + "index": "pypi", + "version": "==1.2.1" + }, + "flake8-polyfill": { + "hashes": [ + "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9", + "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda" + ], + "version": "==1.0.2" + }, + "idna": { + "hashes": [ + "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", + "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + ], + "markers": "python_version >= '3'", + "version": "==3.3" + }, + "importlib-metadata": { + "hashes": [ + "sha256:899e2a40a8c4a1aec681feef45733de8a6c58f3f6a0dbed2eb6574b4387a77b6", + "sha256:951f0d8a5b7260e9db5e41d429285b5f451e928479f19d80818878527d36e95e" + ], + "markers": "python_version >= '3.7'", + "version": "==4.10.1" + }, + "iniconfig": { + "hashes": [ + "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", + "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" + ], + "version": "==1.1.1" + }, + "jinja2": { + "hashes": [ + "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8", + "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7" + ], + "markers": "python_version >= '3.6'", + "version": "==3.0.3" + }, + "keyring": { + "hashes": [ + "sha256:9012508e141a80bd1c0b6778d5c610dd9f8c464d75ac6774248500503f972fb9", + "sha256:b0d28928ac3ec8e42ef4cc227822647a19f1d544f21f96457965dc01cf555261" + ], + "markers": "python_version >= '3.7'", + "version": "==23.5.0" + }, + "markupsafe": { + "hashes": [ + "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", + "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", + "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", + "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194", + "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", + "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", + "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", + "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", + "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", + "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", + "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", + "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a", + "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", + "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", + "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", + "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", + "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", + "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", + "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", + "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047", + "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", + "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", + "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b", + "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", + "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", + "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", + "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", + "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1", + "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", + "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", + "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", + "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee", + "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f", + "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", + "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", + "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", + "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", + "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", + "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", + "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86", + "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6", + "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", + "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", + "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", + "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", + "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e", + "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", + "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", + "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f", + "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", + "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", + "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", + "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", + "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", + "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", + "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", + "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a", + "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207", + "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", + "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", + "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd", + "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", + "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", + "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9", + "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", + "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", + "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", + "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", + "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" + ], + "markers": "python_version >= '3.6'", + "version": "==2.0.1" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "mypy": { + "hashes": [ + "sha256:0038b21890867793581e4cb0d810829f5fd4441aa75796b53033af3aa30430ce", + "sha256:1171f2e0859cfff2d366da2c7092b06130f232c636a3f7301e3feb8b41f6377d", + "sha256:1b06268df7eb53a8feea99cbfff77a6e2b205e70bf31743e786678ef87ee8069", + "sha256:1b65714dc296a7991000b6ee59a35b3f550e0073411ac9d3202f6516621ba66c", + "sha256:1bf752559797c897cdd2c65f7b60c2b6969ffe458417b8d947b8340cc9cec08d", + "sha256:300717a07ad09525401a508ef5d105e6b56646f7942eb92715a1c8d610149714", + "sha256:3c5b42d0815e15518b1f0990cff7a705805961613e701db60387e6fb663fe78a", + "sha256:4365c60266b95a3f216a3047f1d8e3f895da6c7402e9e1ddfab96393122cc58d", + "sha256:50c7346a46dc76a4ed88f3277d4959de8a2bd0a0fa47fa87a4cde36fe247ac05", + "sha256:5b56154f8c09427bae082b32275a21f500b24d93c88d69a5e82f3978018a0266", + "sha256:74f7eccbfd436abe9c352ad9fb65872cc0f1f0a868e9d9c44db0893440f0c697", + "sha256:7b3f6f557ba4afc7f2ce6d3215d5db279bcf120b3cfd0add20a5d4f4abdae5bc", + "sha256:8c11003aaeaf7cc2d0f1bc101c1cc9454ec4cc9cb825aef3cafff8a5fdf4c799", + "sha256:8ca7f8c4b1584d63c9a0f827c37ba7a47226c19a23a753d52e5b5eddb201afcd", + "sha256:c89702cac5b302f0c5d33b172d2b55b5df2bede3344a2fbed99ff96bddb2cf00", + "sha256:d8f1ff62f7a879c9fe5917b3f9eb93a79b78aad47b533911b853a757223f72e7", + "sha256:d9d2b84b2007cea426e327d2483238f040c49405a6bf4074f605f0156c91a47a", + "sha256:e839191b8da5b4e5d805f940537efcaa13ea5dd98418f06dc585d2891d228cf0", + "sha256:f9fe20d0872b26c4bba1c1be02c5340de1019530302cf2dcc85c7f9fc3252ae0", + "sha256:ff3bf387c14c805ab1388185dd22d6b210824e164d4bb324b195ff34e322d166" + ], + "index": "pypi", + "version": "==0.931" + }, + "mypy-extensions": { + "hashes": [ + "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", + "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" + ], + "version": "==0.4.3" + }, + "packaging": { + "hashes": [ + "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", + "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" + ], + "markers": "python_version >= '3.6'", + "version": "==21.3" + }, + "pdoc": { + "hashes": [ + "sha256:de3784b5692397fa2f9ddb57a61513a1cabf38281f084aef32b5ed4ee6fbc09e" + ], + "index": "pypi", + "version": "==9.0.1" + }, + "pep517": { + "hashes": [ + "sha256:931378d93d11b298cf511dd634cf5ea4cb249a28ef84160b3247ee9afb4e8ab0", + "sha256:dd884c326898e2c6e11f9e0b64940606a93eb10ea022a2e067959f3a110cf161" + ], + "version": "==0.12.0" + }, + "pep8-naming": { + "hashes": [ + "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37", + "sha256:bb2455947757d162aa4cad55dba4ce029005cd1692f2899a21d51d8630ca7841" + ], + "index": "pypi", + "version": "==0.12.1" + }, + "pkginfo": { + "hashes": [ + "sha256:542e0d0b6750e2e21c20179803e40ab50598d8066d51097a0e382cba9eb02bff", + "sha256:c24c487c6a7f72c66e816ab1796b96ac6c3d14d49338293d2141664330b55ffc" + ], + "version": "==1.8.2" + }, + "pluggy": { + "hashes": [ + "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", + "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" + ], + "markers": "python_version >= '3.6'", + "version": "==1.0.0" + }, + "py": { + "hashes": [ + "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", + "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==1.11.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20", + "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==2.8.0" + }, + "pyflakes": { + "hashes": [ + "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c", + "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.4.0" + }, + "pygments": { + "hashes": [ + "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65", + "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a" + ], + "markers": "python_version >= '3.5'", + "version": "==2.11.2" + }, + "pyparsing": { + "hashes": [ + "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea", + "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484" + ], + "markers": "python_version >= '3.6'", + "version": "==3.0.7" + }, + "pyproject-flake8": { + "hashes": [ + "sha256:bdeca37f78ecd34bd64a49d3657d53d099f5445831071a31c46e1fe20cd61461", + "sha256:e61ed1dc088e9f9f8a7170967ac4ec135acfef3a59ab9738c7b58cc11f294a7e" + ], + "index": "pypi", + "version": "==0.0.1a2" + }, + "pytest": { + "hashes": [ + "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89", + "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134" + ], + "index": "pypi", + "version": "==6.2.5" + }, + "pytest-cov": { + "hashes": [ + "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6", + "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470" + ], + "index": "pypi", + "version": "==3.0.0" + }, + "readme-renderer": { + "hashes": [ + "sha256:a50a0f2123a4c1145ac6f420e1a348aafefcc9211c846e3d51df05fe3d865b7d", + "sha256:b512beafa6798260c7d5af3e1b1f097e58bfcd9a575da7c4ddd5e037490a5b85" + ], + "markers": "python_version >= '3.6'", + "version": "==32.0" + }, + "requests": { + "extras": [ + "socks" + ], + "hashes": [ + "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", + "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==2.27.1" + }, + "requests-toolbelt": { + "hashes": [ + "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f", + "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0" + ], + "version": "==0.9.1" + }, + "rfc3986": { + "hashes": [ + "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", + "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "toml": { + "hashes": [ + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.10.2" + }, + "tomli": { + "hashes": [ + "sha256:b5bde28da1fed24b9bd1d4d2b8cba62300bfb4ec9a6187a957e8ddb9434c5224", + "sha256:c292c34f58502a1eb2bbb9f5bbc9a5ebc37bee10ffb8c2d6bbdfa8eb13cc14e1" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "tqdm": { + "hashes": [ + "sha256:8dd278a422499cd6b727e6ae4061c40b48fce8b76d1ccbf5d34fca9b7f925b0c", + "sha256:d359de7217506c9851b7869f3708d8ee53ed70a1b8edbba4dbcb47442592920d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==4.62.3" + }, + "twine": { + "hashes": [ + "sha256:28460a3db6b4532bde6a5db6755cf2dce6c5020bada8a641bb2c5c7a9b1f35b8", + "sha256:8c120845fc05270f9ee3e9d7ebbed29ea840e41f48cd059e04733f7e1d401345" + ], + "index": "pypi", + "version": "==3.7.1" + }, + "types-requests": { + "hashes": [ + "sha256:8ec9f5f84adc6f579f53943312c28a84e87dc70201b54f7c4fbc7d22ecfa8a3e", + "sha256:c2f4e4754d07ca0a88fd8a89bbc6c8a9f90fb441f9c9b572fd5c484f04817486" + ], + "index": "pypi", + "version": "==2.27.8" + }, + "types-urllib3": { + "hashes": [ + "sha256:a929c68a57b24eee8f7003357b935b802e17767e752d9237266f799ca48ee326", + "sha256:aa0de26893f138523d5552bbb023826c0cc7ea5749d80c1693c57aab7b55f469" + ], + "version": "==1.26.8" + }, + "typing-extensions": { + "hashes": [ + "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e", + "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b" + ], + "markers": "python_version >= '3.6'", + "version": "==4.0.1" + }, + "urllib3": { + "hashes": [ + "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", + "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.8" + }, + "webencodings": { + "hashes": [ + "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", + "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" + ], + "version": "==0.5.1" + }, + "wheel": { + "hashes": [ + "sha256:4bdcd7d840138086126cd09254dc6195fb4fc6f01c050a1d7236f2630db1d22a", + "sha256:e9a504e793efbca1b8e0e9cb979a249cf4a0a7b5b8c9e8b65a5e39d49529c1c4" + ], + "index": "pypi", + "version": "==0.37.1" + }, + "zipp": { + "hashes": [ + "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d", + "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375" + ], + "markers": "python_version >= '3.7'", + "version": "==3.7.0" + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..82b7aaf --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Python 3 library and CLI for [SporeStack](https://sporestack.com) [.onion](http://spore64i5sofqlfz5gq2ju4msgzojjwifls7rok2cti624zyq3fcelad.onion) + +## Requirements + +* Python 3.7-3.10 (or maybe newer) + +## Installation + +* `pip install sporestack` +* Recommended: Create a virtual environment, first. Can use `pipenv`, as well. + +## Running without installing (preferred) + +* Make sure `pipx` is installed. +* `pipx run sporestack` +* Make sure you're on the latest version with `sporestack version`. + +## Screenshot + +![sporestack CLI screenshot](https://sporestack.com/static/sporestackv2-screenshot.png) + +## Usage + +* `sporestack launch SomeHostname --flavor vps-1vcpu-1gb --days 7 --ssh-key ~/.ssh/id_rsa.pub --operating-system debian-10 --currency btc` +* `sporestack topup SomeHostname --days 3 --currency xmr` +* `sporestack launch SomeOtherHostname --flavor vps-1vcpu-2gb --days 7 --ssh-key ~/.ssh/id_rsa.pub --operating-system debian-11 --currency btc` +* `sporestack stop SomeHostname` +* `sporestack start SomeHostname` +* `sporestack list` +* `sporestack remove SomeHostname # If expired` +* `sporestack settlement-token-generate` +* `sporestack settlement-token-enable (token) --dollars 10 --currency xmr` +* `sporestack settlement-token-add (token) --dollars 25 --currency btc` +* `sporestack settlement-token-balance (token)` + +More examples on the [website](https://sporestack.com). + +## Notes + +* You can use `--settlement-token` if you don't want to pay with QR codes all the time. +* If using a .onion API endpoint, will try to use a local Tor proxy if connecting to a .onion URL. (127.0.0.1:9050) + +## Developing + +* `pip install pipenv pre-commit` +* `pipenv install --deploy --dev` +* `pipenv run make test` (If you don't have `make`, use `almake`) +* Hint: `pre-commit run` is a faster way to run some of the tests/autofixers. + +## Licence + +[Unlicense/Public domain](LICENSE.txt) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b71b328 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[tool.coverage.report] +show_missing = true + +[tool.coverage.run] +omit = ["tests/*", "build/*"] + +# Have to use `pflake8` instead of `flake8` +[tool.flake8] +max-line-length = 88 +noqa-require-code = "true" +exclude = ".git,__pycache__,build,dist" +max-complexity = 15 + +[tool.isort] +profile = "black" + +[tool.mypy] +files = "." +plugins = ["pydantic.mypy"] +exclude = "(build|site-packages|__pycache__)" + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true +warn_untyped_fields = true + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b5057a0 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,50 @@ +[metadata] +name = sporestack +version = 5.2.1 +description = SporeStack.com library and client. Launch servers with Monero or Bitcoin. +long_description = file: README.md +long_description_content_type = text/markdown +url = https://sporestack.com/ +author = SporeStack +author_email = admin@sporestack.com +license = Unlicense +license_file = LICENSE.txt +classifiers = + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 +keywords = + bitcoin + bitcoincash + bitcoinsv + monero + servers + infrastructure + vps + virtual private server + +[options] +packages = find: +install_requires = + pydantic + requests[socks]>=2.22.0 + segno + typer + importlib-metadata;python_version<"3.8" +python_requires = >=3.7 +package_dir = =src +zip_safe = False + +[options.packages.find] +where = src + +[options.entry_points] +console_scripts = + sporestack = sporestack.cli:cli + +[options.package_data] +sporestack = + py.typed diff --git a/src/sporestack/__init__.py b/src/sporestack/__init__.py new file mode 100644 index 0000000..b54434c --- /dev/null +++ b/src/sporestack/__init__.py @@ -0,0 +1 @@ +__all__ = ["api", "api_client", "exceptions"] diff --git a/src/sporestack/api.py b/src/sporestack/api.py new file mode 100644 index 0000000..5cff3ae --- /dev/null +++ b/src/sporestack/api.py @@ -0,0 +1,136 @@ +""" + +SporeStack API request/response models + +""" + + +from typing import List, Optional + +from pydantic import BaseModel + +from .models import NetworkInterface, Payment + + +class TokenEnable: + url = "/token/{token}/enable" + method = "POST" + + class Request(BaseModel): + currency: str + dollars: int + + class Response(BaseModel): + token: str + payment: Payment + + +class TokenAdd: + url = "/token/{token}/add" + method = "POST" + + class Request(BaseModel): + currency: str + dollars: int + + class Response(BaseModel): + token: str + payment: Payment + + +class TokenBalance: + url = "/token/{token}/balance" + method = "GET" + + class Response(BaseModel): + token: str + cents: int + usd: str + + +class ServerLaunch: + url = "/server/{machine_id}/launch" + method = "POST" + + class Request(BaseModel): + machine_id: str + days: int + currency: str + flavor: str + ssh_key: str + operating_system: str + region: Optional[str] + organization: Optional[str] + settlement_token: Optional[str] + affiliate_amount: Optional[int] + affiliate_token: Optional[str] + + class Response(BaseModel): + created_at: Optional[int] + payment: Payment + expiration: Optional[int] + machine_id: str + network_interfaces: List[NetworkInterface] + region: str + latest_api_version: int + created: bool + paid: bool + warning: Optional[str] + txid: Optional[str] + operating_system: str + flavor: str + + +class ServerTopup: + url = "/server/{machine_id}/topup" + method = "POST" + + class Request(BaseModel): + machine_id: str + days: int + currency: str + settlement_token: Optional[str] + affiliate_amount: Optional[int] + affiliate_token: Optional[str] + + class Response(BaseModel): + machine_id: str + payment: Payment + paid: bool + warning: Optional[str] + expiration: int + txid: Optional[str] + latest_api_version: int + + +class ServerInfo: + url = "/server/{machine_id}/info" + method = "GET" + + class Response(BaseModel): + created_at: int + expiration: int + running: bool + machine_id: str + network_interfaces: List[NetworkInterface] + region: str + + +class ServerStart: + url = "/server/{machine_id}/start" + method = "POST" + + +class ServerStop: + url = "/server/{machine_id}/stop" + method = "POST" + + +class ServerDelete: + url = "/server/{machine_id}/delete" + method = "POST" + + +class ServerRebuild: + url = "/server/{machine_id}/rebuild" + method = "POST" diff --git a/src/sporestack/api_client.py b/src/sporestack/api_client.py new file mode 100644 index 0000000..80e5df1 --- /dev/null +++ b/src/sporestack/api_client.py @@ -0,0 +1,274 @@ +import logging +import os +from time import sleep +from typing import Any, Dict, Optional + +import requests + +from . import api, exceptions +from .version import __version__ + +log = logging.getLogger(__name__) + +LATEST_API_VERSION = 2 + +CLEARNET_ENDPOINT = "https://api.sporestack.com" +TOR_ENDPOINT = ( + "http://api.spore64i5sofqlfz5gq2ju4msgzojjwifls7rok2cti624zyq3fcelad.onion" +) + +API_ENDPOINT = CLEARNET_ENDPOINT + +GET_TIMEOUT = 60 +POST_TIMEOUT = 90 +USE_TOR_PROXY = "auto" + + +def _get_tor_proxy() -> str: + """ + This makes testing easier. + """ + return os.getenv("TOR_PROXY", "socks5h://127.0.0.1:9050") + + +# For requests module +TOR_PROXY_REQUESTS = {"http": _get_tor_proxy(), "https": _get_tor_proxy()} + + +def _is_onion_url(url: str) -> bool: + """ + returns True/False depending on if a URL looks like a Tor hidden service + (.onion) or not. + This is designed to false as non-onion just to be on the safe-ish side, + depending on your view point. It requires URLs like: http://domain.tld/, + not http://domain.tld or domain.tld/. + + This can be optimized a lot. + """ + try: + url_parts = url.split("/") + domain = url_parts[2] + tld = domain.split(".")[-1] + if tld == "onion": + return True + except Exception: + pass + return False + + +def _api_request( + url: str, + empty_post: bool = False, + json_params: Optional[Dict[str, Any]] = None, + retry: bool = False, +) -> Any: + headers = {"User-Agent": f"sporestack-python/{__version__}"} + proxies = {} + if _is_onion_url(url) is True: + log.debug("Got a .onion API endpoint, using local Tor SOCKS proxy.") + proxies = TOR_PROXY_REQUESTS + + try: + if empty_post is True: + request = requests.post( + url, timeout=POST_TIMEOUT, proxies=proxies, headers=headers + ) + elif json_params is None: + request = requests.get( + url, timeout=GET_TIMEOUT, proxies=proxies, headers=headers + ) + else: + request = requests.post( + url, + json=json_params, + timeout=POST_TIMEOUT, + proxies=proxies, + headers=headers, + ) + except Exception as e: + if retry is True: + log.warning(f"Got an error, but retrying: {e}") + sleep(5) + # Try again. + return _api_request( + url, + empty_post=empty_post, + json_params=json_params, + retry=retry, + ) + else: + raise + + status_code_first_digit = request.status_code // 100 + if status_code_first_digit == 2: + try: + return request.json() + except Exception: + return request.content + elif status_code_first_digit == 4: + log.debug("HTTP status code: {request.status_code}") + raise exceptions.SporeStackUserError(request.content.decode("utf-8")) + elif status_code_first_digit == 5: + if retry is True: + log.warning(request.content.decode("utf-8")) + log.warning("Got a 500, retrying in 5 seconds...") + sleep(5) + # Try again if we get a 500 + return _api_request( + url, + empty_post=empty_post, + json_params=json_params, + retry=retry, + ) + else: + raise exceptions.SporeStackServerError(str(request.content)) + else: + # Not sure why we'd get this. + request.raise_for_status() + raise Exception("Stuff broke strangely. Please contact SporeStack support.") + + +def launch( + machine_id: str, + days: int, + currency: str, + flavor: str, + operating_system: str, + ssh_key: str, + api_endpoint: str = API_ENDPOINT, + region: Optional[str] = None, + settlement_token: Optional[str] = None, + retry: bool = False, + affiliate_amount: Optional[int] = None, + affiliate_token: Optional[str] = None, +) -> api.ServerLaunch.Response: + request = api.ServerLaunch.Request( + machine_id=machine_id, + days=days, + currency=currency, + settlement_token=settlement_token, + affiliate_amount=affiliate_amount, + affiliate_token=affiliate_token, + flavor=flavor, + region=region, + operating_system=operating_system, + ssh_key=ssh_key, + ) + url = api_endpoint + api.ServerLaunch.url.format(machine_id=machine_id) + response = _api_request(url=url, json_params=request.dict(), retry=retry) + response_object = api.ServerLaunch.Response.parse_obj(response) + assert response_object.machine_id == machine_id + return response_object + + +def topup( + machine_id: str, + days: int, + currency: str, + api_endpoint: str = API_ENDPOINT, + settlement_token: Optional[str] = None, + retry: bool = False, + affiliate_amount: Optional[int] = None, + affiliate_token: Optional[str] = None, +) -> api.ServerTopup.Response: + """ + Topup a server. + """ + request = api.ServerTopup.Request( + machine_id=machine_id, + days=days, + currency=currency, + settlement_token=settlement_token, + affiliate_amount=affiliate_amount, + affiliate_token=affiliate_token, + ) + url = api_endpoint + api.ServerTopup.url.format(machine_id=machine_id) + response = _api_request(url=url, json_params=request.dict(), retry=retry) + response_object = api.ServerTopup.Response.parse_obj(response) + assert response_object.machine_id == machine_id + return response_object + + +def start(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None: + """ + Boots the server. + """ + url = api_endpoint + api.ServerStart.url.format(machine_id=machine_id) + _api_request(url, empty_post=True) + + +def stop(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None: + """ + Powers off the server. + """ + url = api_endpoint + api.ServerStop.url.format(machine_id=machine_id) + _api_request(url, empty_post=True) + + +def delete(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None: + """ + Deletes the server. + """ + url = api_endpoint + api.ServerDelete.url.format(machine_id=machine_id) + _api_request(url, empty_post=True) + + +def rebuild(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None: + """ + Rebuilds the server with the operating system and SSH key set at launch time. + + Deletes all of the data on the server! + """ + url = api_endpoint + api.ServerRebuild.url.format(machine_id=machine_id) + _api_request(url, empty_post=True) + + +def info(machine_id: str, api_endpoint: str = API_ENDPOINT) -> api.ServerInfo.Response: + """ + Returns info about the server. + """ + url = api_endpoint + api.ServerInfo.url.format(machine_id=machine_id) + response = _api_request(url) + response_object = api.ServerInfo.Response.parse_obj(response) + assert response_object.machine_id == machine_id + return response_object + + +def token_enable( + token: str, + dollars: int, + currency: str, + api_endpoint: str = API_ENDPOINT, + retry: bool = False, +) -> api.TokenEnable.Response: + request = api.TokenEnable.Request(dollars=dollars, currency=currency) + url = api_endpoint + api.TokenEnable.url.format(token=token) + response = _api_request(url=url, json_params=request.dict(), retry=retry) + response_object = api.TokenEnable.Response.parse_obj(response) + assert response_object.token == token + return response_object + + +def token_add( + token: str, + dollars: int, + currency: str, + api_endpoint: str = API_ENDPOINT, + retry: bool = False, +) -> api.TokenAdd.Response: + request = api.TokenAdd.Request(dollars=dollars, currency=currency) + url = api_endpoint + api.TokenAdd.url.format(token=token) + response = _api_request(url=url, json_params=request.dict(), retry=retry) + response_object = api.TokenAdd.Response.parse_obj(response) + assert response_object.token == token + return response_object + + +def token_balance( + token: str, api_endpoint: str = API_ENDPOINT +) -> api.TokenBalance.Response: + url = api_endpoint + api.TokenBalance.url.format(token=token) + response = _api_request(url=url) + response_object = api.TokenBalance.Response.parse_obj(response) + assert response_object.token == token + return response_object diff --git a/src/sporestack/cli.py b/src/sporestack/cli.py new file mode 100644 index 0000000..055a65f --- /dev/null +++ b/src/sporestack/cli.py @@ -0,0 +1,593 @@ +""" +SporeStack CLI: `sporestack` +""" + +import importlib.util +import json +import logging +import os +import sys +import time +from pathlib import Path +from types import ModuleType +from typing import TYPE_CHECKING, Any, Dict, Optional + +if sys.version_info[:2] >= (3, 8): # pragma: nocover + from importlib.metadata import version as importlib_metadata_version +else: # pragma: nocover + # Python 3.7 doesn't have this. + from importlib_metadata import version as importlib_metadata_version + +import typer + + +def lazy_import(name: str) -> ModuleType: + """ + Lazily import a module. Helps speed up CLI performance. + """ + spec = importlib.util.find_spec(name) + assert spec is not None + assert spec.loader is not None + loader = importlib.util.LazyLoader(spec.loader) + spec.loader = loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + loader.exec_module(module) + return module + + +# For mypy +if TYPE_CHECKING: + from . import api_client +else: + api_client = lazy_import("sporestack.api_client") + +HELP = """ +SporeStack Python CLI + +Optional environment variables: +SPORESTACK_ENDPOINT +*or* +SPORESTACK_USE_TOR_ENDPOINT + +TOR_PROXY (defaults to socks5h://127.0.0.1:9050 which is fine for most) +""" + +cli = typer.Typer(help=HELP) + +logging.basicConfig(level=logging.INFO) + +DEFAULT_FLAVOR = "vps-1vcpu-1gb" + +WAITING_PAYMENT_TO_PROCESS = "Waiting for payment to process..." + + +def get_api_endpoint() -> str: + api_endpoint = os.getenv("SPORESTACK_ENDPOINT", api_client.CLEARNET_ENDPOINT) + if os.getenv("SPORESTACK_USE_TOR_ENDPOINT", None) is not None: + api_endpoint = api_client.TOR_ENDPOINT + return api_endpoint + + +def make_payment(currency: str, uri: str, usd: str) -> None: + import segno + + premessage = """Payment URI: {} +Pay *exactly* the specified amount. No more, no less. Pay within +one hour at the very most. +Resize your terminal and try again if QR code above is not readable. +Press ctrl+c to abort.""" + message = premessage.format(uri) + qr = segno.make(uri) + # This typer.echos. + qr.terminal() + typer.echo(message) + typer.echo(f"Approximate price in USD: {usd}") + input("[Press enter once you have made payment.]") + + +@cli.command() +def launch( + hostname: str, + days: int = typer.Option(...), + ssh_key_file: Path = typer.Option(...), + operating_system: str = typer.Option(...), + flavor: str = DEFAULT_FLAVOR, + currency: Optional[str] = None, + settlement_token: Optional[str] = None, + region: Optional[str] = None, +) -> None: + """ + Attempts to launch a server. + """ + + from . import utils + + if settlement_token is not None: + if currency is None or currency == "settlement": + currency = "settlement" + else: + msg = "Cannot use non-settlement --currency with --settlement-token" + typer.echo(msg, err=True) + raise typer.Exit(code=2) + if currency is None: + typer.echo("--currency must be set.", err=True) + raise typer.Exit(code=2) + + if machine_exists(hostname): + typer.echo(f"{hostname} already created.") + raise typer.Exit(code=1) + + ssh_key = ssh_key_file.read_text() + + machine_id = utils.random_machine_id() + + assert currency is not None + response = api_client.launch( + machine_id=machine_id, + days=days, + flavor=flavor, + operating_system=operating_system, + ssh_key=ssh_key, + currency=currency, + region=region, + settlement_token=settlement_token, + api_endpoint=get_api_endpoint(), + retry=True, + ) + + # This will be false at least the first time if paying with BTC or BCH. + if response.payment.paid is False: + assert response.payment.uri is not None + make_payment( + currency=currency, + uri=response.payment.uri, + usd=response.payment.usd, + ) + + tries = 360 + while tries > 0: + tries = tries - 1 + typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True) + # FIXME: Wait one hour in a smarter way. + # Waiting for payment to set in. + time.sleep(10) + response = api_client.launch( + machine_id=machine_id, + days=days, + flavor=flavor, + operating_system=operating_system, + ssh_key=ssh_key, + currency=currency, + region=region, + settlement_token=settlement_token, + api_endpoint=get_api_endpoint(), + retry=True, + ) + if response.payment.paid is True: + break + + if response.created is False: + tries = 360 + while tries > 0: + typer.echo("Waiting for server to build...", err=True) + tries = tries + 1 + # Waiting for server to spin up. + time.sleep(10) + response = api_client.launch( + machine_id=machine_id, + days=days, + flavor=flavor, + operating_system=operating_system, + ssh_key=ssh_key, + currency=currency, + region=region, + settlement_token=settlement_token, + api_endpoint=get_api_endpoint(), + retry=True, + ) + if response.created is True: + break + + if response.created is False: + typer.echo("Server creation failed, tries exceeded.", err=True) + raise typer.Exit(code=1) + + created_dict = response.dict() + created_dict["vm_hostname"] = hostname + save_machine_info(created_dict) + typer.echo(pretty_machine_info(created_dict), err=True) + typer.echo(json.dumps(created_dict, indent=4)) + + +@cli.command() +def topup( + hostname: str, + days: int = typer.Option(...), + currency: Optional[str] = None, + settlement_token: Optional[str] = None, +) -> None: + """ + tops up an existing vm. + """ + if settlement_token is not None: + if currency is None or currency == "settlement": + currency = "settlement" + else: + msg = "Cannot use non-settlement --currency with --settlement-token" + typer.echo(msg, err=True) + raise typer.Exit(code=2) + + if currency is None: + typer.echo("--currency must be set.", err=True) + raise typer.Exit(code=2) + + if not machine_exists(hostname): + typer.echo(f"{hostname} does not exist.") + raise typer.Exit(code=1) + + machine_info = get_machine_info(hostname) + machine_id = machine_info["machine_id"] + + assert currency is not None + response = api_client.topup( + machine_id=machine_id, + days=days, + currency=currency, + api_endpoint=get_api_endpoint(), + settlement_token=settlement_token, + retry=True, + ) + + # This will be false at least the first time if paying with anything + # but settlement. + if response.payment.paid is False: + assert response.payment.uri is not None + make_payment( + currency=currency, + uri=response.payment.uri, + usd=response.payment.usd, + ) + + tries = 360 + while tries > 0: + typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True) + tries = tries - 1 + # FIXME: Wait one hour in a smarter way. + # Waiting for payment to set in. + time.sleep(10) + response = api_client.topup( + machine_id=machine_id, + days=days, + currency=currency, + api_endpoint=get_api_endpoint(), + settlement_token=settlement_token, + retry=True, + ) + if response.payment.paid is True: + break + + machine_info["expiration"] = response.expiration + save_machine_info(machine_info, overwrite=True) + typer.echo(machine_info["expiration"]) + + +def machine_info_path() -> Path: + home = os.getenv("HOME") + assert home is not None, "Unable to detect $HOME environment variable?" + sporestack_dir = Path(home, ".sporestack") + old_sporestack_dir = Path(home, ".sporestackv2") + if old_sporestack_dir.exists(): + typer.echo( + "~/.sporestackv2 will be renamed to ~/.sporestack, this is backwards incompatible!!", # noqa: E501 + err=True, + ) + if sporestack_dir.exists(): + typer.echo( + "~/.sporestackv2 AND ~/.sporestack detected. ABORTING! Contact support.", # noqa: E501 + err=True, + ) + sys.exit(1) + else: + old_sporestack_dir.rename(sporestack_dir) + + # Make it, if it doesn't exist already. + sporestack_dir.mkdir(exist_ok=True) + + return sporestack_dir + + +def save_machine_info(machine_info: Dict[str, Any], overwrite: bool = False) -> None: + """ + Save info to disk. + """ + os.umask(0o0077) + directory = machine_info_path() + hostname = machine_info["vm_hostname"] + json_file = directory.joinpath(f"{hostname}.json") + if overwrite is False: + assert json_file.exists() is False, f"{json_file} already exists." + json_file.write_text(json.dumps(machine_info)) + + +def get_machine_info(hostname: str) -> Dict[str, Any]: + """ + Get info from disk. + """ + directory = machine_info_path() + json_file = directory.joinpath(f"{hostname}.json") + if not json_file.exists(): + raise ValueError(f"{hostname} does not exist in {directory} as {json_file}") + machine_info = json.loads(json_file.read_bytes()) + assert isinstance(machine_info, dict) + if machine_info["vm_hostname"] != hostname: + raise ValueError("hostname does not match filename.") + return machine_info + + +def pretty_machine_info(info: Dict[str, Any]) -> str: + msg = "Hostname: {}\n".format(info["vm_hostname"]) + msg += "Machine ID (keep this secret!): {}\n".format(info["machine_id"]) + if "ipv6" in info["network_interfaces"][0]: + msg += "IPv6: {}\n".format(info["network_interfaces"][0]["ipv6"]) + if "ipv4" in info["network_interfaces"][0]: + msg += "IPv4: {}\n".format(info["network_interfaces"][0]["ipv4"]) + expiration = info["expiration"] + human_expiration = time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime(expiration)) + if "running" in info: + msg += "Running: {}\n".format(info["running"]) + msg += f"Expiration: {expiration} ({human_expiration})\n" + time_to_live = expiration - int(time.time()) + hours = time_to_live // 3600 + msg += f"Server will be deleted in {hours} hours." + return msg + + +@cli.command() +def list() -> None: + """ + List all locally known servers. + """ + directory = machine_info_path() + infos = [] + for hostname_json in os.listdir(directory): + hostname = hostname_json.split(".")[0] + saved_vm_info = get_machine_info(hostname) + try: + upstream_vm_info = api_client.info( + machine_id=saved_vm_info["machine_id"], + ) + saved_vm_info["expiration"] = upstream_vm_info.expiration + saved_vm_info["running"] = upstream_vm_info.running + infos.append(saved_vm_info) + except ValueError as e: + expiration = saved_vm_info["expiration"] + human_expiration = time.strftime( + "%Y-%m-%d %H:%M:%S %z", time.localtime(expiration) + ) + msg = hostname + msg += f" expired ({expiration} {human_expiration}): " + msg += str(e) + typer.echo(msg) + + for info in infos: + typer.echo() + typer.echo(pretty_machine_info(info)) + + typer.echo() + + +def machine_exists(hostname: str) -> bool: + """ + Check if the VM's JSON exists locally. + """ + return machine_info_path().joinpath(f"{hostname}.json").exists() + + +@cli.command() +def get_attribute(hostname: str, attribute: str) -> None: + """ + Returns an attribute about the VM. + """ + machine_info = get_machine_info(hostname) + typer.echo(machine_info[attribute]) + + +@cli.command() +def info(hostname: str) -> None: + """ + Info on the VM + """ + machine_info = get_machine_info(hostname) + machine_id = machine_info["machine_id"] + typer.echo( + api_client.info(machine_id=machine_id, api_endpoint=get_api_endpoint()).json() + ) + + +@cli.command() +def start(hostname: str) -> None: + """ + Boots the VM. + """ + machine_info = get_machine_info(hostname) + machine_id = machine_info["machine_id"] + api_client.start(machine_id=machine_id, api_endpoint=get_api_endpoint()) + typer.echo(f"{hostname} started.") + + +@cli.command() +def stop(hostname: str) -> None: + """ + Immediately kills the VM. + """ + machine_info = get_machine_info(hostname) + machine_id = machine_info["machine_id"] + api_client.stop(machine_id=machine_id, api_endpoint=get_api_endpoint()) + typer.echo(f"{hostname} stopped.") + + +@cli.command() +def delete(hostname: str) -> None: + """ + Deletes the VM (most likely prematurely. + """ + machine_info = get_machine_info(hostname) + machine_id = machine_info["machine_id"] + api_client.delete(machine_id=machine_id, api_endpoint=get_api_endpoint()) + # Also remove the .json file + machine_info_path().joinpath(f"{hostname}.json").unlink() + typer.echo(f"{hostname} was deleted.") + + +@cli.command() +def rebuild(hostname: str) -> None: + """ + Rebuilds the VM with the operating system and SSH key given at launch time. + + Will take a couple minutes to complete after the request is made. + """ + machine_info = get_machine_info(hostname) + machine_id = machine_info["machine_id"] + api_client.rebuild(machine_id=machine_id, api_endpoint=get_api_endpoint()) + typer.echo(f"{hostname} rebuilding.") + + +@cli.command() +def settlement_token_enable( + token: str, + dollars: int = typer.Option(...), + currency: str = typer.Option(...), +) -> None: + """ + Enables a new settlement token. + + Dollars is starting balance. + """ + + response = api_client.token_enable( + token=token, + dollars=dollars, + currency=currency, + api_endpoint=get_api_endpoint(), + retry=True, + ) + + uri = response.payment.uri + assert uri is not None + usd = response.payment.usd + + make_payment(currency=currency, uri=uri, usd=usd) + + tries = 360 * 2 + while tries > 0: + typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True) + tries = tries - 1 + # FIXME: Wait two hours in a smarter way. + # Waiting for payment to set in. + time.sleep(10) + response = api_client.token_enable( + token=token, + dollars=dollars, + currency=currency, + api_endpoint=get_api_endpoint(), + retry=True, + ) + if response.payment.paid is True: + typer.echo( + f"{token} has been enabled with ${dollars}. Save it and don't lose it!" + ) + return + raise ValueError(f"{token} did not get enabled in time.") + + +@cli.command() +def settlement_token_add( + token: str, + dollars: int = typer.Option(...), + currency: str = typer.Option(...), +) -> None: + """ + Adds balance to an existing settlement token. + """ + + response = api_client.token_add( + token, + dollars, + currency=currency, + api_endpoint=get_api_endpoint(), + retry=True, + ) + + uri = response.payment.uri + assert uri is not None + usd = response.payment.usd + + make_payment(currency=currency, uri=uri, usd=usd) + + tries = 360 * 2 + while tries > 0: + typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True) + tries = tries - 1 + # FIXME: Wait two hours in a smarter way. + response = api_client.token_add( + token, + dollars, + currency=currency, + api_endpoint=get_api_endpoint(), + retry=True, + ) + # Waiting for payment to set in. + time.sleep(10) + if response.payment.paid is True: + typer.echo(f"Added {dollars} dollars to {token}") + return + raise ValueError(f"{token} did not get enabled in time.") + + +@cli.command() +def settlement_token_balance(token: str) -> None: + """ + Gets balance for a settlement token. + """ + + typer.echo( + api_client.token_balance(token=token, api_endpoint=get_api_endpoint()).usd + ) + + +@cli.command() +def settlement_token_generate() -> None: + """ + Generates a settlement token that can be enabled. + """ + from . import utils + + typer.echo(utils.random_token()) + + +@cli.command() +def version() -> None: + """ + Returns the installed version. + """ + typer.echo(importlib_metadata_version(__package__)) + + +@cli.command() +def api_endpoint() -> None: + """ + Prints the selected API endpoint: Env var: SPORESTACK_ENDPOINT, + or, SPORESTACK_USE_TOR=1 + """ + endpoint = get_api_endpoint() + if ".onion" in endpoint: + typer.echo(f"{endpoint} using {api_client._get_tor_proxy()}") + return + else: + typer.echo(endpoint) + return + + +if __name__ == "__main__": + cli() diff --git a/src/sporestack/exceptions.py b/src/sporestack/exceptions.py new file mode 100644 index 0000000..80a7d15 --- /dev/null +++ b/src/sporestack/exceptions.py @@ -0,0 +1,14 @@ +class SporeStackError(Exception): + pass + + +class SporeStackUserError(SporeStackError): + """HTTP 4XX""" + + pass + + +class SporeStackServerError(SporeStackError): + """HTTP 5XX""" + + pass diff --git a/src/sporestack/models.py b/src/sporestack/models.py new file mode 100644 index 0000000..b72d30d --- /dev/null +++ b/src/sporestack/models.py @@ -0,0 +1,22 @@ +""" + +SporeStack API supplemental models + +""" + + +from typing import Optional + +from pydantic import BaseModel + + +class NetworkInterface(BaseModel): + ipv4: str + ipv6: str + + +class Payment(BaseModel): + txid: Optional[str] + uri: Optional[str] + usd: str + paid: bool diff --git a/src/sporestack/py.typed b/src/sporestack/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/sporestack/utils.py b/src/sporestack/utils.py new file mode 100644 index 0000000..9ababd8 --- /dev/null +++ b/src/sporestack/utils.py @@ -0,0 +1,27 @@ +import secrets +from base64 import b16encode +from struct import pack +from zlib import adler32 + + +def checksum(to_hash: str) -> str: + """ + Base 16 string of half the adler32 checksum + """ + adler32_hash = adler32(bytes(to_hash, "utf-8")) + return b16encode(pack("I", adler32_hash)).decode("utf-8").lower()[-4:] + + +def random_machine_id() -> str: + """ + These used to be 64 hex characters. Now they have a new format. + """ + to_hash = f"ss_m_{secrets.token_hex(11)}" + return f"{to_hash}_{checksum(to_hash)}" + + +def random_token() -> str: + """ + 64 hex characters. + """ + return secrets.token_hex(32) diff --git a/src/sporestack/version.py b/src/sporestack/version.py new file mode 100644 index 0000000..108d161 --- /dev/null +++ b/src/sporestack/version.py @@ -0,0 +1,10 @@ +import sys + +if sys.version_info[:2] >= (3, 8): # pragma: nocover + from importlib.metadata import version as importlib_metadata_version +else: # pragma: nocover + # Python 3.7 doesn't have this. + from importlib_metadata import version as importlib_metadata_version + + +__version__ = importlib_metadata_version(__package__) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api_client.py b/tests/test_api_client.py new file mode 100644 index 0000000..17cd838 --- /dev/null +++ b/tests/test_api_client.py @@ -0,0 +1,132 @@ +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import ValidationError + +from sporestack import api_client + + +def test__is_onion_url() -> None: + onion_url = "http://spore64i5sofqlfz5gq2ju4msgzojjwifls7" + onion_url += "rok2cti624zyq3fcelad.onion/v2/" + assert api_client._is_onion_url(onion_url) is True + # This is a good, unusual test. + onion_url = "https://www.facebookcorewwwi.onion/" + assert api_client._is_onion_url(onion_url) is True + assert api_client._is_onion_url("http://domain.com") is False + assert api_client._is_onion_url("domain.com") is False + assert api_client._is_onion_url("http://onion.domain.com/.onion/") is False + assert api_client._is_onion_url("http://me.me/file.onion/") is False + assert api_client._is_onion_url("http://me.me/file.onion") is False + + +@patch("sporestack.api_client._api_request") +def test_launch(mock_api_request: MagicMock) -> None: + with pytest.raises(ValidationError): + api_client.launch( + "dummymachineid", + currency="xmr", + days=1, + operating_system="freebsd-12", + ssh_key="id-rsa...", + flavor="aflavor", + ) + json_params = { + "machine_id": "dummymachineid", + "days": 1, + "currency": "xmr", + "flavor": "aflavor", + "ssh_key": "id-rsa...", + "operating_system": "freebsd-12", + "region": None, + "organization": None, + "settlement_token": None, + "affiliate_amount": None, + "affiliate_token": None, + } + mock_api_request.assert_called_once_with( + url="https://api.sporestack.com/server/dummymachineid/launch", + json_params=json_params, + retry=False, + ) + + +@patch("sporestack.api_client._api_request") +def test_topup(mock_api_request: MagicMock) -> None: + with pytest.raises(ValidationError): + api_client.topup("dummymachineid", currency="xmr", days=1) + json_params = { + "machine_id": "dummymachineid", + "days": 1, + "currency": "xmr", + "settlement_token": None, + "affiliate_amount": None, + "affiliate_token": None, + } + mock_api_request.assert_called_once_with( + url="https://api.sporestack.com/server/dummymachineid/topup", + json_params=json_params, + retry=False, + ) + + +@patch("sporestack.api_client._api_request") +def test_start(mock_api_request: MagicMock) -> None: + api_client.start("dummymachineid") + mock_api_request.assert_called_once_with( + "https://api.sporestack.com/server/dummymachineid/start", empty_post=True + ) + + +@patch("sporestack.api_client._api_request") +def test_stop(mock_api_request: MagicMock) -> None: + api_client.stop("dummymachineid") + mock_api_request.assert_called_once_with( + "https://api.sporestack.com/server/dummymachineid/stop", empty_post=True + ) + + +@patch("sporestack.api_client._api_request") +def test_rebuild(mock_api_request: MagicMock) -> None: + api_client.rebuild("dummymachineid") + mock_api_request.assert_called_once_with( + "https://api.sporestack.com/server/dummymachineid/rebuild", empty_post=True + ) + + +@patch("sporestack.api_client._api_request") +def test_info(mock_api_request: MagicMock) -> None: + with pytest.raises(ValidationError): + api_client.info("dummymachineid") + mock_api_request.assert_called_once_with( + "https://api.sporestack.com/server/dummymachineid/info" + ) + + +@patch("sporestack.api_client._api_request") +def test_delete(mock_api_request: MagicMock) -> None: + api_client.delete("dummymachineid") + mock_api_request.assert_called_once_with( + "https://api.sporestack.com/server/dummymachineid/delete", empty_post=True + ) + + +@patch("sporestack.api_client._api_request") +def test_token_balance(mock_api_request: MagicMock) -> None: + with pytest.raises(ValidationError): + api_client.token_balance("dummytoken") + mock_api_request.assert_called_once_with( + url="https://api.sporestack.com/token/dummytoken/balance" + ) + + +@patch("sporestack.api_client._api_request") +def test_token_enable(mock_api_request: MagicMock) -> None: + with pytest.raises(ValidationError): + api_client.token_enable("dummytoken", currency="xmr", dollars=20) + json_params = {"currency": "xmr", "dollars": 20} + mock_api_request.assert_called_once_with( + url="https://api.sporestack.com/token/dummytoken/enable", + json_params=json_params, + retry=False, + ) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..09f9f40 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,44 @@ +from _pytest.monkeypatch import MonkeyPatch +from typer.testing import CliRunner + +from sporestack import cli +from sporestack.api_client import TOR_ENDPOINT + +runner = CliRunner() + + +def test_version() -> None: + result = runner.invoke(cli.cli, ["version"]) + assert "." in result.output + assert result.exit_code == 0 + + +def test_get_api_endpoint(monkeypatch: MonkeyPatch) -> None: + monkeypatch.delenv("SPORESTACK_ENDPOINT", raising=False) + monkeypatch.delenv("SPORESTACK_USE_TOR_ENDPOINT", raising=False) + assert cli.get_api_endpoint() == "https://api.sporestack.com" + monkeypatch.setenv("SPORESTACK_USE_TOR_ENDPOINT", "1") + assert ".onion" in cli.get_api_endpoint() + monkeypatch.delenv("SPORESTACK_USE_TOR_ENDPOINT") + monkeypatch.setenv("SPORESTACK_ENDPOINT", "oog.boog") + assert cli.get_api_endpoint() == "oog.boog" + + +def test_cli_api_endpoint(monkeypatch: MonkeyPatch) -> None: + # So tests pass locally, even if these are set. + monkeypatch.delenv("SPORESTACK_ENDPOINT", raising=False) + monkeypatch.delenv("SPORESTACK_USE_TOR_ENDPOINT", raising=False) + monkeypatch.delenv("TOR_PROXY", raising=False) + result = runner.invoke(cli.cli, ["api-endpoint"]) + assert result.output == "https://api.sporestack.com" + "\n" + assert result.exit_code == 0 + + monkeypatch.setenv("SPORESTACK_USE_TOR_ENDPOINT", "1") + result = runner.invoke(cli.cli, ["api-endpoint"]) + assert result.output == TOR_ENDPOINT + " using socks5h://127.0.0.1:9050\n" + assert result.exit_code == 0 + + monkeypatch.setenv("TOR_PROXY", "socks5h://127.0.0.1:1337") + result = runner.invoke(cli.cli, ["api-endpoint"]) + assert result.output == TOR_ENDPOINT + " using socks5h://127.0.0.1:1337\n" + assert result.exit_code == 0 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..32f501b --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,11 @@ +from sporestack import utils + + +def test_random_machine_id() -> None: + assert utils.random_machine_id() != utils.random_machine_id() + assert len(utils.random_machine_id()) == 32 + assert utils.random_machine_id().startswith("ss_m_") + + +def test_hash() -> None: + assert utils.checksum("ss_m_1deadbeefcafedeadbeef1") == "0892"