v10.0.0: Cleanups/refactor

Added integration-test.sh
This commit is contained in:
Administrator 2023-04-13 00:01:33 +00:00
parent 6dc1c8acd5
commit ba202eca05
10 changed files with 188 additions and 126 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ __pycache__
.pytest_cache .pytest_cache
.coverage .coverage
.tox .tox
dummydotsporestackfolder

View File

@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [10.0.0 - 2023-04-12]
## Changed
- No more `retry` options in `api_client`. Use try/except for `SporeStackServerError`, instead, to retry on 500s.
- Exception messages may be improved.
## [9.1.1 - 2023-04-12] ## [9.1.1 - 2023-04-12]
### Changed ### Changed

59
integration-test.sh Executable file
View File

@ -0,0 +1,59 @@
#!/bin/sh
# These are pretty hacky and need to be cleaned up, but serve a purpose.
# Set REAL_TESTING_TOKEN for more tests.
set -ex
export SPORESTACK_DIR=$(pwd)/dummydotsporestackfolder
rm -r $SPORESTACK_DIR
mkdir $SPORESTACK_DIR
sporestack version
sporestack version | grep '[0-9]\.[0-9]\.[0-9]'
sporestack api-endpoint
sporestack api-endpoint | grep api.sporestack.com
sporestack token list
sporestack token list 2>&1 | wc -l | grep '2$'
sporestack token import importediminvalid --key "imaninvalidkey"
sporestack token list | grep importediminvalid
sporestack token list | grep imaninvalidkey
sporestack server launch --no-quote --token neverbeencreated --operating-system debian-11 --days 1 2>&1 | grep 'does not exist'
# Online tests start here.
sporestack token create --dollars 50 --currency fakecurrency ihaveafakecurrency 2>&1 | grep 'value is not a valid'
sporestack server launch --no-quote --token importediminvalid --operating-system debian-11 --days 1 2>&1 | grep 'ensure this value has at least 32'
sporestack server flavors | grep vcpu
sporestack server operating-systems | grep debian-11
if [ -z "$REAL_TESTING_TOKEN" ]; then
echo "REAL_TESTING_TOKEN not set, not finishing tests."
echo Success
exit 0
else
echo "REAL_TESTING_TOKEN is set, will continue testing."
fi
sporestack token import realtestingtoken --key "$REAL_TESTING_TOKEN"
sporestack token balance realtestingtoken | grep -F '$'
sporestack token messages realtestingtoken
sporestack token servers realtestingtoken
sporestack server launch --no-quote --token realtestingtoken --operating-system debian-11 --days 1 --hostname sporestackpythonintegrationtestdelme
sporestack server topup --token realtestingtoken --hostname sporestackpythonintegrationtestdelme --days 1
sporestack server info --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server json --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server start --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server stop --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server rebuild --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server delete --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server forget --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
echo Success

View File

