From 7398ebd1a231545e984e106986527a56d08caee7 Mon Sep 17 00:00:00 2001 From: SporeStack Date: Wed, 3 Jan 2024 21:13:48 +0000 Subject: [PATCH] v10.8.0: Add `--wait/--no-wait` support to `sporestack token create/topup` and more --- .woodpecker.yml | 8 -- CHANGELOG.md | 13 ++ README.md | 2 +- integration-test.sh | 3 + pyproject.toml | 6 +- src/sporestack/__init__.py | 2 +- src/sporestack/api.py | 1 + src/sporestack/api_client.py | 13 +- src/sporestack/cli.py | 244 +++++++++++++++++++++-------------- src/sporestack/client.py | 14 +- src/sporestack/models.py | 2 +- tox.ini | 2 +- 12 files changed, 195 insertions(+), 115 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 1f88725..8aea765 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 602642d..43af011 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index c978868..c3c02d1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## Requirements -* Python 3.7-3.11 (or maybe newer) +* Python 3.8-3.11 (and likely newer) ## Installation diff --git a/integration-test.sh b/integration-test.sh index fc25136..434caf8 100755 --- a/integration-test.sh +++ b/integration-test.sh @@ -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 diff --git a/pyproject.toml b/pyproject.toml index e3f73c3..af1d795 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/sporestack/__init__.py b/src/sporestack/__init__.py index 08ca06c..dab3d8e 100644 --- a/src/sporestack/__init__.py +++ b/src/sporestack/__init__.py @@ -2,4 +2,4 @@ __all__ = ["api", "api_client", "client", "exceptions"] -__version__ = "10.7.2" +__version__ = "10.8.0" diff --git a/src/sporestack/api.py b/src/sporestack/api.py index 0860d68..2e124df 100644 --- a/src/sporestack/api.py +++ b/src/sporestack/api.py @@ -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)] diff --git a/src/sporestack/api_client.py b/src/sporestack/api_client.py index 47a9861..5275cec 100644 --- a/src/sporestack/api_client.py +++ b/src/sporestack/api_client.py @@ -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" diff --git a/src/sporestack/cli.py b/src/sporestack/cli.py index f2ddb2e..9fbcb44 100644 --- a/src/sporestack/cli.py +++ b/src/sporestack/cli.py @@ -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.""" diff --git a/src/sporestack/client.py b/src/sporestack/client.py index e7a7f2f..c4ae0d7 100644 --- a/src/sporestack/client.py +++ b/src/sporestack/client.py @@ -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) diff --git a/src/sporestack/models.py b/src/sporestack/models.py index 052e3c8..bc78334 100644 --- a/src/sporestack/models.py +++ b/src/sporestack/models.py @@ -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.") ] diff --git a/tox.ini b/tox.ini index 44a7c8c..105c651 100644 --- a/tox.ini +++ b/tox.ini @@ -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 =