From 8e00f28940a71ba5af40919d742ba7b9b621b20e Mon Sep 17 00:00:00 2001 From: SporeStack Date: Wed, 8 Feb 2023 21:05:34 +0000 Subject: [PATCH] v9.0.0: Use httpx, /server/quote support, Client support --- CHANGELOG.md | 12 ++ Makefile | 2 +- Pipfile.lock | 187 ++++++++++------------------- pyproject.toml | 2 +- src/sporestack/__init__.py | 2 +- src/sporestack/api.py | 21 +++- src/sporestack/api_client.py | 221 +++++++++++++++++------------------ src/sporestack/cli.py | 151 +++++++++++++----------- src/sporestack/client.py | 25 ++++ tests/test_cli.py | 6 +- 10 files changed, 317 insertions(+), 312 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed55c4a..721facc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [9.0.0 - 2023-02-08] + +### Added + +- `Client` added to `client` +- `/server/quote` support +- `--no-wait` option for `sporestack server launch` to not wait for an IP address to be assigned. + +### Changed + +- Now uses `httpx` instead of `requests` + ## [8.0.0 - 2023-02-07] ### Changed diff --git a/Makefile b/Makefile index ff18a50..09ed32e 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ format: test: black --check . ruff . - python -m mypy --strict . + mypy $(MAKE) test-pytest test-pytest: diff --git a/Pipfile.lock b/Pipfile.lock index 9b4a072..d39ae9c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -14,6 +14,14 @@ ] }, "default": { + "anyio": { + "hashes": [ + "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421", + "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3" + ], + "markers": "python_full_version >= '3.6.2'", + "version": "==3.6.2" + }, "certifi": { "hashes": [ "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3", @@ -22,100 +30,6 @@ "markers": "python_version >= '3.6'", "version": "==2022.12.7" }, - "charset-normalizer": { - "hashes": [ - "sha256:00d3ffdaafe92a5dc603cb9bd5111aaa36dfa187c8285c543be562e61b755f6b", - "sha256:024e606be3ed92216e2b6952ed859d86b4cfa52cd5bc5f050e7dc28f9b43ec42", - "sha256:0298eafff88c99982a4cf66ba2efa1128e4ddaca0b05eec4c456bbc7db691d8d", - "sha256:02a51034802cbf38db3f89c66fb5d2ec57e6fe7ef2f4a44d070a593c3688667b", - "sha256:083c8d17153ecb403e5e1eb76a7ef4babfc2c48d58899c98fcaa04833e7a2f9a", - "sha256:0a11e971ed097d24c534c037d298ad32c6ce81a45736d31e0ff0ad37ab437d59", - "sha256:0bf2dae5291758b6f84cf923bfaa285632816007db0330002fa1de38bfcb7154", - "sha256:0c0a590235ccd933d9892c627dec5bc7511ce6ad6c1011fdf5b11363022746c1", - "sha256:0f438ae3532723fb6ead77e7c604be7c8374094ef4ee2c5e03a3a17f1fca256c", - "sha256:109487860ef6a328f3eec66f2bf78b0b72400280d8f8ea05f69c51644ba6521a", - "sha256:11b53acf2411c3b09e6af37e4b9005cba376c872503c8f28218c7243582df45d", - "sha256:12db3b2c533c23ab812c2b25934f60383361f8a376ae272665f8e48b88e8e1c6", - "sha256:14e76c0f23218b8f46c4d87018ca2e441535aed3632ca134b10239dfb6dadd6b", - "sha256:16a8663d6e281208d78806dbe14ee9903715361cf81f6d4309944e4d1e59ac5b", - "sha256:292d5e8ba896bbfd6334b096e34bffb56161c81408d6d036a7dfa6929cff8783", - "sha256:2c03cc56021a4bd59be889c2b9257dae13bf55041a3372d3295416f86b295fb5", - "sha256:2e396d70bc4ef5325b72b593a72c8979999aa52fb8bcf03f701c1b03e1166918", - "sha256:2edb64ee7bf1ed524a1da60cdcd2e1f6e2b4f66ef7c077680739f1641f62f555", - "sha256:31a9ddf4718d10ae04d9b18801bd776693487cbb57d74cc3458a7673f6f34639", - "sha256:356541bf4381fa35856dafa6a965916e54bed415ad8a24ee6de6e37deccf2786", - "sha256:358a7c4cb8ba9b46c453b1dd8d9e431452d5249072e4f56cfda3149f6ab1405e", - "sha256:37f8febc8ec50c14f3ec9637505f28e58d4f66752207ea177c1d67df25da5aed", - "sha256:39049da0ffb96c8cbb65cbf5c5f3ca3168990adf3551bd1dee10c48fce8ae820", - "sha256:39cf9ed17fe3b1bc81f33c9ceb6ce67683ee7526e65fde1447c772afc54a1bb8", - "sha256:3ae1de54a77dc0d6d5fcf623290af4266412a7c4be0b1ff7444394f03f5c54e3", - "sha256:3b590df687e3c5ee0deef9fc8c547d81986d9a1b56073d82de008744452d6541", - "sha256:3e45867f1f2ab0711d60c6c71746ac53537f1684baa699f4f668d4c6f6ce8e14", - "sha256:3fc1c4a2ffd64890aebdb3f97e1278b0cc72579a08ca4de8cd2c04799a3a22be", - "sha256:4457ea6774b5611f4bed5eaa5df55f70abde42364d498c5134b7ef4c6958e20e", - "sha256:44ba614de5361b3e5278e1241fda3dc1838deed864b50a10d7ce92983797fa76", - "sha256:4a8fcf28c05c1f6d7e177a9a46a1c52798bfe2ad80681d275b10dcf317deaf0b", - "sha256:4b0d02d7102dd0f997580b51edc4cebcf2ab6397a7edf89f1c73b586c614272c", - "sha256:502218f52498a36d6bf5ea77081844017bf7982cdbe521ad85e64cabee1b608b", - "sha256:503e65837c71b875ecdd733877d852adbc465bd82c768a067badd953bf1bc5a3", - "sha256:5995f0164fa7df59db4746112fec3f49c461dd6b31b841873443bdb077c13cfc", - "sha256:59e5686dd847347e55dffcc191a96622f016bc0ad89105e24c14e0d6305acbc6", - "sha256:601f36512f9e28f029d9481bdaf8e89e5148ac5d89cffd3b05cd533eeb423b59", - "sha256:608862a7bf6957f2333fc54ab4399e405baad0163dc9f8d99cb236816db169d4", - "sha256:62595ab75873d50d57323a91dd03e6966eb79c41fa834b7a1661ed043b2d404d", - "sha256:70990b9c51340e4044cfc394a81f614f3f90d41397104d226f21e66de668730d", - "sha256:71140351489970dfe5e60fc621ada3e0f41104a5eddaca47a7acb3c1b851d6d3", - "sha256:72966d1b297c741541ca8cf1223ff262a6febe52481af742036a0b296e35fa5a", - "sha256:74292fc76c905c0ef095fe11e188a32ebd03bc38f3f3e9bcb85e4e6db177b7ea", - "sha256:761e8904c07ad053d285670f36dd94e1b6ab7f16ce62b9805c475b7aa1cffde6", - "sha256:772b87914ff1152b92a197ef4ea40efe27a378606c39446ded52c8f80f79702e", - "sha256:79909e27e8e4fcc9db4addea88aa63f6423ebb171db091fb4373e3312cb6d603", - "sha256:7e189e2e1d3ed2f4aebabd2d5b0f931e883676e51c7624826e0a4e5fe8a0bf24", - "sha256:7eb33a30d75562222b64f569c642ff3dc6689e09adda43a082208397f016c39a", - "sha256:81d6741ab457d14fdedc215516665050f3822d3e56508921cc7239f8c8e66a58", - "sha256:8499ca8f4502af841f68135133d8258f7b32a53a1d594aa98cc52013fff55678", - "sha256:84c3990934bae40ea69a82034912ffe5a62c60bbf6ec5bc9691419641d7d5c9a", - "sha256:87701167f2a5c930b403e9756fab1d31d4d4da52856143b609e30a1ce7160f3c", - "sha256:88600c72ef7587fe1708fd242b385b6ed4b8904976d5da0893e31df8b3480cb6", - "sha256:8ac7b6a045b814cf0c47f3623d21ebd88b3e8cf216a14790b455ea7ff0135d18", - "sha256:8b8af03d2e37866d023ad0ddea594edefc31e827fee64f8de5611a1dbc373174", - "sha256:8c7fe7afa480e3e82eed58e0ca89f751cd14d767638e2550c77a92a9e749c317", - "sha256:8eade758719add78ec36dc13201483f8e9b5d940329285edcd5f70c0a9edbd7f", - "sha256:911d8a40b2bef5b8bbae2e36a0b103f142ac53557ab421dc16ac4aafee6f53dc", - "sha256:93ad6d87ac18e2a90b0fe89df7c65263b9a99a0eb98f0a3d2e079f12a0735837", - "sha256:95dea361dd73757c6f1c0a1480ac499952c16ac83f7f5f4f84f0658a01b8ef41", - "sha256:9ab77acb98eba3fd2a85cd160851816bfce6871d944d885febf012713f06659c", - "sha256:9cb3032517f1627cc012dbc80a8ec976ae76d93ea2b5feaa9d2a5b8882597579", - "sha256:9cf4e8ad252f7c38dd1f676b46514f92dc0ebeb0db5552f5f403509705e24753", - "sha256:9d9153257a3f70d5f69edf2325357251ed20f772b12e593f3b3377b5f78e7ef8", - "sha256:a152f5f33d64a6be73f1d30c9cc82dfc73cec6477ec268e7c6e4c7d23c2d2291", - "sha256:a16418ecf1329f71df119e8a65f3aa68004a3f9383821edcb20f0702934d8087", - "sha256:a60332922359f920193b1d4826953c507a877b523b2395ad7bc716ddd386d866", - "sha256:a8d0fc946c784ff7f7c3742310cc8a57c5c6dc31631269876a88b809dbeff3d3", - "sha256:ab5de034a886f616a5668aa5d098af2b5385ed70142090e2a31bcbd0af0fdb3d", - "sha256:c22d3fe05ce11d3671297dc8973267daa0f938b93ec716e12e0f6dee81591dc1", - "sha256:c2ac1b08635a8cd4e0cbeaf6f5e922085908d48eb05d44c5ae9eabab148512ca", - "sha256:c512accbd6ff0270939b9ac214b84fb5ada5f0409c44298361b2f5e13f9aed9e", - "sha256:c75ffc45f25324e68ab238cb4b5c0a38cd1c3d7f1fb1f72b5541de469e2247db", - "sha256:c95a03c79bbe30eec3ec2b7f076074f4281526724c8685a42872974ef4d36b72", - "sha256:cadaeaba78750d58d3cc6ac4d1fd867da6fc73c88156b7a3212a3cd4819d679d", - "sha256:cd6056167405314a4dc3c173943f11249fa0f1b204f8b51ed4bde1a9cd1834dc", - "sha256:db72b07027db150f468fbada4d85b3b2729a3db39178abf5c543b784c1254539", - "sha256:df2c707231459e8a4028eabcd3cfc827befd635b3ef72eada84ab13b52e1574d", - "sha256:e62164b50f84e20601c1ff8eb55620d2ad25fb81b59e3cd776a1902527a788af", - "sha256:e696f0dd336161fca9adbb846875d40752e6eba585843c768935ba5c9960722b", - "sha256:eaa379fcd227ca235d04152ca6704c7cb55564116f8bc52545ff357628e10602", - "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f", - "sha256:f4c39b0e3eac288fedc2b43055cfc2ca7a60362d0e5e87a637beac5d801ef478", - "sha256:f5057856d21e7586765171eac8b9fc3f7d44ef39425f85dbcccb13b3ebea806c", - "sha256:f6f45710b4459401609ebebdbcfb34515da4fc2aa886f95107f556ac69a9147e", - "sha256:f97e83fa6c25693c7a35de154681fcc257c1c41b38beb0304b9c4d2d9e164479", - "sha256:f9d0c5c045a3ca9bedfc35dca8526798eb91a07aa7a2c0fee134c6c6f321cbd7", - "sha256:ff6f3db31555657f3163b15a6b7c6938d08df7adbfc9dd13d9d19edad678f1e8" - ], - "markers": "python_full_version >= '3.6.0'", - "version": "==3.0.1" - }, "click": { "hashes": [ "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", @@ -124,6 +38,33 @@ "markers": "python_version >= '3.7'", "version": "==8.1.3" }, + "h11": { + "hashes": [ + "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", + "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" + ], + "markers": "python_version >= '3.7'", + "version": "==0.14.0" + }, + "httpcore": { + "hashes": [ + "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb", + "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0" + ], + "markers": "python_version >= '3.7'", + "version": "==0.16.3" + }, + "httpx": { + "extras": [ + "socks" + ], + "hashes": [ + "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9", + "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6" + ], + "markers": "python_version >= '3.7'", + "version": "==0.23.3" + }, "idna": { "hashes": [ "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", @@ -174,24 +115,15 @@ "markers": "python_version >= '3.7'", "version": "==1.10.4" }, - "pysocks": { - "hashes": [ - "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299", - "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", - "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0" - ], - "version": "==1.7.1" - }, - "requests": { + "rfc3986": { "extras": [ - "socks" + "idna2008" ], "hashes": [ - "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa", - "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf" + "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", + "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" ], - "markers": "python_version >= '3.7' and python_version < '4'", - "version": "==2.28.2" + "version": "==1.5.0" }, "segno": { "hashes": [ @@ -201,6 +133,21 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==1.5.2" }, + "sniffio": { + "hashes": [ + "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101", + "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384" + ], + "markers": "python_version >= '3.7'", + "version": "==1.3.0" + }, + "socksio": { + "hashes": [ + "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", + "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac" + ], + "version": "==1.0.0" + }, "sporestack": { "editable": true, "path": "." @@ -220,14 +167,6 @@ ], "markers": "python_version >= '3.7'", "version": "==4.4.0" - }, - "urllib3": { - "hashes": [ - "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72", - "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==1.26.14" } }, "develop": { @@ -761,9 +700,7 @@ "version": "==37.3" }, "requests": { - "extras": [ - "socks" - ], + "extras": [], "hashes": [ "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa", "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf" @@ -780,12 +717,14 @@ "version": "==0.10.1" }, "rfc3986": { - "hashes": [ - "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", - "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" + "extras": [ + "idna2008" ], - "markers": "python_version >= '3.7'", - "version": "==2.0.0" + "hashes": [ + "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", + "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" + ], + "version": "==1.5.0" }, "ruff": { "hashes": [ diff --git a/pyproject.toml b/pyproject.toml index 0146ca3..d09247d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ keywords = ["bitcoin", "monero", "vps"] license = {file = "LICENSE.txt"} dependencies = [ "pydantic", - "requests[socks]>=2.22.0", + "httpx[socks]", "segno", "typer", ] diff --git a/src/sporestack/__init__.py b/src/sporestack/__init__.py index 6774856..3c48abf 100644 --- a/src/sporestack/__init__.py +++ b/src/sporestack/__init__.py @@ -2,4 +2,4 @@ __all__ = ["api", "api_client", "exceptions"] -__version__ = "8.0.0" +__version__ = "9.0.0" diff --git a/src/sporestack/api.py b/src/sporestack/api.py index 48483f1..b4b009a 100644 --- a/src/sporestack/api.py +++ b/src/sporestack/api.py @@ -7,7 +7,7 @@ SporeStack API request/response models from typing import Dict, List, Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field from .models import Flavor, NetworkInterface, Payment @@ -36,6 +36,25 @@ class TokenBalance: usd: str +class ServerQuote: + url = "/server/quote" + method = "GET" + + """Takes days and flavor as parameters.""" + + class Response(BaseModel): + cents: int = Field( + default=..., ge=1, title="Cents", description="(US) cents", example=1_000_00 + ) + usd: str = Field( + default=..., + min_length=5, + title="USD", + description="USD in $1,000.00 format", + example="$1,000.00", + ) + + class ServerLaunch: url = "/server/{machine_id}/launch" method = "POST" diff --git a/src/sporestack/api_client.py b/src/sporestack/api_client.py index 372c303..8905aec 100644 --- a/src/sporestack/api_client.py +++ b/src/sporestack/api_client.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from time import sleep from typing import Any, Dict, Optional -import requests +import httpx from . import __version__, api, exceptions @@ -23,15 +23,14 @@ GET_TIMEOUT = 60 POST_TIMEOUT = 90 USE_TOR_PROXY = "auto" - -session = requests.Session() +HEADERS = {"User-Agent": f"sporestack-python/{__version__}"} def _get_tor_proxy() -> str: """ This makes testing easier. """ - return os.getenv("TOR_PROXY", "socks5h://127.0.0.1:9050") + return os.getenv("TOR_PROXY", "socks5://127.0.0.1:9050") # For requests module @@ -59,82 +58,78 @@ def _is_onion_url(url: str) -> bool: 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 = session.post( - url, timeout=POST_TIMEOUT, proxies=proxies, headers=headers - ) - elif json_params is None: - request = session.get( - url, timeout=GET_TIMEOUT, proxies=proxies, headers=headers - ) - else: - request = session.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.") - - @dataclass class APIClient: api_endpoint: str = API_ENDPOINT + def __post_init__(self) -> None: + headers = httpx.Headers(HEADERS) + proxy = None + if _is_onion_url(self.api_endpoint): + proxy = _get_tor_proxy() + self._httpx_client = httpx.Client(headers=headers, proxies=proxy) + + def _api_request( + self, + url: str, + empty_post: bool = False, + json_params: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + retry: bool = False, + ) -> Any: + try: + if empty_post is True: + request = self._httpx_client.post(url, timeout=POST_TIMEOUT) + elif json_params is None: + request = self._httpx_client.get(url, timeout=GET_TIMEOUT) + else: + request = self._httpx_client.post( + url, + json=json_params, + timeout=POST_TIMEOUT, + ) + except Exception as e: + if retry is True: + log.warning(f"Got an error, but retrying: {e}") + sleep(5) + # Try again. + return self._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 self._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 server_launch( self, machine_id: str, @@ -144,10 +139,10 @@ class APIClient: ssh_key: str, token: str, region: Optional[str] = None, - quote: bool = False, hostname: str = "", autorenew: bool = False, - ) -> api.ServerLaunch.Response: + ) -> None: + """Launch a server.""" request = api.ServerLaunch.Request( days=days, token=token, @@ -155,31 +150,36 @@ class APIClient: region=region, operating_system=operating_system, ssh_key=ssh_key, - quote=quote, hostname=hostname, autorenew=autorenew, ) url = self.api_endpoint + api.ServerLaunch.url.format(machine_id=machine_id) - response = _api_request(url=url, json_params=request.dict()) - response_object = api.ServerLaunch.Response.parse_obj(response) - assert response_object.machine_id == machine_id - return response_object + self._api_request(url=url, json_params=request.dict()) def server_topup( self, machine_id: str, days: int, token: str, - ) -> api.ServerTopup.Response: - """ - Topup a server. - """ + ) -> None: + """Topup a server.""" request = api.ServerTopup.Request(days=days, token=token) url = self.api_endpoint + api.ServerTopup.url.format(machine_id=machine_id) - response = _api_request(url=url, json_params=request.dict()) - response_object = api.ServerTopup.Response.parse_obj(response) - assert response_object.machine_id == machine_id - return response_object + self._api_request(url=url, json_params=request.dict()) + + def server_quote(self, days: int, flavor: str) -> api.ServerQuote.Response: + """Get a quote for how much a server will cost.""" + + url = self.api_endpoint + api.ServerQuote.url + response = self._httpx_client.get( + url=url, + params={"days": days, "flavor": flavor}, + ) + if response.status_code == 422: + raise exceptions.SporeStackUserError(response.json()["detail"]) + + response.raise_for_status() + return api.ServerQuote.Response.parse_obj(response.json()) def autorenew_enable(self, machine_id: str) -> None: """ @@ -188,7 +188,7 @@ class APIClient: url = self.api_endpoint + api.ServerEnableAutorenew.url.format( machine_id=machine_id ) - _api_request(url, empty_post=True) + self._api_request(url, empty_post=True) def autorenew_disable(self, machine_id: str) -> None: """ @@ -197,35 +197,35 @@ class APIClient: url = self.api_endpoint + api.ServerDisableAutorenew.url.format( machine_id=machine_id ) - _api_request(url, empty_post=True) + self._api_request(url, empty_post=True) def server_start(self, machine_id: str) -> None: """ Power on the server. """ url = self.api_endpoint + api.ServerStart.url.format(machine_id=machine_id) - _api_request(url, empty_post=True) + self._api_request(url, empty_post=True) def server_stop(self, machine_id: str) -> None: """ Power off the server. """ url = self.api_endpoint + api.ServerStop.url.format(machine_id=machine_id) - _api_request(url, empty_post=True) + self._api_request(url, empty_post=True) def server_delete(self, machine_id: str) -> None: """ Delete the server. """ url = self.api_endpoint + api.ServerDelete.url.format(machine_id=machine_id) - _api_request(url, empty_post=True) + self._api_request(url, empty_post=True) def server_forget(self, machine_id: str) -> None: """ Forget about a destroyed/deleted server. """ url = self.api_endpoint + api.ServerForget.url.format(machine_id=machine_id) - _api_request(url, empty_post=True) + self._api_request(url, empty_post=True) def server_rebuild(self, machine_id: str) -> None: """ @@ -234,16 +234,15 @@ class APIClient: Deletes all of the data on the server! """ url = self.api_endpoint + api.ServerRebuild.url.format(machine_id=machine_id) - _api_request(url, empty_post=True) + self._api_request(url, empty_post=True) def server_info(self, machine_id: str) -> api.ServerInfo.Response: """ Returns info about the server. """ url = self.api_endpoint + api.ServerInfo.url.format(machine_id=machine_id) - response = _api_request(url) + response = self._api_request(url) response_object = api.ServerInfo.Response.parse_obj(response) - assert response_object.machine_id == machine_id return response_object def servers_launched_from_token( @@ -253,25 +252,21 @@ class APIClient: Returns info of servers launched from a given token. """ url = self.api_endpoint + api.ServersLaunchedFromToken.url.format(token=token) - response = _api_request(url) + response = self._api_request(url) response_object = api.ServersLaunchedFromToken.Response.parse_obj(response) return response_object def flavors(self) -> api.Flavors.Response: - """ - Returns available flavors. - """ + """Returns available flavors (server sizes).""" url = self.api_endpoint + api.Flavors.url - response = _api_request(url) + response = self._api_request(url) response_object = api.Flavors.Response.parse_obj(response) return response_object def operating_systems(self) -> api.OperatingSystems.Response: - """ - Returns available operating systems. - """ + """Returns available operating systems.""" url = self.api_endpoint + api.OperatingSystems.url - response = _api_request(url) + response = self._api_request(url) response_object = api.OperatingSystems.Response.parse_obj(response) return response_object @@ -282,16 +277,16 @@ class APIClient: currency: str, retry: bool = False, ) -> api.TokenAdd.Response: + """Add balance (money) to a token.""" request = api.TokenAdd.Request(dollars=dollars, currency=currency) url = self.api_endpoint + api.TokenAdd.url.format(token=token) - response = _api_request(url=url, json_params=request.dict(), retry=retry) + response = self._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(self, token: str) -> api.TokenBalance.Response: + """Return a token's balance.""" url = self.api_endpoint + api.TokenBalance.url.format(token=token) - response = _api_request(url=url) + response = self._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 index 037e6c0..a42533a 100644 --- a/src/sporestack/cli.py +++ b/src/sporestack/cli.py @@ -7,10 +7,14 @@ import logging import os import time from pathlib import Path -from typing import Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional import typer +if TYPE_CHECKING: + from . import api + + HELP = """ SporeStack Python CLI @@ -19,7 +23,7 @@ SPORESTACK_ENDPOINT *or* SPORESTACK_USE_TOR_ENDPOINT -TOR_PROXY (defaults to socks5h://127.0.0.1:9050 which is fine for most) +TOR_PROXY (defaults to socks5://127.0.0.1:9050 which is fine for most) """ _home = os.getenv("HOME", None) @@ -82,14 +86,19 @@ Press ctrl+c to abort.""" @server_cli.command() def launch( hostname: str = "", - days: int = typer.Option(...), - operating_system: str = typer.Option(...), + days: int = typer.Option( + ..., min=1, max=90, help="Number of days the server should run for." + ), + operating_system: str = typer.Option(..., help="Example: debian-11"), ssh_key_file: Path = DEFAULT_SSH_KEY_FILE, flavor: str = DEFAULT_FLAVOR, token: str = DEFAULT_TOKEN, region: Optional[str] = None, quote: bool = typer.Option(True, help="Require manual price confirmation."), - autorenew: bool = typer.Option(False, help="BETA: Automatically renew server."), + autorenew: bool = typer.Option(False, help="Automatically renew server."), + wait: bool = typer.Option( + True, help="Wait for server to be assigned an IP address." + ), ) -> None: """ Launch a server on SporeStack. @@ -99,8 +108,10 @@ def launch( from . import utils from .api_client import APIClient + from .client import Client api_client = APIClient(api_endpoint=get_api_endpoint()) + client = Client(api_client=api_client, client_token=_token) typer.echo(f"Loading SSH key from {ssh_key_file}...") if not ssh_key_file.exists(): @@ -114,20 +125,9 @@ def launch( machine_id = utils.random_machine_id() if quote: - response = api_client.server_launch( - machine_id=machine_id, - days=days, - flavor=flavor, - operating_system=operating_system, - ssh_key=ssh_key, - region=region, - token=_token, - quote=True, - hostname=hostname, - autorenew=autorenew, - ) + quote_response = client.server_quote(days=days, flavor=flavor) - msg = f"Is {response.payment.usd} for {days} day(s) of {flavor} okay?" + msg = f"Is {quote_response.usd} for {days} day(s) of {flavor} okay?" typer.echo(msg, err=True) input("[Press ctrl+c to cancel, or enter to accept.]") @@ -141,34 +141,37 @@ def launch( err=True, ) - tries = 360 - while tries > 0: - response = api_client.server_launch( - machine_id=machine_id, - days=days, - flavor=flavor, - operating_system=operating_system, - ssh_key=ssh_key, - region=region, - token=_token, - hostname=hostname, - autorenew=autorenew, - ) - if response.created is True: - break - typer.echo("Waiting for server to build...", err=True) - tries = tries + 1 - # Waiting for server to spin up. - time.sleep(10) - - if response.created is False: - typer.echo("Server creation failed, tries exceeded.", err=True) - raise typer.Exit(code=1) - - typer.echo( - pretty_machine_info(api_client.server_info(machine_id=machine_id).dict()) + server = client.token.launch_server( + machine_id=machine_id, + days=days, + flavor=flavor, + operating_system=operating_system, + ssh_key=ssh_key, + region=region, + hostname=hostname, + autorenew=autorenew, ) + if wait: + tries = 360 + while tries > 0: + response = server.info() + if response.ipv4 != "": + break + typer.echo("Waiting for server to build...", err=True) + tries = tries + 1 + # Waiting for server to spin up. + time.sleep(10) + + if response.ipv4 == "": + typer.echo("Server creation failed, tries exceeded.", err=True) + raise typer.Exit(code=1) + else: + print_machine_info(response) + return + + print_machine_info(server.info()) + @server_cli.command() def topup( @@ -271,6 +274,38 @@ def pretty_machine_info(info: Dict[str, Any]) -> str: return msg +def print_machine_info(info: "api.ServerInfo.Response") -> None: + if info.hostname != "": + typer.echo(f"Hostname: {info.hostname}") + else: + typer.echo("Hostname: (none) (No hostname set)") + + typer.echo(f"Machine ID (keep this secret!): {info.machine_id}") + if info.ipv6 != "": + typer.echo(f"IPv6: {info.ipv6}") + else: + typer.echo("IPv6: (Not yet assigned)") + if info.ipv4 != "": + typer.echo(f"IPv4: {info.ipv4}") + else: + typer.echo("IPv4: (Not yet assigned)") + typer.echo(f"Region: {info.region}") + typer.echo(f"Flavor: {info.flavor.slug}") + human_expiration = time.strftime( + "%Y-%m-%d %H:%M:%S %z", time.localtime(info.expiration) + ) + typer.echo(f"Expiration: {info.expiration} ({human_expiration})") + typer.echo(f"Token: {info.token}") + if info.deleted: + typer.echo("Server was deleted!") + else: + typer.echo(f"Running: {info.running}") + time_to_live = info.expiration - int(time.time()) + hours = time_to_live // 3600 + typer.echo(f"Server will be deleted in {hours} hours.") + typer.echo(f"Autorenew: {info.autorenew}") + + @server_cli.command(name="list") def server_list( token: str = DEFAULT_TOKEN, @@ -307,27 +342,9 @@ def server_list( if hostname == "": if info.machine_id in machine_id_hostnames: hostname = machine_id_hostnames[info.machine_id] - if hostname != "": - typer.echo(f"Hostname: {hostname}") + info.hostname = hostname - typer.echo(f"Machine ID (keep this secret!): {info.machine_id}") - typer.echo(f"IPv6: {info.network_interfaces[0].ipv6}") - typer.echo(f"IPv4: {info.network_interfaces[0].ipv4}") - typer.echo(f"Region: {info.region}") - typer.echo(f"Flavor: {info.flavor.slug}") - human_expiration = time.strftime( - "%Y-%m-%d %H:%M:%S %z", time.localtime(info.expiration) - ) - typer.echo(f"Expiration: {info.expiration} ({human_expiration})") - typer.echo(f"Token: {info.token}") - if info.deleted: - typer.echo("Server was deleted!") - else: - typer.echo(f"Running: {info.running}") - time_to_live = info.expiration - int(time.time()) - hours = time_to_live // 3600 - typer.echo(f"Server will be deleted in {hours} hours.") - typer.echo(f"Autorenew: {info.autorenew}") + print_machine_info(info) printed_machine_ids.append(info.machine_id) @@ -406,9 +423,7 @@ def info(hostname: str = "", machine_id: str = "", token: str = DEFAULT_TOKEN) - from .api_client import APIClient api_client = APIClient(api_endpoint=get_api_endpoint()) - typer.echo( - pretty_machine_info(api_client.server_info(machine_id=machine_id).dict()) - ) + print_machine_info(api_client.server_info(machine_id=machine_id)) @server_cli.command(name="json") @@ -759,7 +774,7 @@ def version() -> None: def api_endpoint() -> None: """ Prints the selected API endpoint: Env var: SPORESTACK_ENDPOINT, - or, SPORESTACK_USE_TOR=1 + or, SPORESTACK_USE_TOR_ENDPOINT=1 """ from . import api_client diff --git a/src/sporestack/client.py b/src/sporestack/client.py index 43ed656..f4561c3 100644 --- a/src/sporestack/client.py +++ b/src/sporestack/client.py @@ -101,3 +101,28 @@ class Token: return Server( machine_id=machine_id, api_client=self.api_client, token=self.token ) + + +@dataclass +class Client: + client_token: str = "" + """Token to manage/pay for servers with.""" + api_client: APIClient = APIClient() + """Your own API Client, perhaps if you want to connect through Tor.""" + + def flavors(self) -> api.Flavors.Response: + """Returns available flavors (server sizes).""" + return self.api_client.flavors() + + def operating_systems(self) -> api.OperatingSystems.Response: + """Returns available operating systems.""" + return self.api_client.operating_systems() + + def server_quote(self, days: int, flavor: str) -> api.ServerQuote.Response: + """Get a quote for how much a server will cost.""" + return self.api_client.server_quote(days=days, flavor=flavor) + + @property + def token(self) -> Token: + """Returns a Token object with the api_client and token specified.""" + return Token(token=self.client_token, api_client=self.api_client) diff --git a/tests/test_cli.py b/tests/test_cli.py index b2828b9..22304c0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -36,12 +36,12 @@ def test_cli_api_endpoint(monkeypatch: MonkeyPatch) -> None: 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.output == TOR_ENDPOINT + " using socks5://127.0.0.1:9050\n" assert result.exit_code == 0 - monkeypatch.setenv("TOR_PROXY", "socks5h://127.0.0.1:1337") + monkeypatch.setenv("TOR_PROXY", "socks5://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.output == TOR_ENDPOINT + " using socks5://127.0.0.1:1337\n" assert result.exit_code == 0