@ -20,6 +20,8 @@ ignore = [
unfixable = [ unfixable = [
"F401", # Don't try to automatically remove unused imports "F401", # Don't try to automatically remove unused imports
"RUF100", # Unused noqa
"F841", # Unused variable
] ]
target-version = "py37" target-version = "py37"

View File

@ -2,4 +2,4 @@
__all__ = ["api", "api_client", "exceptions"] __all__ = ["api", "api_client", "exceptions"]
__version__ = "9.1.1" __version__ = "10.0.0"

View File

@ -7,7 +7,7 @@ SporeStack API request/response models
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Dict, List, Optional from typing import Dict, List, Optional, Union
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -85,7 +85,7 @@ class ServerTopup:
class Request(BaseModel): class Request(BaseModel):
days: int days: int
token: str token: Union[str, None] = None
class ServerInfo: class ServerInfo:

View File

@ -1,8 +1,7 @@
import logging import logging
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from time import sleep from typing import List, Optional
from typing import Any, Dict, List, Optional
import httpx import httpx
from pydantic import parse_obj_as from pydantic import parse_obj_as
@ -59,6 +58,40 @@ def _is_onion_url(url: str) -> bool:
return False return False
def _get_response_error_text(response: httpx.Response) -> str:
"""Get a response's error text. Assumes the response is actually an error."""
if (
"content-type" in response.headers
and response.headers["content-type"] == "application/json"
):
error = response.json()
if "detail" in error:
if isinstance(error["detail"], str):
return error["detail"]
else:
return str(error["detail"])
return response.text
def _handle_response(response: httpx.Response) -> None:
status_code_first_digit = response.status_code // 100
if status_code_first_digit == 2:
return
error_response_text = _get_response_error_text(response)
if response.status_code == 429:
raise exceptions.SporeStackTooManyRequestsError(error_response_text)
elif status_code_first_digit == 4:
raise exceptions.SporeStackServerError(error_response_text)
elif status_code_first_digit == 5:
# User should probably retry.
raise exceptions.SporeStackServerError(error_response_text)
else:
# How did we get here?
raise exceptions.SporeStackServerError(error_response_text)
@dataclass @dataclass
class APIClient: class APIClient:
api_endpoint: str = API_ENDPOINT api_endpoint: str = API_ENDPOINT
@ -70,67 +103,6 @@ class APIClient:
proxy = _get_tor_proxy() proxy = _get_tor_proxy()
self._httpx_client = httpx.Client(headers=headers, proxies=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,
@ -155,31 +127,30 @@ class APIClient:
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)
self._api_request(url=url, json_params=request.dict()) response = self._httpx_client.post(url=url, json=request.dict())
_handle_response(response)
def server_topup( def server_topup(
self, self,
machine_id: str, machine_id: str,
days: int, days: int,
token: str, token: str | None = None,
) -> None: ) -> 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)
self._api_request(url=url, json_params=request.dict()) response = self._httpx_client.post(url=url, json=request.dict())
_handle_response(response)
def server_quote(self, days: int, flavor: str) -> api.ServerQuote.Response: def server_quote(self, days: int, flavor: str) -> api.ServerQuote.Response:
"""Get a quote for how much a server will cost.""" """Get a quote for how much a server will cost."""
url = self.api_endpoint + api.ServerQuote.url url = self.api_endpoint + api.ServerQuote.url
response = self._httpx_client.get( response = self._httpx_client.get(
url=url, url,
params={"days": days, "flavor": flavor}, params={"days": days, "flavor": flavor},
) )
if response.status_code == 422: _handle_response(response)
raise exceptions.SporeStackUserError(response.json()["detail"])
response.raise_for_status()
return api.ServerQuote.Response.parse_obj(response.json()) return api.ServerQuote.Response.parse_obj(response.json())
def autorenew_enable(self, machine_id: str) -> None: def autorenew_enable(self, machine_id: str) -> None:
@ -189,7 +160,8 @@ 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
) )
self._api_request(url, empty_post=True) response = self._httpx_client.post(url)
_handle_response(response)
def autorenew_disable(self, machine_id: str) -> None: def autorenew_disable(self, machine_id: str) -> None:
""" """
@ -198,35 +170,40 @@ 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
) )
self._api_request(url, empty_post=True) response = self._httpx_client.post(url)
_handle_response(response)
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)
self._api_request(url, empty_post=True) response = self._httpx_client.post(url)
_handle_response(response)
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)
self._api_request(url, empty_post=True) response = self._httpx_client.post(url)
_handle_response(response)
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)
self._api_request(url, empty_post=True) response = self._httpx_client.post(url)
_handle_response(response)
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)
self._api_request(url, empty_post=True) response = self._httpx_client.post(url)
_handle_response(response)
def server_rebuild(self, machine_id: str) -> None: def server_rebuild(self, machine_id: str) -> None:
""" """
@ -235,15 +212,17 @@ 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)
self._api_request(url, empty_post=True) response = self._httpx_client.post(url)
_handle_response(response)
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 = self._api_request(url) response = self._httpx_client.get(url)
response_object = api.ServerInfo.Response.parse_obj(response) _handle_response(response)
response_object = api.ServerInfo.Response.parse_obj(response.json())
return response_object return response_object
def servers_launched_from_token( def servers_launched_from_token(
@ -253,22 +232,27 @@ 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 = self._api_request(url) response = self._httpx_client.get(url)
response_object = api.ServersLaunchedFromToken.Response.parse_obj(response) _handle_response(response)
response_object = api.ServersLaunchedFromToken.Response.parse_obj(
response.json()
)
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 (server sizes)."""
url = self.api_endpoint + api.Flavors.url url = self.api_endpoint + api.Flavors.url
response = self._api_request(url) response = self._httpx_client.get(url)
response_object = api.Flavors.Response.parse_obj(response) _handle_response(response)
response_object = api.Flavors.Response.parse_obj(response.json())
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 = self._api_request(url) response = self._httpx_client.get(url)
response_object = api.OperatingSystems.Response.parse_obj(response) _handle_response(response)
response_object = api.OperatingSystems.Response.parse_obj(response.json())
return response_object return response_object
def token_add( def token_add(
@ -276,30 +260,28 @@ class APIClient:
token: str, token: str,
dollars: int, dollars: int,
currency: str, currency: str,
retry: bool = False,
) -> api.TokenAdd.Response: ) -> api.TokenAdd.Response:
"""Add balance (money) to a token.""" """Add balance (money) to a token."""
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 = self._api_request(url=url, json_params=request.dict(), retry=retry) request = api.TokenAdd.Request(dollars=dollars, currency=currency)
response_object = api.TokenAdd.Response.parse_obj(response) response = self._httpx_client.post(url, json=request.dict())
_handle_response(response)
response_object = api.TokenAdd.Response.parse_obj(response.json())
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.""" """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 = self._api_request(url=url) response = self._httpx_client.get(url)
response_object = api.TokenBalance.Response.parse_obj(response) _handle_response(response)
response_object = api.TokenBalance.Response.parse_obj(response.json())
return response_object return response_object
def token_get_messages(self, token: str) -> List[api.TokenMessage]: def token_get_messages(self, token: str) -> List[api.TokenMessage]:
"""Get messages for/from the token.""" """Get messages for/from the token."""
url = self.api_endpoint + f"/token/{token}/messages" url = self.api_endpoint + f"/token/{token}/messages"
log.debug(f"Token send message URL: {url}")
response = self._httpx_client.get(url=url) response = self._httpx_client.get(url=url)
if response.status_code == 422: _handle_response(response)
raise exceptions.SporeStackUserError(response.json()["detail"])
response.raise_for_status()
return parse_obj_as(List[api.TokenMessage], response.json()) return parse_obj_as(List[api.TokenMessage], response.json())
@ -307,7 +289,4 @@ class APIClient:
"""Send a message to SporeStack support.""" """Send a message to SporeStack support."""
url = self.api_endpoint + f"/token/{token}/messages" url = self.api_endpoint + f"/token/{token}/messages"
response = self._httpx_client.post(url=url, json={"message": message}) response = self._httpx_client.post(url=url, json={"message": message})
if response.status_code == 422: _handle_response(response)
raise exceptions.SporeStackUserError(response.json()["detail"])
response.raise_for_status()

View File

@ -30,7 +30,7 @@ _home = os.getenv("HOME", None)
assert _home is not None, "Unable to detect $HOME environment variable?" assert _home is not None, "Unable to detect $HOME environment variable?"
HOME = Path(_home) HOME = Path(_home)
SPORESTACK_DIR = HOME / ".sporestack" SPORESTACK_DIR = Path(os.getenv("SPORESTACK_DIR", HOME / ".sporestack"))
# Try to protect files in ~/.sporestack # Try to protect files in ~/.sporestack
os.umask(0o0077) os.umask(0o0077)
@ -211,7 +211,11 @@ def server_info_path() -> Path:
servers_dir = SPORESTACK_DIR / "servers" servers_dir = SPORESTACK_DIR / "servers"
# Migrate existing server.json files into servers subdirectory # Migrate existing server.json files into servers subdirectory
if SPORESTACK_DIR.exists() and not servers_dir.exists(): if (
SPORESTACK_DIR.exists()
and not servers_dir.exists()
and len(list(SPORESTACK_DIR.glob("*.json"))) > 0
):
typer.echo( typer.echo(
f"Migrating server profiles found in {SPORESTACK_DIR} to {servers_dir}.", f"Migrating server profiles found in {SPORESTACK_DIR} to {servers_dir}.",
err=True, err=True,
@ -627,6 +631,7 @@ def token_create(
raise typer.Exit(1) raise typer.Exit(1)
from .api_client import APIClient from .api_client import APIClient
from .exceptions import SporeStackServerError
api_client = APIClient(api_endpoint=get_api_endpoint()) api_client = APIClient(api_endpoint=get_api_endpoint())
@ -634,7 +639,6 @@ def token_create(
token=_token, token=_token,
dollars=dollars, dollars=dollars,
currency=currency, currency=currency,
retry=True,
) )
uri = response.payment.uri uri = response.payment.uri
@ -650,12 +654,15 @@ def token_create(
# FIXME: Wait two hours in a smarter way. # FIXME: Wait two hours in a smarter way.
# Waiting for payment to set in. # Waiting for payment to set in.
time.sleep(10) time.sleep(10)
response = api_client.token_add( try:
token=_token, response = api_client.token_add(
dollars=dollars, token=_token,
currency=currency, dollars=dollars,
retry=True, currency=currency,
) )
except SporeStackServerError:
typer.echo("Received 500 HTTP status, will try again.", err=True)
continue
if response.payment.paid is True: if response.payment.paid is True:
typer.echo(f"{token} has been enabled with ${dollars}.") typer.echo(f"{token} has been enabled with ${dollars}.")
typer.echo(f"{token}'s key is {_token}.") typer.echo(f"{token}'s key is {_token}.")
@ -688,6 +695,7 @@ def token_topup(
token = load_token(token) token = load_token(token)
from .api_client import APIClient from .api_client import APIClient
from .exceptions import SporeStackServerError
api_client = APIClient(api_endpoint=get_api_endpoint()) api_client = APIClient(api_endpoint=get_api_endpoint())
@ -695,7 +703,6 @@ def token_topup(
token, token,
dollars, dollars,
currency=currency, currency=currency,
retry=True,
) )
uri = response.payment.uri uri = response.payment.uri
@ -709,12 +716,15 @@ def token_topup(
typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True) typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True)
tries = tries - 1 tries = tries - 1
# FIXME: Wait two hours in a smarter way. # FIXME: Wait two hours in a smarter way.
response = api_client.token_add( try:
token, response = api_client.token_add(
dollars, token=token,
currency=currency, dollars=dollars,
retry=True, currency=currency,
) )
except SporeStackServerError:
typer.echo("Received 500 HTTP status, will try again.", err=True)
continue
# Waiting for payment to set in. # Waiting for payment to set in.
time.sleep(10) time.sleep(10)
if response.payment.paid is True: if response.payment.paid is True:
@ -789,9 +799,7 @@ def messages(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
def send_message( def send_message(
token: str = typer.Argument(DEFAULT_TOKEN), message: str = typer.Option(...) token: str = typer.Argument(DEFAULT_TOKEN), message: str = typer.Option(...)
) -> None: ) -> None:
""" """Send a support message."""
Send a support message.
"""
token = load_token(token) token = load_token(token)
from .api_client import APIClient from .api_client import APIClient
@ -805,9 +813,7 @@ def send_message(
@cli.command() @cli.command()
def version() -> None: def version() -> None:
""" """Returns the installed version."""
Returns the installed version.
"""
from . import __version__ from . import __version__
typer.echo(__version__) typer.echo(__version__)

View File

@ -8,6 +8,12 @@ class SporeStackUserError(SporeStackError):
pass pass
class SporeStackTooManyRequestsError(SporeStackError):
"""HTTP 429, retry again later"""
pass
class SporeStackServerError(SporeStackError): class SporeStackServerError(SporeStackError):
"""HTTP 5XX""" """HTTP 5XX"""

View File

@ -12,4 +12,6 @@ deps =
pytest-socket~=0.6.0 pytest-socket~=0.6.0
pytest-cov~=4.0 pytest-cov~=4.0
pytest-mock~=3.6 pytest-mock~=3.6
commands = pytest --cov=sporestack --cov-fail-under=39 --cov-report=term --durations=3 commands =
pytest --cov=sporestack --cov-fail-under=39 --cov-report=term --durations=3
sporestack api-endpoint