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

This commit is contained in:
Administrator 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: 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: python-3.8:
group: test group: test
image: python:3.8 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. - 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] ## [10.7.0 - 2023-10-31]
## Added ## Added

View File

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

View File

@ -52,6 +52,7 @@ 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 token invoices realtestingtoken
sporestack token topup realtestingtoken --currency xmr --dollars 26 --no-wait
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
@ -71,6 +72,8 @@ sporestack server rebuild --token realtestingtoken --hostname sporestackpythonin
sporestack server delete --token realtestingtoken --hostname sporestackpythonintegrationtestdelme sporestack server delete --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server forget --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 rm -r $SPORESTACK_DIR
echo Success echo Success

View File

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

View File

@ -2,4 +2,4 @@
__all__ = ["api", "api_client", "client", "exceptions"] __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.""" """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
legacy_polling: bool = True
class Response(BaseModel): class Response(BaseModel):
token: Annotated[str, Field(deprecated=True)] token: Annotated[str, Field(deprecated=True)]

View File

@ -274,10 +274,13 @@ class APIClient:
token: str, token: str,
dollars: int, dollars: int,
currency: str, currency: str,
legacy_polling: bool = True,
) -> api.TokenAdd.Response: ) -> api.TokenAdd.Response:
"""Add balance (money) to a token.""" """Add balance (money) to a token."""
url = self.api_endpoint + api.TokenAdd.url.format(token=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()) response = self._httpx_client.post(url, json=request.dict())
_handle_response(response) _handle_response(response)
response_object = api.TokenAdd.Response.parse_obj(response.json()) 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}) response = self._httpx_client.post(url=url, json={"message": message})
_handle_response(response) _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]: def token_invoices(self, token: str) -> List[Invoice]:
"""Get token invoices.""" """Get token invoices."""
url = self.api_endpoint + f"/token/{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()) return APIClient(api_endpoint=get_api_endpoint())
def make_payment(invoice: "Invoice") -> None: def invoice_qr(invoice: "Invoice") -> None:
import segno import segno
from ._cli_utils import cents_to_usd qr = segno.make(invoice.payment_uri)
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.terminal() qr.terminal()
typer.echo(message)
typer.echo(f"Approximate price in USD: {usd}")
input("[Press enter once you have made payment.]")
@server_cli.command() @server_cli.command()
@ -304,17 +288,13 @@ def epoch_to_human(epoch: int) -> str:
def print_machine_info(info: "api.ServerInfo.Response") -> None: def print_machine_info(info: "api.ServerInfo.Response") -> None:
from rich.console import Console from rich.console import Console
from rich.panel import Panel
console = Console(width=None if sys.stdout.isatty() else 10**9) console = Console(width=None if sys.stdout.isatty() else 10**9)
output = "" output = ""
if info.hostname != "": output = ""
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"
if info.ipv6 != "": if info.ipv6 != "":
output += f"IPv6: {info.ipv6}\n" output += f"IPv6: {info.ipv6}\n"
else: 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"Expiration: {epoch_to_human(info.expiration)}\n"
output += f"Autorenew: {info.autorenew}" 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") @server_cli.command(name="list")
@ -759,60 +754,34 @@ def token_create(
dollars: Annotated[int, typer.Option()], dollars: Annotated[int, typer.Option()],
currency: Annotated[str, typer.Option()], currency: Annotated[str, typer.Option()],
token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN, 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: ) -> 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()
typer.echo(f"Generated key {_token} for use with token {token}", err=True)
if Path(SPORESTACK_DIR / "tokens" / f"{token}.json").exists(): if Path(SPORESTACK_DIR / "tokens" / f"{token}.json").exists():
typer.echo("Token already created! Did you mean to `topup`?", err=True) typer.echo("Token already created! Did you mean to `topup`?", err=True)
raise typer.Exit(1) raise typer.Exit(1)
from .api_client import APIClient _token = utils.random_token()
from .exceptions import SporeStackServerError typer.echo(f"Generated key {_token} for use with token {token}", err=True)
api_client = APIClient(api_endpoint=get_api_endpoint()) save_token(token, _token)
token_add(
response = api_client.token_add(
token=_token, token=_token,
dollars=dollars, dollars=dollars,
currency=currency, currency=currency,
wait=wait,
token_name=token,
qr=qr,
) )
typer.echo(f"{token}'s key is {_token}.")
make_payment(response.invoice) typer.echo("Save it, don't share it, and don't lose it!")
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.")
@token_cli.command(name="import") @token_cli.command(name="import")
@ -824,51 +793,80 @@ def token_import(
save_token(name, key) 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") @token_cli.command(name="topup")
def token_topup( def token_topup(
token: str = typer.Argument(DEFAULT_TOKEN), token: str = typer.Argument(DEFAULT_TOKEN),
dollars: int = typer.Option(...), dollars: int = typer.Option(...),
currency: str = 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: ) -> None:
"""Adds balance to an existing token.""" """Adds balance to an existing token."""
token = load_token(token) real_token = load_token(token)
token_add(
from httpx import HTTPError token=real_token,
dollars=dollars,
from .api_client import APIClient
from .exceptions import SporeStackServerError
api_client = APIClient(api_endpoint=get_api_endpoint())
response = api_client.token_add(
token,
dollars,
currency=currency, 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() @token_cli.command()
def balance(token: str = typer.Argument(DEFAULT_TOKEN)) -> None: 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) 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() @token_cli.command()
def messages(token: str = typer.Argument(DEFAULT_TOKEN)) -> None: def messages(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
"""Show support messages.""" """Show support messages."""

View File

@ -70,9 +70,15 @@ class Token:
token: str = field(default_factory=random_token) token: str = field(default_factory=random_token)
api_client: APIClient = field(default_factory=APIClient) 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""" """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: def balance(self) -> int:
"""Returns the token's balance in cents.""" """Returns the token's balance in cents."""
@ -82,6 +88,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 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]: def invoices(self) -> List[Invoice]:
"""Returns invoices for adding balance to the token.""" """Returns invoices for adding balance to the token."""
return self.api_client.token_invoices(token=self.token) return self.api_client.token_invoices(token=self.token)

View File

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

View File

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