From afe2d5e1788a6cd10ded2f77131d5d1852cd37a3 Mon Sep 17 00:00:00 2001 From: /dev/null Date: Thu, 10 Feb 2022 21:47:57 +0000 Subject: [PATCH] 5.2.1 --- .editorconfig | 12 + .gitignore | 10 + .pre-commit-config.yaml | 34 ++ .woodpecker.yml | 39 ++ CHANGELOG.md | 35 ++ LICENSE.txt | 24 ++ Makefile | 21 ++ Pipfile | 30 ++ Pipfile.lock | 681 +++++++++++++++++++++++++++++++++++ README.md | 52 +++ pyproject.toml | 30 ++ setup.cfg | 50 +++ src/sporestack/__init__.py | 1 + src/sporestack/api.py | 136 +++++++ src/sporestack/api_client.py | 274 ++++++++++++++ src/sporestack/cli.py | 593 ++++++++++++++++++++++++++++++ src/sporestack/exceptions.py | 14 + src/sporestack/models.py | 22 ++ src/sporestack/py.typed | 0 src/sporestack/utils.py | 27 ++ src/sporestack/version.py | 10 + tests/__init__.py | 0 tests/test_api_client.py | 132 +++++++ tests/test_cli.py | 44 +++ tests/test_utils.py | 11 + 25 files changed, 2282 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE.txt create mode 100644 Makefile create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 src/sporestack/__init__.py create mode 100644 src/sporestack/api.py create mode 100644 src/sporestack/api_client.py create mode 100644 src/sporestack/cli.py create mode 100644 src/sporestack/exceptions.py create mode 100644 src/sporestack/models.py create mode 100644 src/sporestack/py.typed create mode 100644 src/sporestack/utils.py create mode 100644 src/sporestack/version.py create mode 100644 tests/__init__.py create mode 100644 tests/test_api_client.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_utils.py 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"