v10.5.0: Added `sporestack token invoices` command
This commit is contained in:
parent
5ff095af3f
commit
16c790728d
10
CHANGELOG.md
10
CHANGELOG.md
|
@ -15,6 +15,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
- Nothing yet.
|
- 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]
|
## [10.4.0 - 2023-05-12]
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
18
README.md
18
README.md
|
@ -8,27 +8,31 @@
|
||||||
|
|
||||||
## Installation
|
## 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.
|
* 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.
|
* 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
|
## Running without installing
|
||||||
|
|
||||||
* Make sure `pipx` is installed.
|
* 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/).
|
* 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
|
## Usage
|
||||||
|
|
||||||
* `sporestack token create --dollars 20 --currency xmr # Can use btc as well.`
|
* `sporestack token create --dollars 20 --currency xmr # Can use btc as well.`
|
||||||
* `sporestack token list`
|
* `sporestack token list`
|
||||||
* `sporestack token balance`
|
* `sporestack token info`
|
||||||
* `sporestack server launch SomeHostname --operating-system debian-11 --days 1 # Will use ~/.ssh/id_rsa.pub as your SSH key, by default`
|
* `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`.)
|
(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 stop --hostname SomeHostname`
|
||||||
* `sporestack server start 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 list`
|
||||||
* `sporestack server remove SomeHostname # If expired`
|
* `sporestack server delete --hostname SomeHostname`
|
||||||
|
* `sporestack server remove --hostname SomeHostname # If expired`
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,6 @@ sporestack api-endpoint
|
||||||
sporestack api-endpoint | grep "$SPORESTACK_ENDPOINT"
|
sporestack api-endpoint | grep "$SPORESTACK_ENDPOINT"
|
||||||
|
|
||||||
sporestack token list
|
sporestack token list
|
||||||
sporestack token list 2>&1 | wc -l | grep '2$'
|
|
||||||
|
|
||||||
sporestack token import importediminvalid --key "imaninvalidkey"
|
sporestack token import importediminvalid --key "imaninvalidkey"
|
||||||
sporestack token list | grep importediminvalid
|
sporestack token list | grep importediminvalid
|
||||||
|
@ -52,6 +51,7 @@ sporestack token balance realtestingtoken | grep -F '$'
|
||||||
sporestack token info realtestingtoken
|
sporestack token info realtestingtoken
|
||||||
sporestack token messages realtestingtoken
|
sporestack token messages realtestingtoken
|
||||||
sporestack token servers realtestingtoken
|
sporestack token servers realtestingtoken
|
||||||
|
sporestack token invoices realtestingtoken
|
||||||
|
|
||||||
sporestack server list --token realtestingtoken
|
sporestack server list --token realtestingtoken
|
||||||
sporestack server launch --no-quote --token realtestingtoken --operating-system debian-11 --days 1 --hostname sporestackpythonintegrationtestdelme
|
sporestack server launch --no-quote --token realtestingtoken --operating-system debian-11 --days 1 --hostname sporestackpythonintegrationtestdelme
|
||||||
|
|
|
@ -57,7 +57,7 @@ dependencies = [
|
||||||
"pydantic",
|
"pydantic",
|
||||||
"httpx[socks]",
|
"httpx[socks]",
|
||||||
"segno",
|
"segno",
|
||||||
"typer",
|
"typer>=0.9.0",
|
||||||
"rich",
|
"rich",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -2,4 +2,4 @@
|
||||||
|
|
||||||
__all__ = ["api", "api_client", "exceptions"]
|
__all__ = ["api", "api_client", "exceptions"]
|
||||||
|
|
||||||
__version__ = "10.4.0"
|
__version__ = "10.5.0"
|
||||||
|
|
|
@ -1,13 +1,19 @@
|
||||||
"""SporeStack API request/response models"""
|
"""SporeStack API request/response models"""
|
||||||
|
|
||||||
|
|
||||||
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, List, Optional, Union
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
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:
|
class TokenAdd:
|
||||||
|
@ -16,12 +22,14 @@ class TokenAdd:
|
||||||
|
|
||||||
class Request(BaseModel):
|
class Request(BaseModel):
|
||||||
currency: str
|
currency: str
|
||||||
|
"""BREAKING: This will change to models.Currency in version 11."""
|
||||||
dollars: int
|
dollars: int
|
||||||
affiliate_token: Union[str, None] = None
|
affiliate_token: Union[str, None] = None
|
||||||
|
|
||||||
class Response(BaseModel):
|
class Response(BaseModel):
|
||||||
token: str
|
token: Annotated[str, Field(deprecated=True)]
|
||||||
payment: Payment
|
payment: Annotated[Payment, Field(deprecated=True)]
|
||||||
|
invoice: Invoice
|
||||||
|
|
||||||
|
|
||||||
class TokenBalance:
|
class TokenBalance:
|
||||||
|
@ -29,7 +37,7 @@ class TokenBalance:
|
||||||
method = "GET"
|
method = "GET"
|
||||||
|
|
||||||
class Response(BaseModel):
|
class Response(BaseModel):
|
||||||
token: str
|
token: Annotated[str, Field(deprecated=True)]
|
||||||
cents: int
|
cents: int
|
||||||
usd: str
|
usd: str
|
||||||
|
|
||||||
|
@ -41,16 +49,18 @@ class ServerQuote:
|
||||||
"""Takes days and flavor as parameters."""
|
"""Takes days and flavor as parameters."""
|
||||||
|
|
||||||
class Response(BaseModel):
|
class Response(BaseModel):
|
||||||
cents: int = Field(
|
cents: Annotated[
|
||||||
default=..., ge=1, title="Cents", description="(US) cents", example=1_000_00
|
int, Field(ge=1, title="Cents", description="(US) cents", example=1_000_00)
|
||||||
)
|
]
|
||||||
usd: str = Field(
|
usd: Annotated[
|
||||||
default=...,
|
str,
|
||||||
min_length=5,
|
Field(
|
||||||
title="USD",
|
min_length=5,
|
||||||
description="USD in $1,000.00 format",
|
title="USD",
|
||||||
example="$1,000.00",
|
description="USD in $1,000.00 format",
|
||||||
)
|
example="$1,000.00",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ServerLaunch:
|
class ServerLaunch:
|
||||||
|
|
|
@ -7,7 +7,7 @@ import httpx
|
||||||
from pydantic import parse_obj_as
|
from pydantic import parse_obj_as
|
||||||
|
|
||||||
from . import __version__, api, exceptions
|
from . import __version__, api, exceptions
|
||||||
from .models import TokenInfo
|
from .models import Invoice, TokenInfo
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -300,3 +300,11 @@ class APIClient:
|
||||||
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})
|
||||||
_handle_response(response)
|
_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())
|
||||||
|
|
|
@ -5,14 +5,21 @@ SporeStack CLI: `sporestack`
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||||
|
|
||||||
import typer
|
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:
|
if TYPE_CHECKING:
|
||||||
from . import api
|
from . import api
|
||||||
|
from .models import Invoice
|
||||||
|
|
||||||
|
|
||||||
HELP = """
|
HELP = """
|
||||||
|
@ -71,15 +78,21 @@ def get_api_endpoint() -> str:
|
||||||
return api_endpoint
|
return api_endpoint
|
||||||
|
|
||||||
|
|
||||||
def make_payment(currency: str, uri: str, usd: str) -> None:
|
def make_payment(invoice: "Invoice") -> None:
|
||||||
import segno
|
import segno
|
||||||
|
|
||||||
premessage = """Payment URI: {}
|
from ._cli_utils import cents_to_usd
|
||||||
Pay *exactly* the specified amount. No more, no less. Pay within
|
|
||||||
one hour at the very most.
|
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.
|
Resize your terminal and try again if QR code above is not readable.
|
||||||
Press ctrl+c to abort."""
|
Press ctrl+c to abort."""
|
||||||
message = premessage.format(uri)
|
|
||||||
qr = segno.make(uri)
|
qr = segno.make(uri)
|
||||||
# This typer.echos.
|
# This typer.echos.
|
||||||
qr.terminal()
|
qr.terminal()
|
||||||
|
@ -90,11 +103,12 @@ Press ctrl+c to abort."""
|
||||||
|
|
||||||
@server_cli.command()
|
@server_cli.command()
|
||||||
def launch(
|
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 = "",
|
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,
|
ssh_key_file: Path = DEFAULT_SSH_KEY_FILE,
|
||||||
flavor: str = DEFAULT_FLAVOR,
|
flavor: str = DEFAULT_FLAVOR,
|
||||||
token: str = DEFAULT_TOKEN,
|
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"Region: {info.region}")
|
||||||
typer.echo(f"Flavor: {info.flavor.slug}")
|
typer.echo(f"Flavor: {info.flavor.slug}")
|
||||||
typer.echo(f"Expiration: {epoch_to_human(info.expiration)}")
|
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:
|
if info.deleted_at != 0 or info.deleted:
|
||||||
typer.echo("Server was deleted!")
|
typer.echo("Server was deleted!")
|
||||||
if info.deleted_at != 0:
|
if info.deleted_at != 0:
|
||||||
|
@ -321,20 +335,35 @@ def print_machine_info(info: "api.ServerInfo.Response") -> None:
|
||||||
@server_cli.command(name="list")
|
@server_cli.command(name="list")
|
||||||
def server_list(
|
def server_list(
|
||||||
token: str = DEFAULT_TOKEN,
|
token: str = DEFAULT_TOKEN,
|
||||||
local: bool = typer.Option(
|
local: Annotated[
|
||||||
True, help="List older servers not associated to token."
|
bool, typer.Option(help="List older servers not associated to token.")
|
||||||
),
|
] = True,
|
||||||
show_forgotten: bool = typer.Option(
|
show_forgotten: Annotated[
|
||||||
False, help="Show deleted and forgotten servers."
|
bool, typer.Option(help="Show deleted and forgotten servers.")
|
||||||
),
|
] = False,
|
||||||
) -> None:
|
) -> 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 .api_client import APIClient
|
||||||
from .exceptions import SporeStackUserError
|
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
|
server_infos = api_client.servers_launched_from_token(token=_token).servers
|
||||||
machine_id_hostnames = {}
|
machine_id_hostnames = {}
|
||||||
|
@ -348,6 +377,13 @@ def server_list(
|
||||||
|
|
||||||
printed_machine_ids = []
|
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:
|
for info in server_infos:
|
||||||
if not show_forgotten and info.forgotten_at is not None:
|
if not show_forgotten and info.forgotten_at is not None:
|
||||||
continue
|
continue
|
||||||
|
@ -360,10 +396,23 @@ def server_list(
|
||||||
hostname = machine_id_hostnames[info.machine_id]
|
hostname = machine_id_hostnames[info.machine_id]
|
||||||
info.hostname = hostname
|
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)
|
printed_machine_ids.append(info.machine_id)
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
if local:
|
if local:
|
||||||
for hostname_json in os.listdir(directory):
|
for hostname_json in os.listdir(directory):
|
||||||
hostname = hostname_json.split(".")[0]
|
hostname = hostname_json.split(".")[0]
|
||||||
|
@ -383,7 +432,7 @@ def server_list(
|
||||||
except SporeStackUserError as e:
|
except SporeStackUserError as e:
|
||||||
expiration = saved_vm_info["expiration"]
|
expiration = saved_vm_info["expiration"]
|
||||||
human_expiration = time.strftime(
|
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 = hostname
|
||||||
msg += f" expired ({expiration} {human_expiration}): "
|
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 ._cli_utils import cents_to_usd, gb_string, mb_string, tb_string
|
||||||
from .api_client import APIClient
|
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 = Table(show_header=True, header_style="bold magenta")
|
||||||
table.add_column("Flavor Slug (--flavor)")
|
table.add_column("Flavor Slug (--flavor)")
|
||||||
|
@ -664,15 +713,17 @@ def save_token(token: str, key: str) -> None:
|
||||||
|
|
||||||
@token_cli.command(name="create")
|
@token_cli.command(name="create")
|
||||||
def token_create(
|
def token_create(
|
||||||
token: str = typer.Argument(DEFAULT_TOKEN),
|
dollars: Annotated[int, typer.Option()],
|
||||||
dollars: int = typer.Option(...),
|
currency: Annotated[str, typer.Option()],
|
||||||
currency: str = typer.Option(...),
|
token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Enables a new token.
|
Enables a new token.
|
||||||
|
|
||||||
Dollars is starting balance.
|
Dollars is starting balance.
|
||||||
"""
|
"""
|
||||||
|
from httpx import HTTPError
|
||||||
|
|
||||||
from . import utils
|
from . import utils
|
||||||
|
|
||||||
_token = utils.random_token()
|
_token = utils.random_token()
|
||||||
|
@ -694,11 +745,7 @@ def token_create(
|
||||||
currency=currency,
|
currency=currency,
|
||||||
)
|
)
|
||||||
|
|
||||||
uri = response.payment.uri
|
make_payment(response.invoice)
|
||||||
assert uri is not None
|
|
||||||
usd = response.payment.usd
|
|
||||||
|
|
||||||
make_payment(currency=currency, uri=uri, usd=usd)
|
|
||||||
|
|
||||||
tries = 360 * 2
|
tries = 360 * 2
|
||||||
while tries > 0:
|
while tries > 0:
|
||||||
|
@ -713,10 +760,10 @@ def token_create(
|
||||||
dollars=dollars,
|
dollars=dollars,
|
||||||
currency=currency,
|
currency=currency,
|
||||||
)
|
)
|
||||||
except SporeStackServerError:
|
except (SporeStackServerError, HTTPError):
|
||||||
typer.echo("Received 500 HTTP status, will try again.", err=True)
|
typer.echo("Received 500 HTTP status, will try again.", err=True)
|
||||||
continue
|
continue
|
||||||
if response.payment.paid is True:
|
if response.invoice.paid:
|
||||||
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}.")
|
||||||
typer.echo("Save it, don't share it, and don't lose it!")
|
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."""
|
"""Adds balance to an existing token."""
|
||||||
token = load_token(token)
|
token = load_token(token)
|
||||||
|
|
||||||
|
from httpx import HTTPError
|
||||||
|
|
||||||
from .api_client import APIClient
|
from .api_client import APIClient
|
||||||
from .exceptions import SporeStackServerError
|
from .exceptions import SporeStackServerError
|
||||||
|
|
||||||
|
@ -754,11 +803,7 @@ def token_topup(
|
||||||
currency=currency,
|
currency=currency,
|
||||||
)
|
)
|
||||||
|
|
||||||
uri = response.payment.uri
|
make_payment(response.invoice)
|
||||||
assert uri is not None
|
|
||||||
usd = response.payment.usd
|
|
||||||
|
|
||||||
make_payment(currency=currency, uri=uri, usd=usd)
|
|
||||||
|
|
||||||
tries = 360 * 2
|
tries = 360 * 2
|
||||||
while tries > 0:
|
while tries > 0:
|
||||||
|
@ -771,12 +816,12 @@ def token_topup(
|
||||||
dollars=dollars,
|
dollars=dollars,
|
||||||
currency=currency,
|
currency=currency,
|
||||||
)
|
)
|
||||||
except SporeStackServerError:
|
except (SporeStackServerError, HTTPError):
|
||||||
typer.echo("Received 500 HTTP status, will try again.", err=True)
|
typer.echo("Received 500 HTTP status, will try again.", err=True)
|
||||||
continue
|
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.invoice.paid:
|
||||||
typer.echo(f"Added {dollars} dollars to {token}")
|
typer.echo(f"Added {dollars} dollars to {token}")
|
||||||
return
|
return
|
||||||
raise ValueError(f"{token} did not get enabled in time.")
|
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")
|
@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.
|
Show information about a token, including balance.
|
||||||
|
|
||||||
|
@ -828,7 +873,7 @@ def token_info(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
|
||||||
|
|
||||||
|
|
||||||
@token_cli.command()
|
@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."""
|
"""Returns server info for servers launched by a given token."""
|
||||||
_token = load_token(token)
|
_token = load_token(token)
|
||||||
|
|
||||||
|
@ -842,13 +887,75 @@ def servers(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
|
||||||
@token_cli.command(name="list")
|
@token_cli.command(name="list")
|
||||||
def token_list() -> None:
|
def token_list() -> None:
|
||||||
"""List tokens."""
|
"""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()
|
token_dir = token_path()
|
||||||
typer.echo(f"SporeStack tokens present in {token_dir}:", err=True)
|
table = Table(
|
||||||
typer.echo("(Name): (Key)", err=True)
|
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"):
|
for token_file in token_dir.glob("*.json"):
|
||||||
token = token_file.stem
|
token = token_file.stem
|
||||||
key = load_token(token)
|
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()
|
@token_cli.command()
|
||||||
|
|
|
@ -3,7 +3,7 @@ from typing import List, Union
|
||||||
|
|
||||||
from . import api
|
from . import api
|
||||||
from .api_client import APIClient
|
from .api_client import APIClient
|
||||||
from .models import TokenInfo
|
from .models import Invoice, TokenInfo
|
||||||
from .utils import random_machine_id, random_token
|
from .utils import random_machine_id, random_token
|
||||||
|
|
||||||
|
|
||||||
|
@ -74,6 +74,10 @@ class Token:
|
||||||
"""Returns information about a token."""
|
"""Returns information about a token."""
|
||||||
return self.api_client.token_info(token=self.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]:
|
def messages(self) -> List[api.TokenMessage]:
|
||||||
"""Returns support messages for/from the token."""
|
"""Returns support messages for/from the token."""
|
||||||
return self.api_client.token_get_messages(token=self.token)
|
return self.api_client.token_get_messages(token=self.token)
|
||||||
|
|
|
@ -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
|
class Currency(str, Enum):
|
||||||
|
xmr = "xmr"
|
||||||
from pydantic import BaseModel
|
"""Monero"""
|
||||||
|
btc = "btc"
|
||||||
|
"""Bitcoin"""
|
||||||
|
bch = "bch"
|
||||||
|
"""Bitcoin Cash"""
|
||||||
|
|
||||||
|
|
||||||
class Payment(BaseModel):
|
class Payment(BaseModel):
|
||||||
|
"""This is deprecated in favor of Invoice."""
|
||||||
|
|
||||||
txid: Optional[str]
|
txid: Optional[str]
|
||||||
uri: Optional[str]
|
uri: Optional[str]
|
||||||
usd: str
|
usd: str
|
||||||
|
@ -62,3 +75,54 @@ class Region(BaseModel):
|
||||||
slug: str
|
slug: str
|
||||||
# Actually human readable string describing the region.
|
# Actually human readable string describing the region.
|
||||||
name: str
|
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."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
Loading…
Reference in New Issue