v10.8.0: Add `--wait/--no-wait` support to `sporestack token create/topup` and more
This commit is contained in:
parent
7a4f228625
commit
7398ebd1a2
|
@ -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
|
||||
|
|
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
## Requirements
|
||||
|
||||
* Python 3.7-3.11 (or maybe newer)
|
||||
* Python 3.8-3.11 (and likely newer)
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -2,4 +2,4 @@
|
|||
|
||||
__all__ = ["api", "api_client", "client", "exceptions"]
|
||||
|
||||
__version__ = "10.7.2"
|
||||
__version__ = "10.8.0"
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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.")
|
||||
|
||||
|
||||
@token_cli.command(name="import")
|
||||
|
@ -824,50 +793,79 @@ 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,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
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,
|
||||
real_token = load_token(token)
|
||||
token_add(
|
||||
token=real_token,
|
||||
dollars=dollars,
|
||||
currency=currency,
|
||||
wait=wait,
|
||||
token_name=token,
|
||||
qr=qr,
|
||||
)
|
||||
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()
|
||||
|
@ -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."""
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.")
|
||||
]
|
||||
|
|
Loading…
Reference in New Issue