v10.8.0: Add `--wait/--no-wait` support to `sporestack token create/topup` and more

This commit is contained in:
SporeStack 2024-01-03 21:13:48 +00:00
parent 7a4f228625
commit 7398ebd1a2
12 changed files with 195 additions and 115 deletions

View File

@ -1,12 +1,4 @@
pipeline:
python-3.7:
group: test
image: python:3.7-alpine
commands:
- pip install pipenv==2023.10.24
- pipenv install --dev --deploy
- pipenv run almake test-pytest # We only test with pytest on 3.7
python-3.8:
group: test
image: python:3.8

View File

@ -15,6 +15,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Nothing yet.
## [10.8.0 - 2024-01-03]
## Added
- Support for paying invoices without polling.
- `--qr/--no-qr` to `sporestack token topup` and `sporestack token create`.
- `--wait/--no-wait` to `sporestack token topup` and `sporestack token create`.
- `sporestack token invoice` support to view an individual invoice.
## Removed
- Python 3.7 support.
## [10.7.0 - 2023-10-31]
## Added

View File

@ -4,7 +4,7 @@
## Requirements
* Python 3.7-3.11 (or maybe newer)
* Python 3.8-3.11 (and likely newer)
## Installation

View File

@ -52,6 +52,7 @@ sporestack token info realtestingtoken
sporestack token messages realtestingtoken
sporestack token servers realtestingtoken
sporestack token invoices realtestingtoken
sporestack token topup realtestingtoken --currency xmr --dollars 26 --no-wait
sporestack server list --token realtestingtoken
sporestack server launch --no-quote --token realtestingtoken --operating-system debian-11 --days 1 --hostname sporestackpythonintegrationtestdelme
@ -71,6 +72,8 @@ sporestack server rebuild --token realtestingtoken --hostname sporestackpythonin
sporestack server delete --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server forget --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack token create newtoken --currency xmr --dollars 27 --no-wait
rm -r $SPORESTACK_DIR
echo Success

View File

@ -24,7 +24,7 @@ unfixable = [
"F841", # Unused variable
]
target-version = "py37"
target-version = "py38"
[tool.coverage.report]
show_missing = true
@ -48,7 +48,7 @@ warn_untyped_fields = true
name = "sporestack"
authors = [ {name = "SporeStack", email="support@sporestack.com"} ]
readme = "README.md"
requires-python = "~=3.7"
requires-python = "~=3.8"
dynamic = ["version", "description"]
keywords = ["bitcoin", "monero", "vps", "server"]
license = {file = "LICENSE.txt"}
@ -60,7 +60,7 @@ dependencies = [
"rich",
]
# These will be made mandatory for v11
# You will have to specify sporestack[cli] for v11+.
[project.optional-dependencies]
cli = [
"segno",

View File

@ -2,4 +2,4 @@
__all__ = ["api", "api_client", "client", "exceptions"]
__version__ = "10.7.2"
__version__ = "10.8.0"

View File

@ -25,6 +25,7 @@ class TokenAdd:
"""BREAKING: This will change to models.Currency in version 11."""
dollars: int
affiliate_token: Union[str, None] = None
legacy_polling: bool = True
class Response(BaseModel):
token: Annotated[str, Field(deprecated=True)]

View File

@ -274,10 +274,13 @@ class APIClient:
token: str,
dollars: int,
currency: str,
legacy_polling: bool = True,
) -> api.TokenAdd.Response:
"""Add balance (money) to a token."""
url = self.api_endpoint + api.TokenAdd.url.format(token=token)
request = api.TokenAdd.Request(dollars=dollars, currency=currency)
request = api.TokenAdd.Request(
dollars=dollars, currency=currency, legacy_polling=legacy_polling
)
response = self._httpx_client.post(url, json=request.dict())
_handle_response(response)
response_object = api.TokenAdd.Response.parse_obj(response.json())
@ -313,6 +316,14 @@ class APIClient:
response = self._httpx_client.post(url=url, json={"message": message})
_handle_response(response)
def token_invoice(self, token: str, invoice: str) -> Invoice:
"""Get a particular invoice."""
url = self.api_endpoint + f"/token/{token}/invoices/{invoice}"
response = self._httpx_client.get(url=url)
_handle_response(response)
return parse_obj_as(Invoice, response.json())
def token_invoices(self, token: str) -> List[Invoice]:
"""Get token invoices."""
url = self.api_endpoint + f"/token/{token}/invoices"

View File

