From 16c790728d1d8e3ee8466a5aae0226a6130e2d32 Mon Sep 17 00:00:00 2001 From: SporeStack Date: Fri, 12 May 2023 20:05:38 +0000 Subject: [PATCH] v10.5.0: Added `sporestack token invoices` command --- CHANGELOG.md | 10 ++ README.md | 18 ++-- integration-test.sh | 2 +- pyproject.toml | 2 +- src/sporestack/__init__.py | 2 +- src/sporestack/api.py | 38 ++++--- src/sporestack/api_client.py | 10 +- src/sporestack/cli.py | 195 +++++++++++++++++++++++++++-------- src/sporestack/client.py | 6 +- src/sporestack/models.py | 76 ++++++++++++-- 10 files changed, 283 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5a9ad2..29f709a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Nothing yet. +## [10.5.0 - 2023-05-12] + +## Changed + +- Use fancy table output for `sporestack server list`. + +## Added + +- `sporestack token invoices` command. + ## [10.4.0 - 2023-05-12] ## Changed diff --git a/README.md b/README.md index dd7d3d2..c978868 100644 --- a/README.md +++ b/README.md @@ -8,27 +8,31 @@ ## Installation -* `pip install sporestack` +* `pip install sporestack` (Run `pip install 'sporestack[cli]'` if you wish to use the CLI features and not just the Python library.) * Recommended: Create a virtual environment, first, and use it inside there. * Something else to consider: Installing [rich](https://github.com/Textualize/rich) (`pip install rich`) in the same virtual environment will make `--help`-style output prettier. ## Running without installing * Make sure `pipx` is installed. -* `pipx run sporestack` +* `pipx run 'sporestack[cli]'` * Make sure you're on the latest stable version comparing `sporestack version` with git tags in this repository, or releases on [PyPI](https://pypi.org/project/sporestack/). ## Usage * `sporestack token create --dollars 20 --currency xmr # Can use btc as well.` * `sporestack token list` -* `sporestack token balance` -* `sporestack server launch SomeHostname --operating-system debian-11 --days 1 # Will use ~/.ssh/id_rsa.pub as your SSH key, by default` +* `sporestack token info` +* `sporestack server launch --hostname SomeHostname --operating-system debian-11 --days 1 # Will use ~/.ssh/id_rsa.pub as your SSH key, by default` (You may also want to consider passing `--region` to have a non-random region. This will use the "primary" token by default, which is the default when you run `sporestack token create`.) -* `sporestack server stop SomeHostname` -* `sporestack server start SomeHostname` +* `sporestack server stop --hostname SomeHostname` +* `sporestack server stop --machine-id ss_m_... # Or use --machine-id to be more pedantic.` +* `sporestack server start --hostname SomeHostname` +* `sporestack server autorenew-enable --hostname SomeHostname` +* `sporestack server autorenew-disable --hostname SomeHostname` * `sporestack server list` -* `sporestack server remove SomeHostname # If expired` +* `sporestack server delete --hostname SomeHostname` +* `sporestack server remove --hostname SomeHostname # If expired` ## Notes diff --git a/integration-test.sh b/integration-test.sh index 903ec2f..1189019 100755 --- a/integration-test.sh +++ b/integration-test.sh @@ -21,7 +21,6 @@ sporestack api-endpoint sporestack api-endpoint | grep "$SPORESTACK_ENDPOINT" sporestack token list -sporestack token list 2>&1 | wc -l | grep '2$' sporestack token import importediminvalid --key "imaninvalidkey" sporestack token list | grep importediminvalid @@ -52,6 +51,7 @@ sporestack token balance realtestingtoken | grep -F '$' sporestack token info realtestingtoken sporestack token messages realtestingtoken sporestack token servers realtestingtoken +sporestack token invoices realtestingtoken sporestack server list --token realtestingtoken sporestack server launch --no-quote --token realtestingtoken --operating-system debian-11 --days 1 --hostname sporestackpythonintegrationtestdelme diff --git a/pyproject.toml b/pyproject.toml index 1e35c27..63c1353 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ "pydantic", "httpx[socks]", "segno", - "typer", + "typer>=0.9.0", "rich", ] diff --git a/src/sporestack/__init__.py b/src/sporestack/__init__.py index 6ddee49..dc029df 100644 --- a/src/sporestack/__init__.py +++ b/src/sporestack/__init__.py @@ -2,4 +2,4 @@ __all__ = ["api", "api_client", "exceptions"] -__version__ = "10.4.0" +__version__ = "10.5.0" diff --git a/src/sporestack/api.py b/src/sporestack/api.py index 1c5f646..fd4e9d6 100644 --- a/src/sporestack/api.py +++ b/src/sporestack/api.py @@ -1,13 +1,19 @@ """SporeStack API request/response models""" +import sys from datetime import datetime from enum import Enum from typing import Dict, List, Optional, Union from pydantic import BaseModel, Field -from .models import Flavor, OperatingSystem, Payment, Region +from .models import Flavor, Invoice, OperatingSystem, Payment, Region + +if sys.version_info >= (3, 9): # pragma: nocover + from typing import Annotated +else: # pragma: nocover + from typing_extensions import Annotated class TokenAdd: @@ -16,12 +22,14 @@ class TokenAdd: class Request(BaseModel): currency: str + """BREAKING: This will change to models.Currency in version 11.""" dollars: int affiliate_token: Union[str, None] = None class Response(BaseModel): - token: str - payment: Payment + token: Annotated[str, Field(deprecated=True)] + payment: Annotated[Payment, Field(deprecated=True)] + invoice: Invoice class TokenBalance: @@ -29,7 +37,7 @@ class TokenBalance: method = "GET" class Response(BaseModel): - token: str + token: Annotated[str, Field(deprecated=True)] cents: int usd: str @@ -41,16 +49,18 @@ class ServerQuote: """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", - ) + cents: Annotated[ + int, Field(ge=1, title="Cents", description="(US) cents", example=1_000_00) + ] + usd: Annotated[ + str, + Field( + min_length=5, + title="USD", + description="USD in $1,000.00 format", + example="$1,000.00", + ), + ] class ServerLaunch: diff --git a/src/sporestack/api_client.py b/src/sporestack/api_client.py index 7b03b25..bd15e50 100644 --- a/src/sporestack/api_client.py +++ b/src/sporestack/api_client.py @@ -7,7 +7,7 @@ import httpx from pydantic import parse_obj_as from . import __version__, api, exceptions -from .models import TokenInfo +from .models import Invoice, TokenInfo log = logging.getLogger(__name__) @@ -300,3 +300,11 @@ class APIClient: url = self.api_endpoint + f"/token/{token}/messages" response = self._httpx_client.post(url=url, json={"message": message}) _handle_response(response) + + def token_invoices(self, token: str) -> List[Invoice]: + """Get token invoices.""" + url = self.api_endpoint + f"/token/{token}/invoices" + response = self._httpx_client.get(url=url) + _handle_response(response) + + return parse_obj_as(List[Invoice], response.json()) diff --git a/src/sporestack/cli.py b/src/sporestack/cli.py index 8346f1c..c1a2f0a 100644 --- a/src/sporestack/cli.py +++ b/src/sporestack/cli.py @@ -5,14 +5,21 @@ SporeStack CLI: `sporestack` import json import logging import os +import sys import time from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, Optional import typer +if sys.version_info >= (3, 9): # pragma: nocover + from typing import Annotated +else: # pragma: nocover + from typing_extensions import Annotated + if TYPE_CHECKING: from . import api + from .models import Invoice HELP = """ @@ -71,15 +78,21 @@ def get_api_endpoint() -> str: return api_endpoint -def make_payment(currency: str, uri: str, usd: str) -> None: +def make_payment(invoice: "Invoice") -> None: import segno - premessage = """Payment URI: {} -Pay *exactly* the specified amount. No more, no less. Pay within -one hour at the very most. + from ._cli_utils import cents_to_usd + + uri = invoice.payment_uri + usd = cents_to_usd(invoice.amount) + expires = epoch_to_human(invoice.expires) + + message = f"""Invoice: {invoice.id} +Invoice expires: {expires} (payment must be confirmed by this time) +Payment URI: {uri} +Pay *exactly* the specified amount. No more, no less. Resize your terminal and try again if QR code above is not readable. Press ctrl+c to abort.""" - message = premessage.format(uri) qr = segno.make(uri) # This typer.echos. qr.terminal() @@ -90,11 +103,12 @@ Press ctrl+c to abort.""" @server_cli.command() def launch( + days: Annotated[ + int, + typer.Option(min=1, max=90, help="Number of days the server should run for."), + ], + operating_system: Annotated[str, typer.Option(help="Example: debian-11")], hostname: str = "", - 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, @@ -301,7 +315,7 @@ def print_machine_info(info: "api.ServerInfo.Response") -> None: typer.echo(f"Region: {info.region}") typer.echo(f"Flavor: {info.flavor.slug}") typer.echo(f"Expiration: {epoch_to_human(info.expiration)}") - typer.echo(f"Token: {info.token}") + typer.echo(f"Token (keep this secret!): {info.token}") if info.deleted_at != 0 or info.deleted: typer.echo("Server was deleted!") if info.deleted_at != 0: @@ -321,20 +335,35 @@ def print_machine_info(info: "api.ServerInfo.Response") -> None: @server_cli.command(name="list") def server_list( token: str = DEFAULT_TOKEN, - local: bool = typer.Option( - True, help="List older servers not associated to token." - ), - show_forgotten: bool = typer.Option( - False, help="Show deleted and forgotten servers." - ), + local: Annotated[ + bool, typer.Option(help="List older servers not associated to token.") + ] = True, + show_forgotten: Annotated[ + bool, typer.Option(help="Show deleted and forgotten servers.") + ] = False, ) -> None: - """List all locally known servers and all servers under the given token.""" + """Lists a token's servers.""" + _token = load_token(token) + + from rich.console import Console + from rich.table import Table + from .api_client import APIClient from .exceptions import SporeStackUserError - api_client = APIClient(api_endpoint=get_api_endpoint()) + console = Console(width=None if sys.stdout.isatty() else 10**9) - _token = load_token(token) + table = Table( + title=f"Servers for {token} ({_token})", + show_header=True, + header_style="bold magenta", + caption=( + "For more details on a server, run " + "`sporestack server info --machine-id (machine id)`" + ), + ) + + api_client = APIClient(api_endpoint=get_api_endpoint()) server_infos = api_client.servers_launched_from_token(token=_token).servers machine_id_hostnames = {} @@ -348,6 +377,13 @@ def server_list( printed_machine_ids = [] + table.add_column("Machine ID [bold](Secret!)[/bold]", style="dim") + table.add_column("Hostname") + table.add_column("IPv4") + table.add_column("IPv6") + table.add_column("Expires At") + table.add_column("Autorenew") + for info in server_infos: if not show_forgotten and info.forgotten_at is not None: continue @@ -360,10 +396,23 @@ def server_list( hostname = machine_id_hostnames[info.machine_id] info.hostname = hostname - print_machine_info(info) + expiration = epoch_to_human(info.expiration) + if info.deleted_at: + expiration = f"[bold]Deleted[/bold] at {epoch_to_human(info.deleted_at)}" + + table.add_row( + info.machine_id, + info.hostname, + info.ipv4, + info.ipv6, + expiration, + str(info.autorenew), + ) printed_machine_ids.append(info.machine_id) + console.print(table) + if local: for hostname_json in os.listdir(directory): hostname = hostname_json.split(".")[0] @@ -383,7 +432,7 @@ def server_list( except SporeStackUserError as e: expiration = saved_vm_info["expiration"] human_expiration = time.strftime( - "%Y-%m-%d %H:%M:%S %z", time.localtime(expiration) + "%Y-%m-%d %H:%M:%S %z", time.localtime(saved_vm_info["expiration"]) ) msg = hostname msg += f" expired ({expiration} {human_expiration}): " @@ -562,7 +611,7 @@ def flavors() -> None: from ._cli_utils import cents_to_usd, gb_string, mb_string, tb_string from .api_client import APIClient - console = Console() + console = Console(width=None if sys.stdout.isatty() else 10**9) table = Table(show_header=True, header_style="bold magenta") table.add_column("Flavor Slug (--flavor)") @@ -664,15 +713,17 @@ def save_token(token: str, key: str) -> None: @token_cli.command(name="create") def token_create( - token: str = typer.Argument(DEFAULT_TOKEN), - dollars: int = typer.Option(...), - currency: str = typer.Option(...), + dollars: Annotated[int, typer.Option()], + currency: Annotated[str, typer.Option()], + token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN, ) -> None: """ Enables a new token. Dollars is starting balance. """ + from httpx import HTTPError + from . import utils _token = utils.random_token() @@ -694,11 +745,7 @@ def token_create( currency=currency, ) - uri = response.payment.uri - assert uri is not None - usd = response.payment.usd - - make_payment(currency=currency, uri=uri, usd=usd) + make_payment(response.invoice) tries = 360 * 2 while tries > 0: @@ -713,10 +760,10 @@ def token_create( dollars=dollars, currency=currency, ) - except SporeStackServerError: + except (SporeStackServerError, HTTPError): typer.echo("Received 500 HTTP status, will try again.", err=True) continue - if response.payment.paid is True: + if response.invoice.paid: typer.echo(f"{token} has been enabled with ${dollars}.") typer.echo(f"{token}'s key is {_token}.") typer.echo("Save it, don't share it, and don't lose it!") @@ -743,6 +790,8 @@ def token_topup( """Adds balance to an existing token.""" token = load_token(token) + from httpx import HTTPError + from .api_client import APIClient from .exceptions import SporeStackServerError @@ -754,11 +803,7 @@ def token_topup( currency=currency, ) - uri = response.payment.uri - assert uri is not None - usd = response.payment.usd - - make_payment(currency=currency, uri=uri, usd=usd) + make_payment(response.invoice) tries = 360 * 2 while tries > 0: @@ -771,12 +816,12 @@ def token_topup( dollars=dollars, currency=currency, ) - except SporeStackServerError: + except (SporeStackServerError, HTTPError): typer.echo("Received 500 HTTP status, will try again.", err=True) continue # Waiting for payment to set in. time.sleep(10) - if response.payment.paid is True: + if response.invoice.paid: typer.echo(f"Added {dollars} dollars to {token}") return raise ValueError(f"{token} did not get enabled in time.") @@ -795,7 +840,7 @@ def balance(token: str = typer.Argument(DEFAULT_TOKEN)) -> None: @token_cli.command(name="info") -def token_info(token: str = typer.Argument(DEFAULT_TOKEN)) -> None: +def token_info(token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN) -> None: """ Show information about a token, including balance. @@ -828,7 +873,7 @@ def token_info(token: str = typer.Argument(DEFAULT_TOKEN)) -> None: @token_cli.command() -def servers(token: str = typer.Argument(DEFAULT_TOKEN)) -> None: +def servers(token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN) -> None: """Returns server info for servers launched by a given token.""" _token = load_token(token) @@ -842,13 +887,75 @@ def servers(token: str = typer.Argument(DEFAULT_TOKEN)) -> None: @token_cli.command(name="list") def token_list() -> None: """List tokens.""" + from rich.console import Console + from rich.table import Table + + console = Console(width=None if sys.stdout.isatty() else 10**9) + token_dir = token_path() - typer.echo(f"SporeStack tokens present in {token_dir}:", err=True) - typer.echo("(Name): (Key)", err=True) + table = Table( + show_header=True, + header_style="bold magenta", + caption=f"These tokens are stored in {token_dir}", + ) + table.add_column("Name") + table.add_column("Token (this is a globally unique [bold]secret[/bold])") + for token_file in token_dir.glob("*.json"): token = token_file.stem key = load_token(token) - typer.echo(f"{token}: {key}") + table.add_row(token, key) + + console.print(table) + + +@token_cli.command(name="invoices") +def token_invoices(token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN) -> None: + """List invoices.""" + _token = load_token(token) + + from rich.console import Console + from rich.table import Table + + from ._cli_utils import cents_to_usd + 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) + + console = Console(width=None if sys.stdout.isatty() else 10**9) + + table = Table( + title=f"Invoices for {token} ({_token})", + show_header=True, + header_style="bold magenta", + ) + table.add_column("ID") + table.add_column("Amount") + table.add_column("Created At") + table.add_column("Paid At") + table.add_column("URI") + table.add_column("TXID") + + for invoice in client.token.invoices(): + if invoice.paid: + paid = epoch_to_human(invoice.paid) + else: + if invoice.expired: + paid = "[bold]Expired[/bold]" + else: + paid = f"Unpaid. Expires: {epoch_to_human(invoice.expires)}" + table.add_row( + str(invoice.id), + cents_to_usd(invoice.amount), + epoch_to_human(invoice.created), + paid, + invoice.payment_uri, + invoice.txid, + ) + + console.print(table) @token_cli.command() diff --git a/src/sporestack/client.py b/src/sporestack/client.py index 28a795d..dc11049 100644 --- a/src/sporestack/client.py +++ b/src/sporestack/client.py @@ -3,7 +3,7 @@ from typing import List, Union from . import api from .api_client import APIClient -from .models import TokenInfo +from .models import Invoice, TokenInfo from .utils import random_machine_id, random_token @@ -74,6 +74,10 @@ class Token: """Returns information about a token.""" return self.api_client.token_info(token=self.token) + def invoices(self) -> List[Invoice]: + """Returns invoices for adding balance to the token.""" + return self.api_client.token_invoices(token=self.token) + def messages(self) -> List[api.TokenMessage]: """Returns support messages for/from the token.""" return self.api_client.token_get_messages(token=self.token) diff --git a/src/sporestack/models.py b/src/sporestack/models.py index 757c312..6637eac 100644 --- a/src/sporestack/models.py +++ b/src/sporestack/models.py @@ -1,16 +1,29 @@ -""" +"""SporeStack API supplemental models""" -SporeStack API supplemental models +import sys +from enum import Enum +from typing import Optional, Union -""" +if sys.version_info >= (3, 9): # pragma: nocover + from typing import Annotated +else: # pragma: nocover + from typing_extensions import Annotated + +from pydantic import BaseModel, Field -from typing import Optional - -from pydantic import BaseModel +class Currency(str, Enum): + xmr = "xmr" + """Monero""" + btc = "btc" + """Bitcoin""" + bch = "bch" + """Bitcoin Cash""" class Payment(BaseModel): + """This is deprecated in favor of Invoice.""" + txid: Optional[str] uri: Optional[str] usd: str @@ -62,3 +75,54 @@ class Region(BaseModel): slug: str # Actually human readable string describing the region. name: str + + +class Invoice(BaseModel): + id: int + payment_uri: Annotated[ + str, Field(description="Cryptocurrency URI for the payment.") + ] + cryptocurrency: Annotated[ + Currency, + Field(description="Cryptocurrency that will be used to pay this invoice."), + ] + amount: Annotated[ + int, + Field( + description="Amount of cents to add to the token if this invoice is paid." + ), + ] + fiat_per_coin: Annotated[ + str, + Field( + description="Stringified float of the price when this was made.", + example="100.00", + ), + ] + created: Annotated[ + int, Field(description="Timestamp of when this invoice was created.") + ] + expires: Annotated[ + int, Field(description="Timestamp of when this invoice will expire.") + ] + paid: Annotated[ + int, Field(description="Timestamp of when this invoice was paid. 0 if unpaid.") + ] + txid: Annotated[ + Union[str, None], + Field( + description="TXID of the transaction for this payment, if it was paid.", + min_length=64, + max_length=64, + regex="^[a-f0-9]+$", + ), + ] + expired: Annotated[ + bool, + Field( + description=( + "Whether or not the invoice has expired (only applicable if " + "unpaid, or payment not yet confirmed." + ), + ), + ]