v9.0.0: Use httpx, /server/quote support, Client support

This commit is contained in:
Administrator 2023-02-08 21:05:34 +00:00
parent 7a6c09dad6
commit 8e00f28940
10 changed files with 317 additions and 312 deletions

View File

@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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] ## [8.0.0 - 2023-02-07]
### Changed ### Changed

View File

@ -5,7 +5,7 @@ format:
test: test:
black --check . black --check .
ruff . ruff .
python -m mypy --strict . mypy
$(MAKE) test-pytest $(MAKE) test-pytest
test-pytest: test-pytest:

187
Pipfile.lock generated
View File

@ -14,6 +14,14 @@
] ]
}, },
"default": { "default": {
"anyio": {
"hashes": [
"sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421",
"sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"
],
"markers": "python_full_version >= '3.6.2'",
"version": "==3.6.2"
},
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3", "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3",
@ -22,100 +30,6 @@
"markers": "python_version >= '3.6'", "markers": "python_version >= '3.6'",
"version": "==2022.12.7" "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": { "click": {
"hashes": [ "hashes": [
"sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e",
@ -124,6 +38,33 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==8.1.3" "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": { "idna": {
"hashes": [ "hashes": [
"sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4",
@ -174,24 +115,15 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==1.10.4" "version": "==1.10.4"
}, },
"pysocks": { "rfc3986": {
"hashes": [
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
"sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5",
"sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
],
"version": "==1.7.1"
},
"requests": {
"extras": [ "extras": [
"socks" "idna2008"
], ],
"hashes": [ "hashes": [
"sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa", "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835",
"sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf" "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"
], ],
"markers": "python_version >= '3.7' and python_version < '4'", "version": "==1.5.0"
"version": "==2.28.2"
}, },
"segno": { "segno": {
"hashes": [ "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'", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==1.5.2" "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": { "sporestack": {
"editable": true, "editable": true,
"path": "." "path": "."
@ -220,14 +167,6 @@
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==4.4.0" "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": { "develop": {
@ -761,9 +700,7 @@
"version": "==37.3" "version": "==37.3"
}, },
"requests": { "requests": {
"extras": [ "extras": [],
"socks"
],
"hashes": [ "hashes": [
"sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa", "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa",
"sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf" "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"
@ -780,12 +717,14 @@
"version": "==0.10.1" "version": "==0.10.1"
}, },
"rfc3986": { "rfc3986": {
"hashes": [ "extras": [
"sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", "idna2008"
"sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"
], ],
"markers": "python_version >= '3.7'", "hashes": [
"version": "==2.0.0" "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835",
"sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"
],
"version": "==1.5.0"
}, },
"ruff": { "ruff": {
"hashes": [ "hashes": [

View File

@ -53,7 +53,7 @@ keywords = ["bitcoin", "monero", "vps"]
license = {file = "LICENSE.txt"} license = {file = "LICENSE.txt"}
dependencies = [ dependencies = [
"pydantic", "pydantic",
"requests[socks]>=2.22.0", "httpx[socks]",
"segno", "segno",
"typer", "typer",
] ]

View File

@ -2,4 +2,4 @@
__all__ = ["api", "api_client", "exceptions"] __all__ = ["api", "api_client", "exceptions"]
__version__ = "8.0.0" __version__ = "9.0.0"

View File

@ -7,7 +7,7 @@ SporeStack API request/response models
from typing import Dict, List, Optional from typing import Dict, List, Optional
from pydantic import BaseModel from pydantic import BaseModel, Field
from .models import Flavor, NetworkInterface, Payment from .models import Flavor, NetworkInterface, Payment
@ -36,6 +36,25 @@ class TokenBalance:
usd: str 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: class ServerLaunch:
url = "/server/{machine_id}/launch" url = "/server/{machine_id}/launch"
method = "POST" method = "POST"

View File

@ -4,7 +4,7 @@ from dataclasses import dataclass
from time import sleep from time import sleep
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import requests import httpx
from . import __version__, api, exceptions from . import __version__, api, exceptions
@ -23,15 +23,14 @@ GET_TIMEOUT = 60
POST_TIMEOUT = 90 POST_TIMEOUT = 90
USE_TOR_PROXY = "auto" USE_TOR_PROXY = "auto"
HEADERS = {"User-Agent": f"sporestack-python/{__version__}"}
session = requests.Session()
def _get_tor_proxy() -> str: def _get_tor_proxy() -> str:
""" """
This makes testing easier. 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 # For requests module
@ -59,82 +58,78 @@ def _is_onion_url(url: str) -> bool:
return False 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 @dataclass
class APIClient: class APIClient:
api_endpoint: str = API_ENDPOINT 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( def server_launch(
self, self,
machine_id: str, machine_id: str,
@ -144,10 +139,10 @@ class APIClient:
ssh_key: str, ssh_key: str,
token: str, token: str,
region: Optional[str] = None, region: Optional[str] = None,
quote: bool = False,
hostname: str = "", hostname: str = "",
autorenew: bool = False, autorenew: bool = False,
) -> api.ServerLaunch.Response: ) -> None:
"""Launch a server."""
request = api.ServerLaunch.Request( request = api.ServerLaunch.Request(
days=days, days=days,
token=token, token=token,
@ -155,31 +150,36 @@ class APIClient:
region=region, region=region,
operating_system=operating_system, operating_system=operating_system,
ssh_key=ssh_key, ssh_key=ssh_key,
quote=quote,
hostname=hostname, hostname=hostname,
autorenew=autorenew, autorenew=autorenew,
) )
url = self.api_endpoint + api.ServerLaunch.url.format(machine_id=machine_id) url = self.api_endpoint + api.ServerLaunch.url.format(machine_id=machine_id)
response = _api_request(url=url, json_params=request.dict()) self._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
def server_topup( def server_topup(
self, self,
machine_id: str, machine_id: str,
days: int, days: int,
token: str, token: str,
) -> api.ServerTopup.Response: ) -> None:
""" """Topup a server."""
Topup a server.
"""
request = api.ServerTopup.Request(days=days, token=token) request = api.ServerTopup.Request(days=days, token=token)
url = self.api_endpoint + api.ServerTopup.url.format(machine_id=machine_id) url = self.api_endpoint + api.ServerTopup.url.format(machine_id=machine_id)
response = _api_request(url=url, json_params=request.dict()) self._api_request(url=url, json_params=request.dict())
response_object = api.ServerTopup.Response.parse_obj(response)
assert response_object.machine_id == machine_id def server_quote(self, days: int, flavor: str) -> api.ServerQuote.Response:
return response_object """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: def autorenew_enable(self, machine_id: str) -> None:
""" """
@ -188,7 +188,7 @@ class APIClient:
url = self.api_endpoint + api.ServerEnableAutorenew.url.format( url = self.api_endpoint + api.ServerEnableAutorenew.url.format(
machine_id=machine_id 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: def autorenew_disable(self, machine_id: str) -> None:
""" """
@ -197,35 +197,35 @@ class APIClient:
url = self.api_endpoint + api.ServerDisableAutorenew.url.format( url = self.api_endpoint + api.ServerDisableAutorenew.url.format(
machine_id=machine_id 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: def server_start(self, machine_id: str) -> None:
""" """
Power on the server. Power on the server.
""" """
url = self.api_endpoint + api.ServerStart.url.format(machine_id=machine_id) 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: def server_stop(self, machine_id: str) -> None:
""" """
Power off the server. Power off the server.
""" """
url = self.api_endpoint + api.ServerStop.url.format(machine_id=machine_id) 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: def server_delete(self, machine_id: str) -> None:
""" """
Delete the server. Delete the server.
""" """
url = self.api_endpoint + api.ServerDelete.url.format(machine_id=machine_id) 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: def server_forget(self, machine_id: str) -> None:
""" """
Forget about a destroyed/deleted server. Forget about a destroyed/deleted server.
""" """
url = self.api_endpoint + api.ServerForget.url.format(machine_id=machine_id) 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: def server_rebuild(self, machine_id: str) -> None:
""" """
@ -234,16 +234,15 @@ class APIClient:
Deletes all of the data on the server! Deletes all of the data on the server!
""" """
url = self.api_endpoint + api.ServerRebuild.url.format(machine_id=machine_id) 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: def server_info(self, machine_id: str) -> api.ServerInfo.Response:
""" """
Returns info about the server. Returns info about the server.
""" """
url = self.api_endpoint + api.ServerInfo.url.format(machine_id=machine_id) 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) response_object = api.ServerInfo.Response.parse_obj(response)
assert response_object.machine_id == machine_id
return response_object return response_object
def servers_launched_from_token( def servers_launched_from_token(
@ -253,25 +252,21 @@ class APIClient:
Returns info of servers launched from a given token. Returns info of servers launched from a given token.
""" """
url = self.api_endpoint + api.ServersLaunchedFromToken.url.format(token=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) response_object = api.ServersLaunchedFromToken.Response.parse_obj(response)
return response_object return response_object
def flavors(self) -> api.Flavors.Response: def flavors(self) -> api.Flavors.Response:
""" """Returns available flavors (server sizes)."""
Returns available flavors.
"""
url = self.api_endpoint + api.Flavors.url url = self.api_endpoint + api.Flavors.url
response = _api_request(url) response = self._api_request(url)
response_object = api.Flavors.Response.parse_obj(response) response_object = api.Flavors.Response.parse_obj(response)
return response_object return response_object
def operating_systems(self) -> api.OperatingSystems.Response: def operating_systems(self) -> api.OperatingSystems.Response:
""" """Returns available operating systems."""
Returns available operating systems.
"""
url = self.api_endpoint + api.OperatingSystems.url url = self.api_endpoint + api.OperatingSystems.url
response = _api_request(url) response = self._api_request(url)
response_object = api.OperatingSystems.Response.parse_obj(response) response_object = api.OperatingSystems.Response.parse_obj(response)
return response_object return response_object
@ -282,16 +277,16 @@ class APIClient:
currency: str, currency: str,
retry: bool = False, retry: bool = False,
) -> api.TokenAdd.Response: ) -> api.TokenAdd.Response:
"""Add balance (money) to a token."""
request = api.TokenAdd.Request(dollars=dollars, currency=currency) request = api.TokenAdd.Request(dollars=dollars, currency=currency)
url = self.api_endpoint + api.TokenAdd.url.format(token=token) 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) response_object = api.TokenAdd.Response.parse_obj(response)
assert response_object.token == token
return response_object return response_object
def token_balance(self, token: str) -> api.TokenBalance.Response: def token_balance(self, token: str) -> api.TokenBalance.Response:
"""Return a token's balance."""
url = self.api_endpoint + api.TokenBalance.url.format(token=token) 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) response_object = api.TokenBalance.Response.parse_obj(response)
assert response_object.token == token
return response_object return response_object

View File

@ -7,10 +7,14 @@ import logging
import os import os
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import TYPE_CHECKING, Any, Dict, Optional
import typer import typer
if TYPE_CHECKING:
from . import api
HELP = """ HELP = """
SporeStack Python CLI SporeStack Python CLI
@ -19,7 +23,7 @@ SPORESTACK_ENDPOINT
*or* *or*
SPORESTACK_USE_TOR_ENDPOINT 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) _home = os.getenv("HOME", None)
@ -82,14 +86,19 @@ Press ctrl+c to abort."""
@server_cli.command() @server_cli.command()
def launch( def launch(
hostname: str = "", hostname: str = "",
days: int = typer.Option(...), days: int = typer.Option(
operating_system: str = 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, ssh_key_file: Path = DEFAULT_SSH_KEY_FILE,
flavor: str = DEFAULT_FLAVOR, flavor: str = DEFAULT_FLAVOR,
token: str = DEFAULT_TOKEN, token: str = DEFAULT_TOKEN,
region: Optional[str] = None, region: Optional[str] = None,
quote: bool = typer.Option(True, help="Require manual price confirmation."), 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: ) -> None:
""" """
Launch a server on SporeStack. Launch a server on SporeStack.
@ -99,8 +108,10 @@ def launch(
from . import utils from . import utils
from .api_client import APIClient from .api_client import APIClient
from .client import Client
api_client = APIClient(api_endpoint=get_api_endpoint()) 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}...") typer.echo(f"Loading SSH key from {ssh_key_file}...")
if not ssh_key_file.exists(): if not ssh_key_file.exists():
@ -114,20 +125,9 @@ def launch(
machine_id = utils.random_machine_id() machine_id = utils.random_machine_id()
if quote: if quote:
response = api_client.server_launch( quote_response = client.server_quote(days=days, flavor=flavor)
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,
)
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) typer.echo(msg, err=True)
input("[Press ctrl+c to cancel, or enter to accept.]") input("[Press ctrl+c to cancel, or enter to accept.]")
@ -141,34 +141,37 @@ def launch(
err=True, err=True,
) )
tries = 360 server = client.token.launch_server(
while tries > 0: machine_id=machine_id,
response = api_client.server_launch( days=days,
machine_id=machine_id, flavor=flavor,
days=days, operating_system=operating_system,
flavor=flavor, ssh_key=ssh_key,
operating_system=operating_system, region=region,
ssh_key=ssh_key, hostname=hostname,
region=region, autorenew=autorenew,
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())
) )
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() @server_cli.command()
def topup( def topup(
@ -271,6 +274,38 @@ def pretty_machine_info(info: Dict[str, Any]) -> str:
return msg 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") @server_cli.command(name="list")
def server_list( def server_list(
token: str = DEFAULT_TOKEN, token: str = DEFAULT_TOKEN,
@ -307,27 +342,9 @@ def server_list(
if hostname == "": if hostname == "":
if info.machine_id in machine_id_hostnames: if info.machine_id in machine_id_hostnames:
hostname = machine_id_hostnames[info.machine_id] hostname = machine_id_hostnames[info.machine_id]
if hostname != "": info.hostname = hostname
typer.echo(f"Hostname: {hostname}")
typer.echo(f"Machine ID (keep this secret!): {info.machine_id}") print_machine_info(info)
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}")
printed_machine_ids.append(info.machine_id) 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 from .api_client import APIClient
api_client = APIClient(api_endpoint=get_api_endpoint()) api_client = APIClient(api_endpoint=get_api_endpoint())
typer.echo( print_machine_info(api_client.server_info(machine_id=machine_id))
pretty_machine_info(api_client.server_info(machine_id=machine_id).dict())
)
@server_cli.command(name="json") @server_cli.command(name="json")
@ -759,7 +774,7 @@ def version() -> None:
def api_endpoint() -> None: def api_endpoint() -> None:
""" """
Prints the selected API endpoint: Env var: SPORESTACK_ENDPOINT, Prints the selected API endpoint: Env var: SPORESTACK_ENDPOINT,
or, SPORESTACK_USE_TOR=1 or, SPORESTACK_USE_TOR_ENDPOINT=1
""" """
from . import api_client from . import api_client

View File

@ -101,3 +101,28 @@ class Token:
return Server( return Server(
machine_id=machine_id, api_client=self.api_client, token=self.token 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)

View File

@ -36,12 +36,12 @@ def test_cli_api_endpoint(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setenv("SPORESTACK_USE_TOR_ENDPOINT", "1") monkeypatch.setenv("SPORESTACK_USE_TOR_ENDPOINT", "1")
result = runner.invoke(cli.cli, ["api-endpoint"]) 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 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"]) 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 assert result.exit_code == 0