@ -85,27 +85,11 @@ def get_api_client() -> "APIClient":
return APIClient(api_endpoint=get_api_endpoint())
def make_payment(invoice: "Invoice") -> None:
def invoice_qr(invoice: "Invoice") -> None:
import segno
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."""
qr = segno.make(uri)
# This typer.echos.
qr = segno.make(invoice.payment_uri)
qr.terminal()
typer.echo(message)
typer.echo(f"Approximate price in USD: {usd}")
input("[Press enter once you have made payment.]")
@server_cli.command()
@ -304,17 +288,13 @@ def epoch_to_human(epoch: int) -> str:
def print_machine_info(info: "api.ServerInfo.Response") -> None:
from rich.console import Console
from rich.panel import Panel
console = Console(width=None if sys.stdout.isatty() else 10**9)
output = ""
if info.hostname != "":
output += f"Hostname: {info.hostname}\n"
else:
output += "Hostname: (none) (No hostname set)\n"
output += f"Machine ID (keep this secret!): {info.machine_id}\n"
output = ""
if info.ipv6 != "":
output += f"IPv6: {info.ipv6}\n"
else:
@ -345,7 +325,22 @@ def print_machine_info(info: "api.ServerInfo.Response") -> None:
output += f"Expiration: {epoch_to_human(info.expiration)}\n"
output += f"Autorenew: {info.autorenew}"
console.print(output)
title = f"Machine ID: [italic]{info.machine_id}[/italic] "
if info.hostname != "":
title += f"[bold]({info.hostname})[/bold]"
else:
title += "(No hostname set)"
if info.autorenew:
subtitle = "Server is set to automatically renew. Watch your token balance!"
else:
subtitle = (
f"Server will expire: [italic]{epoch_to_human(info.expiration)}[/italic]"
)
panel = Panel(output, title=title, subtitle=subtitle)
console.print(panel)
@server_cli.command(name="list")
@ -759,60 +754,34 @@ def token_create(
dollars: Annotated[int, typer.Option()],
currency: Annotated[str, typer.Option()],
token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN,
wait: bool = typer.Option(True, help="Wait for the payment to be confirmed."),
qr: bool = typer.Option(True, help="Show a QR code for the payment URI."),
) -> None:
"""
Enables a new token.
Dollars is starting balance.
"""
from httpx import HTTPError
from . import utils
_token = utils.random_token()
typer.echo(f"Generated key {_token} for use with token {token}", err=True)
if Path(SPORESTACK_DIR / "tokens" / f"{token}.json").exists():
typer.echo("Token already created! Did you mean to `topup`?", err=True)
raise typer.Exit(1)
from .api_client import APIClient
from .exceptions import SporeStackServerError
_token = utils.random_token()
typer.echo(f"Generated key {_token} for use with token {token}", err=True)
api_client = APIClient(api_endpoint=get_api_endpoint())
response = api_client.token_add(
save_token(token, _token)
token_add(
token=_token,
dollars=dollars,
currency=currency,
wait=wait,
token_name=token,
qr=qr,
)
make_payment(response.invoice)
tries = 360 * 2
while tries > 0:
typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True)
tries = tries - 1
# FIXME: Wait two hours in a smarter way.
# Waiting for payment to set in.
time.sleep(10)
try:
response = api_client.token_add(
token=_token,
dollars=dollars,
currency=currency,
)
except (SporeStackServerError, HTTPError):
typer.echo("Received 500 HTTP status, will try again.", err=True)
continue
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!")
save_token(token, _token)
return
raise ValueError(f"{token} did not get enabled in time.")
typer.echo(f"{token}'s key is {_token}.")
typer.echo("Save it, don't share it, and don't lose it!")
@token_cli.command(name="import")
@ -824,51 +793,80 @@ def token_import(
save_token(name, key)
def token_add(
token: str, dollars: int, currency: str, wait: bool, token_name: str, qr: bool
) -> None:
from httpx import HTTPError
from .api_client import APIClient
from .client import Client
from .exceptions import SporeStackServerError
api_client = APIClient(api_endpoint=get_api_endpoint())
client = Client(api_client=api_client, client_token=token)
invoice = client.token.add(dollars, currency=currency, legacy_polling=False)
if qr:
invoice_qr(invoice)
typer.echo()
typer.echo(
"Resize your terminal and try again if QR code above is not readable."
)
typer.echo()
invoice_panel(invoice, token=token, token_name=token_name)
typer.echo("Pay *exactly* the specified amount. No more, no less.")
if not wait:
typer.echo("--no-wait: Not waiting for payment to be confirmed.", err=True)
typer.echo(
(
f"Check status with: sporestack token invoice {token_name} "
f"--invoice-id {invoice.id}"
),
err=True,
)
return
typer.echo("Press ctrl+c to abort.")
while invoice.expired is False or invoice.paid is False:
try:
invoice = client.token.invoice(invoice=invoice.id)
except (SporeStackServerError, HTTPError):
typer.echo("Received 500 HTTP status, will try again.", err=True)
continue
if invoice.paid:
typer.echo(
f"Added ${dollars} to {token_name} ({token}) with TXID {invoice.txid}"
)
return
typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True)
time.sleep(60)
if invoice.expired:
raise ValueError("Invoice has expired.")
@token_cli.command(name="topup")
def token_topup(
token: str = typer.Argument(DEFAULT_TOKEN),
dollars: int = typer.Option(...),
currency: str = typer.Option(...),
wait: bool = typer.Option(True, help="Wait for the payment to be confirmed."),
qr: bool = typer.Option(True, help="Show a QR code for the payment URI."),
) -> None:
"""Adds balance to an existing token."""
token = load_token(token)
from httpx import HTTPError
from .api_client import APIClient
from .exceptions import SporeStackServerError
api_client = APIClient(api_endpoint=get_api_endpoint())
response = api_client.token_add(
token,
dollars,
real_token = load_token(token)
token_add(
token=real_token,
dollars=dollars,
currency=currency,
wait=wait,
token_name=token,
qr=qr,
)
make_payment(response.invoice)
tries = 360 * 2
while tries > 0:
typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True)
tries = tries - 1
# FIXME: Wait two hours in a smarter way.
try:
response = api_client.token_add(
token=token,
dollars=dollars,
currency=currency,
)
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.invoice.paid:
typer.echo(f"Added {dollars} dollars to {token}")
return
raise ValueError(f"{token} did not get enabled in time.")
@token_cli.command()
def balance(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
@ -1003,6 +1001,58 @@ def token_invoices(token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN) -> N
console.print(table)
def invoice_panel(invoice: "Invoice", token: str, token_name: str) -> None:
from rich import print
from rich.panel import Panel
if invoice.paid != 0:
subtitle = f"[bold]Paid[/bold] with TXID: {invoice.txid}"
elif invoice.expired:
subtitle = "[bold]Expired[/bold]"
else:
subtitle = f"Unpaid. Expires: {epoch_to_human(invoice.expires)}"
content = (
f"Invoice created: {epoch_to_human(invoice.created)}\n"
f"Payment URI: [link={invoice.payment_uri}]{invoice.payment_uri}[/link]\n"
f"Cryptocurrency: {invoice.cryptocurrency.value.upper()}\n"
f"Cryptocurrency rate: [green]${invoice.fiat_per_coin}[/green]\n"
f"Dollars to add to token: [green]${invoice.amount // 100}[/green]"
)
panel = Panel(
content,
title=(
f"SporeStack Invoice ID [italic]{invoice.id}[/italic] "
f"for token [bold]{token_name}[/bold] ([italic]{token}[/italic])"
),
subtitle=subtitle,
)
print(panel)
@token_cli.command(name="invoice")
def token_invoice(
token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN,
invoice_id: str = typer.Option(help="Invoice's ID."),
qr: bool = typer.Option(False, help="Show a QR code for the payment URI."),
) -> None:
"""Show a particular invoice."""
_token = load_token(token)
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)
invoice = client.token.invoice(invoice_id)
if qr:
invoice_qr(invoice)
typer.echo()
invoice_panel(invoice, token=_token, token_name=token)
@token_cli.command()
def messages(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
"""Show support messages."""

View File

@ -70,9 +70,15 @@ class Token:
token: str = field(default_factory=random_token)
api_client: APIClient = field(default_factory=APIClient)
def add(self, dollars: int, currency: str) -> None:
def add(self, dollars: int, currency: str, legacy_polling: bool = True) -> Invoice:
"""Add to token"""
self.api_client.token_add(token=self.token, dollars=dollars, currency=currency)
response = self.api_client.token_add(
token=self.token,
dollars=dollars,
currency=currency,
legacy_polling=legacy_polling,
)
return response.invoice
def balance(self) -> int:
"""Returns the token's balance in cents."""
@ -82,6 +88,10 @@ class Token:
"""Returns information about a token."""
return self.api_client.token_info(token=self.token)
def invoice(self, invoice: str) -> Invoice:
"""Returns the specified token's invoice."""
return self.api_client.token_invoice(token=self.token, invoice=invoice)
def invoices(self) -> List[Invoice]:
"""Returns invoices for adding balance to the token."""
return self.api_client.token_invoices(token=self.token)

View File

@ -79,7 +79,7 @@ class Region(BaseModel):
class Invoice(BaseModel):
id: Union[int, str]
id: str
payment_uri: Annotated[
str, Field(description="Cryptocurrency URI for the payment.")
]

View File

@ -1,6 +1,6 @@
[tox]
env_list =
py{37,38,39,310,311}-pydantic{1,2}
py{38,39,310,311}-pydantic{1,2}
[testenv]
deps =