Compare commits

...

18 Commits

Author SHA1 Message Date
Administrator 6eaa839d51 Upgrade ruff and typer
ci/woodpecker/push/woodpecker Pipeline failed Details
Also update integration test to use Debian 12 instead of Debian 11.
2024-03-25 20:10:24 +00:00
Administrator 6cad92952a v11.1.0: Automatic per-token SSH key support
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/tag/woodpecker Pipeline failed Details
2024-03-16 01:06:05 +00:00
Administrator 6bc5791980 v11.0.1: Minor bug fixes
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/tag/woodpecker Pipeline failed Details
2024-02-29 04:31:06 +00:00
Administrator c28e8b45fc v11.0.0
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/tag/woodpecker Pipeline failed Details
Mostly cleanups/deprecation removals.
2024-02-27 00:45:32 +00:00
Administrator 9582ce66b7 More Woodpecker CI...
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-01-18 20:35:48 +00:00
Administrator 1ef7224b1a Adjustments to .woodpecker.yml
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-01-18 20:28:39 +00:00
Administrator c0ccff7e74 Remove group from Woodpecker CI
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-01-18 20:22:09 +00:00
Administrator 4de17915cb Update to new Woodpecker CI syntax
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-01-18 20:10:44 +00:00
Administrator 7398ebd1a2 v10.8.0: Add `--wait/--no-wait` support to `sporestack token create/topup` and more 2024-01-03 21:13:48 +00:00
Administrator 7a4f228625 v10.7.2: Fix false positive message about suspended servers 2024-01-03 02:17:27 +00:00
Administrator 0881d28222 v10.7.1: Loosen up type for invoice ID to support new API changes 2024-01-03 02:03:32 +00:00
Administrator bd4be5d999 pipenv updates 2024-01-03 02:00:41 +00:00
Administrator 691fe4d5f8 Integration test fixes for recent changes 2023-12-11 23:13:22 +00:00
Administrator ac7eb3a186 10.7.0: Add suspended server support
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/tag/woodpecker Pipeline failed Details
2023-10-31 21:14:57 +00:00
Administrator 190b94746c Update Pipfile, pipenv
ci/woodpecker/push/woodpecker Pipeline failed Details
2023-10-29 04:50:16 +00:00
Administrator 027bc13ad2 v10.6.3: Bump timeouts to 60 seconds
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/tag/woodpecker Pipeline failed Details
2023-09-18 10:19:52 +00:00
Administrator d84c2975ee v10.6.2: Support Pydantic v1 + v2 2023-07-07 22:31:08 +00:00
Administrator 396dbee6f6 v10.6.1: Require Pydantic v1.10 as we are not yet ready for v2 2023-07-07 18:34:49 +00:00
17 changed files with 1159 additions and 795 deletions

View File

@ -1,54 +1,47 @@
pipeline:
pre-commit:
group: pre-commit-test
image: python:3.11
commands:
- pip install pre-commit==2.20.0
- pre-commit run --all-files
python-3.7:
group: test
image: python:3.7-alpine
commands:
- pip install pipenv==2022.10.25
- pipenv install --dev --deploy
- pipenv run almake test-pytest # We only test with pytest on 3.7
steps:
python-3.8:
group: test
image: python:3.8
commands:
- pip install pipenv==2022.10.25
- pip install pipenv==2023.12.1 tomli
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake test-typing
- pipenv run almake test-pytest
- pipenv run almake build-dist
- sha256sum dist/*
python-3.9:
group: test
image: python:3.9
commands:
- pip install pipenv==2022.10.25
- pip install pipenv==2023.12.1
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake test-typing
- pipenv run almake test-pytest
- pipenv run almake build-dist
- sha256sum dist/*
python-3.10:
group: test
image: python:3.10
commands:
- pip install pipenv==2022.10.25
- pip install pipenv==2023.12.1
- pipenv install --dev --deploy
- pipenv run almake test-typing
- pipenv run almake test-pytest
- pipenv run almake build-dist
- sha256sum dist/*
python-3.11:
image: python:3.11
commands:
- pip install pipenv==2023.12.1
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake build-dist
- sha256sum dist/*
python-3.11:
group: test
python-3.12:
image: python:3.11
commands:
- pip install pipenv==2022.10.25
- pip install pipenv==2023.12.1
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake build-dist

View File

@ -5,16 +5,91 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Deprecated features that will be removed in the next major version.
## Deprecated features that will be removed in the next major version (12.X.X).
- `burn_rate` from `TokenInfo` is deprecated. Use `burn_rate_cents` or `burn_rate_usd` instead.
- `--no-local` will become the default for `sporestack server list`.
- If you want the CLI features, you will have to `pip install sporestack[cli]` instead of just `pip install sporestack`.
- `--local` will be removed from `sporestack server list`.
## [Unreleased]
- Nothing yet.
## [11.1.0 - 2024-03-16]
## Added
### Library
- `ssh_key` to `client.Client()` and to `client.Token()`. This acts as a default SSH key when launching servers this way.
### CLI
- Support for automatic per-token SSH keys (can be overridden with `--ssh-key-file` still.) To generate, run: `ssh-keygen -C "" -t ed25519 -f ~/.sporestack/sshkey/{token}/id_ed25519`
- This means that you don't have to pass `--ssh-key-file` if you are using a token that has a locally associated SSH key.
- When launching a server with `sporestack server launch`, it will suggest adding a readymade configuration to `~/.ssh/config` to utilize whatever key you selected.
## Summary
These changes should make it easier to stay private with SporeStack, conveniently, by utilizing a SSH key per token. In general, we recommend using one unique SSH key per token that you have.
## [11.0.1 - 2024-02-29]
## Fixed
- If a server is deleted during the launch wait phase, it will give up rather than trying to wait forever for an IP address that will never come.
- `--hostname` matching is smarter in case of duplicate hostnames.
## [11.0.0 - 2024-02-26]
## Changed
- Various command/help cleanups.
- If you want the CLI features, you will have to `pip install sporestack[cli]` instead of just `pip install sporestack`.
- `--no-local` is now the default for `sporestack server list`.
## Removed
- Deprecated fields from responses and requests.
- `legacy_polling=True` support for token add/topup.
## [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
- Added `suspended_at` to server info response object.
- Added `autorenew_servers` to token info response object.
- Added `suspended_servers` to token info response object.
## [10.6.3 - 2023-09-18]
## Changed
- Bumped httpx timeouts from 5 seconds to 60 seconds (this may be fine-tuned in the future).
## [10.6.2 - 2023-07-07]
## Changed
- Make package compatible with Pydantic v1.10.x and v2.
## [10.6.1 - 2023-07-07]
## Changed
- Mark package as being compatible with Pydantic v1.10.X. It's not yet ready with v2. Does not seem to be possible to make the release compatible with both.
## [10.6.0 - 2023-05-25]
## Added

View File

@ -1,13 +1,16 @@
format:
black .
ruff --fix .
ruff check --fix .
test:
black --check .
ruff .
mypy
ruff check .
$(MAKE) test-typing
$(MAKE) test-pytest
test-typing:
mypy
test-pytest:
python -m pytest --cov=sporestack --cov-fail-under=39 --cov-report=term --durations=3 --cache-clear

16
Pipfile
View File

@ -7,25 +7,25 @@ name = "pypi"
sporestack = {editable = true, path = "."}
[dev-packages]
black = "~=23.1"
black = "~=24.0"
mypy = "~=1.0"
pytest = "~=7.2"
pytest = "~=8.0"
pytest-cov = "~=4.0"
pytest-mock = "~=3.6"
pytest-socket = "~=0.6.0"
ruff = "==0.0.261"
pytest-socket = "~=0.7.0"
ruff = "~=0.3.4"
respx = "~=0.20.1"
# Building
flit = "~=3.8"
wheel = "~=0.40.0"
build = "~=0.10.0"
wheel = "*"
build = "~=1.0"
# Publishing
twine = "~=4.0"
twine = "~=5.0"
# Docs
pdoc = "~=13.0"
pdoc = "~=14.0"
# Python `make` implementation
almost-make = "~=0.5.2"

1058
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +1,33 @@
# Python 3 library and CLI for [SporeStack](https://sporestack.com) [.onion](http://spore64i5sofqlfz5gq2ju4msgzojjwifls7rok2cti624zyq3fcelad.onion)
# Python 3 library and CLI for [SporeStack](https://sporestack.com) ([SporeStack Tor Hidden Service](http://spore64i5sofqlfz5gq2ju4msgzojjwifls7rok2cti624zyq3fcelad.onion))
[Changelog](CHANGELOG.md)
## Requirements
* Python 3.7-3.11 (or maybe newer)
## Installation
* `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.
* 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.
* Python 3.8-3.11 (and likely newer)
## Running without installing
* Make sure `pipx` is installed.
* Make sure [pipx](https://pipx.pypya.io) is installed.
* `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/).
## Usage
## Installation with pipx
* `sporestack token create --dollars 20 --currency xmr # Can use btc as well.`
* Make sure [pipx](https://pipx.pypya.io) is installed.
* `pipx install 'sporestack[cli]'`
## Traditional installation
* Recommended: Create and activate a virtual environment, first.
* `pip install sporestack` (Run `pip install 'sporestack[cli]'` if you wish to use the command line `sporestack` functionality and not just the Python library.)
## Usage Examples
* Recommended: 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/).
* `sporestack token create --dollars 20 --currency xmr`
* `sporestack token list`
* `sporestack token info`
* `sporestack server launch --hostname 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-12 --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`.)
* `sporestack server stop --hostname SomeHostname`
* `sporestack server stop --machine-id ss_m_... # Or use --machine-id to be more pedantic.`
@ -32,16 +36,15 @@
* `sporestack server autorenew-disable --hostname SomeHostname`
* `sporestack server list`
* `sporestack server delete --hostname SomeHostname`
* `sporestack server remove --hostname SomeHostname # If expired`
## Notes
* If you want to communicate with SporeStack APIs using Tor, set this environment variable: `SPORESTACK_USE_TOR_ENDPOINT=1`
* If you want to communicate with the SporeStack API using Tor, set this environment variable: `SPORESTACK_USE_TOR_ENDPOINT=1`. Verify which endpoint is in use with `sporestack api-endpoint`.
## Developing
* `pipenv install --deploy --dev`
* `pipenv run make test` (If you don't have `make`, use `almake`)
* `pipenv run make test`
* `pipenv run make format` to format files and apply ruff fixes.
## Licence

View File

@ -25,15 +25,14 @@ sporestack token list
sporestack token import importediminvalid --key "imaninvalidkey"
sporestack token list | grep importediminvalid
sporestack token list | grep imaninvalidkey
sporestack server launch --no-quote --token neverbeencreated --operating-system debian-11 --days 1 2>&1 | grep 'does not exist'
sporestack server launch --no-quote --token neverbeencreated --operating-system debian-12 --days 1 2>&1 | grep 'does not exist'
# Online tests start here.
sporestack token create --dollars 50 --currency fakecurrency ihaveafakecurrency 2>&1 | grep 'value is not a valid'
sporestack server launch --no-quote --token importediminvalid --operating-system debian-11 --days 1 2>&1 | grep 'ensure this value has at least 32'
sporestack server launch --no-quote --token importediminvalid --operating-system debian-12 --days 1 2>&1 | grep 'String should have at least'
sporestack server flavors | grep vcpu
sporestack server operating-systems | grep debian-11
sporestack server operating-systems | grep debian-12
sporestack server regions | grep sfo3
sporestack api-changelog
@ -52,9 +51,10 @@ 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
sporestack server launch --no-quote --token realtestingtoken --operating-system debian-12 --days 1 --hostname sporestackpythonintegrationtestdelme
sporestack server list --token realtestingtoken | grep sporestackpythonintegrationtestdelme
sporestack server topup --token realtestingtoken --hostname sporestackpythonintegrationtestdelme --days 1
sporestack server info --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
@ -71,6 +71,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

@ -2,7 +2,7 @@
addopts = "--strict-markers --disable-socket"
[tool.ruff]
select = [
lint.select = [
"F", # pyflakes
"E", # pycodestyle errors
"W", # pycodestyle warnings
@ -13,19 +13,18 @@ select = [
"UP", # pyupgrade
]
ignore = [
lint.ignore = [
"ANN101", # Type annotations for self
"ANN401", # Allow ANY
]
unfixable = [
lint.unfixable = [
"F401", # Don't try to automatically remove unused imports
"RUF100", # Unused noqa
"F841", # Unused variable
]
target-version = "py37"
update-check = false
target-version = "py38"
[tool.coverage.report]
show_missing = true
@ -49,23 +48,19 @@ 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"}
dependencies = [
"pydantic",
"pydantic>=1.10,<3",
"httpx[socks]",
"segno",
"typer>=0.9.0",
"rich",
]
# These will be made mandatory for v11
[project.optional-dependencies]
cli = [
"segno",
"typer",
"typer>=0.9.0",
"rich",
]

View File

@ -1,5 +1,5 @@
"""SporeStack API and CLI for launching servers with Monero or Bitcoin"""
"""SporeStack API library and CLI for launching servers with Monero or Bitcoin"""
__all__ = ["api", "api_client", "exceptions"]
__all__ = ["api", "api_client", "client", "exceptions"]
__version__ = "10.6.0"
__version__ = "11.1.0"

11
src/sporestack/_models.py Normal file
View File

@ -0,0 +1,11 @@
# This file is split out to improve CLI performance.
from enum import Enum
class Currency(str, Enum):
xmr = "xmr"
"""Monero"""
btc = "btc"
"""Bitcoin"""
bch = "bch"
"""Bitcoin Cash"""

View File

@ -1,6 +1,5 @@
"""SporeStack API request/response models"""
import sys
from datetime import datetime
from enum import Enum
@ -8,7 +7,7 @@ from typing import Dict, List, Optional, Union
from pydantic import BaseModel, Field
from .models import Flavor, Invoice, OperatingSystem, Payment, Region
from .models import Currency, Flavor, Invoice, OperatingSystem, Region
if sys.version_info >= (3, 9): # pragma: nocover
from typing import Annotated
@ -21,14 +20,11 @@ class TokenAdd:
method = "POST"
class Request(BaseModel):
currency: str
"""BREAKING: This will change to models.Currency in version 11."""
currency: Currency
dollars: int
affiliate_token: Union[str, None] = None
class Response(BaseModel):
token: Annotated[str, Field(deprecated=True)]
payment: Annotated[Payment, Field(deprecated=True)]
invoice: Invoice
@ -37,7 +33,6 @@ class TokenBalance:
method = "GET"
class Response(BaseModel):
token: Annotated[str, Field(deprecated=True)]
cents: int
usd: str
@ -117,10 +112,10 @@ class ServerInfo:
ipv6: str
region: str
flavor: Flavor
deleted: bool
deleted_at: int
deleted_by: Union[ServerDeletedBy, None]
forgotten_at: Union[datetime, None]
suspended_at: Union[datetime, None]
operating_system: str
hostname: str
autorenew: bool

View File

@ -7,11 +7,12 @@ import httpx
from pydantic import parse_obj_as
from . import __version__, api, exceptions
from .models import Invoice, ServerUpdateRequest, TokenInfo
from .models import Currency, Invoice, ServerUpdateRequest, TokenInfo
log = logging.getLogger(__name__)
LATEST_API_VERSION = 2
"""This is probably not used anymore."""
CLEARNET_ENDPOINT = "https://api.sporestack.com"
TOR_ENDPOINT = (
@ -20,9 +21,7 @@ TOR_ENDPOINT = (
API_ENDPOINT = CLEARNET_ENDPOINT
GET_TIMEOUT = 60
POST_TIMEOUT = 90
USE_TOR_PROXY = "auto"
TIMEOUT = httpx.Timeout(60.0)
HEADERS = {"User-Agent": f"sporestack-python/{__version__}"}
@ -102,7 +101,9 @@ class APIClient:
proxy = None
if _is_onion_url(self.api_endpoint):
proxy = _get_tor_proxy()
self._httpx_client = httpx.Client(headers=headers, proxies=proxy)
self._httpx_client = httpx.Client(
headers=headers, proxies=proxy, timeout=TIMEOUT
)
def server_launch(
self,
@ -273,7 +274,7 @@ class APIClient:
self,
token: str,
dollars: int,
currency: str,
currency: Currency,
) -> api.TokenAdd.Response:
"""Add balance (money) to a token."""
url = self.api_endpoint + api.TokenAdd.url.format(token=token)
@ -313,6 +314,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

@ -8,10 +8,12 @@ import os
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
import typer
from ._models import Currency
if sys.version_info >= (3, 9): # pragma: nocover
from typing import Annotated
else: # pragma: nocover
@ -47,11 +49,12 @@ cli = typer.Typer(help=HELP)
HOME = Path(_home)
token_cli = typer.Typer(help="Commands to interact with SporeStack tokens")
token_cli = typer.Typer(help="Commands to interact with SporeStack tokens.")
cli.add_typer(token_cli, name="token")
server_cli = typer.Typer(help="Commands to interact with SporeStack servers")
server_cli = typer.Typer(help="Commands to interact with SporeStack servers.")
cli.add_typer(server_cli, name="server")
log = logging.getLogger(__name__)
_log_level = os.getenv("LOG_LEVEL", "warning").upper()
_numeric_log_level = getattr(logging, _log_level, None)
if _numeric_log_level is None:
@ -63,6 +66,8 @@ DEFAULT_TOKEN = "primary"
DEFAULT_FLAVOR = "vps-1vcpu-1gb"
# Users may have a different key file, but this is the most common.
DEFAULT_SSH_KEY_FILE = HOME / ".ssh" / "id_rsa.pub"
DEFAULT_TOKEN_SSH_KEY_PRIVATE = Path("id_ed25519")
DEFAULT_TOKEN_SSH_KEY_PUBLIC = DEFAULT_TOKEN_SSH_KEY_PRIVATE.with_suffix(".pub")
# On disk format
TOKEN_VERSION = 1
@ -85,43 +90,98 @@ 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.]")
def normalize_ssh_key_file(ssh_key_file: Union[Path, None], token: str) -> Path:
if ssh_key_file is None:
token_specific_path = ssh_key_path(token)
token_specific_key = token_specific_path / DEFAULT_TOKEN_SSH_KEY_PUBLIC
if token_specific_key.exists():
ssh_key_file = token_specific_key
elif DEFAULT_SSH_KEY_FILE.exists():
ssh_key_file = DEFAULT_SSH_KEY_FILE
if ssh_key_file is None:
typer.echo(
"No SSH key specified with --ssh-key-file, nor was "
f"{token_specific_key} or {DEFAULT_SSH_KEY_FILE} found.",
err=True,
)
typer.echo("You can generate a SSH key with `ssh-key-gen`", err=True)
raise typer.Exit(code=1)
return ssh_key_file
@server_cli.command()
def launch(
days: Annotated[
int,
typer.Option(min=1, max=90, help="Number of days the server should run for."),
typer.Option(
min=1,
max=90,
help=(
"Initially fund the server to run for this many days. Use "
"--autorenew if you don't want it to expire."
),
show_default=False,
),
],
operating_system: Annotated[str, typer.Option(help="Example: debian-11")],
hostname: str = "",
ssh_key_file: Path = DEFAULT_SSH_KEY_FILE,
flavor: str = DEFAULT_FLAVOR,
token: str = DEFAULT_TOKEN,
region: Optional[str] = None,
operating_system: Annotated[
str,
typer.Option(
help=(
"Example: debian-12 (Run `sporestack server operating-systems` for "
"more options.)"
),
show_default=False,
),
],
hostname: Annotated[
str,
typer.Option(
help=(
"Give the server a hostname to help remember what it's for. "
"(Note: This is visible to us.)"
)
),
] = "",
ssh_key_file: Annotated[
Union[Path, None],
typer.Option(
help=(
"SSH key that the new server will allow to login as root. Defaults "
"to the token-specific SSH key, or ~/.ssh/id_rsa.pub if the former "
"was not found."
),
show_default=False,
),
] = None,
flavor: Annotated[
str, typer.Option(help="Run `sporestack server flavors` to see more options.")
] = DEFAULT_FLAVOR,
token: Annotated[
str, typer.Option(help="Which token to launch the server with.")
] = DEFAULT_TOKEN,
region: Annotated[
Optional[str],
typer.Option(
help=(
"Leave unset for random region selection. Or run `sporestack server "
"regions` for options."
),
show_default=False,
),
] = None,
quote: bool = typer.Option(True, help="Require manual price confirmation."),
autorenew: bool = typer.Option(False, help="Automatically renew server."),
autorenew: bool = typer.Option(
False, help="Automatically renew server. (--days 7) recommended if using this."
),
wait: bool = typer.Option(
True, help="Wait for server to be assigned an IP address."
),
@ -130,21 +190,16 @@ def launch(
typer.echo(f"Launching server with token {token}...", err=True)
_token = load_token(token)
from . import utils
ssh_key_file = normalize_ssh_key_file(ssh_key_file=ssh_key_file, token=token)
typer.echo(f"Using SSH key: {ssh_key_file}")
from .client import Client
client = Client(api_client=get_api_client(), client_token=_token)
typer.echo(f"Loading SSH key from {ssh_key_file}...")
if not ssh_key_file.exists():
msg = f"{ssh_key_file} does not exist. "
msg += "You can try generating a key file with `ssh-keygen`"
typer.echo(msg, err=True)
raise typer.Exit(code=1)
ssh_key = ssh_key_file.read_text()
machine_id = utils.random_machine_id()
client = Client(
api_client=get_api_client(),
client_token=_token,
ssh_key=ssh_key_file.read_text(),
)
if quote:
quote_response = client.server_quote(days=days, flavor=flavor)
@ -164,20 +219,23 @@ def launch(
)
server = client.token.launch_server(
machine_id=machine_id,
days=days,
flavor=flavor,
operating_system=operating_system,
ssh_key=ssh_key,
region=region,
hostname=hostname,
autorenew=autorenew,
)
if wait:
tries = 360
tries = 60
while tries > 0:
response = server.info()
if response.deleted_at > 0:
typer.echo(
"Server creation failed, was deleted while waiting.", err=True
)
raise typer.Exit(code=1)
if response.ipv4 != "":
break
typer.echo("Waiting for server to build...", err=True)
@ -190,9 +248,44 @@ def launch(
raise typer.Exit(code=1)
else:
print_machine_info(response)
return
print_machine_info(server.info())
if not wait:
print_machine_info(server.info())
return
typer.echo("Consider adding the following to ~/.ssh/config...")
config = (
"\nHost {host}\n"
"\tHostname {hostname}\n"
f"\tIdentityFile {str(ssh_key_file).strip('.pub')}\n"
"\tUser root\n"
"\t # Remove this comment if you wish to connect via Tor. "
"ProxyCommand nc -x localhost:9050 %h %p\n"
)
typer.echo("If you wish to connect with IPv4:")
typer.echo(
config.format(
host=hostname if hostname != "" else response.ipv4, hostname=response.ipv4
)
)
typer.echo("Or if you wish to connect with IPv6:")
typer.echo(
config.format(
host=hostname if hostname != "" else response.ipv6, hostname=response.ipv6
)
)
msg = (
"If you've done that, you should be able to run `ssh {host}` "
"to connect to the server."
)
if hostname != "":
typer.echo(msg.format(host=hostname))
else:
typer.echo(msg.format(host=f"({response.ipv4} or {response.ipv6})"))
@server_cli.command()
@ -225,20 +318,6 @@ def server_info_path() -> Path:
# Put servers in a subdirectory
servers_dir = SPORESTACK_DIR / "servers"
# Migrate existing server.json files into servers subdirectory
if (
SPORESTACK_DIR.exists()
and not servers_dir.exists()
and len(list(SPORESTACK_DIR.glob("*.json"))) > 0
):
typer.echo(
f"Migrating server profiles found in {SPORESTACK_DIR} to {servers_dir}.",
err=True,
)
servers_dir.mkdir()
for json_file in SPORESTACK_DIR.glob("*.json"):
json_file.rename(servers_dir / json_file.name)
# Make it, if it doesn't exist already.
SPORESTACK_DIR.mkdir(exist_ok=True)
servers_dir.mkdir(exist_ok=True)
@ -255,6 +334,15 @@ def token_path() -> Path:
return token_dir
def ssh_key_path(token: str) -> Path:
ssh_key_dir = SPORESTACK_DIR / "sshkey" / token
# Make it, if it doesn't exist already.
ssh_key_dir.mkdir(exist_ok=True, parents=True)
return ssh_key_dir
def get_machine_info(hostname: str) -> Dict[str, Any]:
"""
Get info from disk.
@ -303,38 +391,60 @@ def epoch_to_human(epoch: int) -> str:
def print_machine_info(info: "api.ServerInfo.Response") -> None:
if info.hostname != "":
typer.echo(f"Hostname: {info.hostname}")
else:
typer.echo("Hostname: (none) (No hostname set)")
from rich.console import Console
from rich.panel import Panel
typer.echo(f"Machine ID (keep this secret!): {info.machine_id}")
console = Console(width=None if sys.stdout.isatty() else 10**9)
output = ""
output = ""
if info.ipv6 != "":
typer.echo(f"IPv6: {info.ipv6}")
output += f"IPv6: {info.ipv6}\n"
else:
typer.echo("IPv6: (Not yet assigned)")
output += "IPv6: (Not yet assigned)\n"
if info.ipv4 != "":
typer.echo(f"IPv4: {info.ipv4}")
output += f"IPv4: {info.ipv4}\n"
else:
typer.echo("IPv4: (Not yet assigned)")
typer.echo(f"Region: {info.region}")
typer.echo(f"Flavor: {info.flavor.slug}")
typer.echo(f"Expiration: {epoch_to_human(info.expiration)}")
typer.echo(f"Token (keep this secret!): {info.token}")
if info.deleted_at != 0 or info.deleted:
typer.echo("Server was deleted!")
if info.deleted_at != 0:
typer.echo(f"Server deleted at: {epoch_to_human(info.deleted_at)}")
output += "IPv4: (Not yet assigned)\n"
output += f"Region: {info.region}\n"
output += f"Flavor: {info.flavor.slug}\n"
output += f"Token (keep this secret!): {info.token}\n"
if info.deleted_at != 0:
output += f"Server deleted at: {epoch_to_human(info.deleted_at)}\n"
if info.deleted_by is not None:
typer.echo(f"Server deleted by: {info.deleted_by.value}")
output += f"Server deleted by: {info.deleted_by.value}\n"
if info.forgotten_at is not None:
typer.echo(f"Server forgotten at: {info.forgotten_at}")
output += f"Server forgotten at: {info.forgotten_at}\n"
else:
typer.echo(f"Running: {info.running}")
msg = f"Running: {info.running}\n"
if info.suspended_at is not None:
msg = (
"Running: Server is powered off because it is [bold]suspended[/bold].\n"
)
output += msg
time_to_live = info.expiration - int(time.time())
hours = time_to_live // 3600
typer.echo(f"Server will be deleted in {hours} hours.")
typer.echo(f"Autorenew: {info.autorenew}")
output += f"Server will be deleted in {hours} hours.\n"
output += f"Expiration: {epoch_to_human(info.expiration)}\n"
output += f"Autorenew: {info.autorenew}"
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")
@ -342,7 +452,7 @@ def server_list(
token: str = DEFAULT_TOKEN,
local: Annotated[
bool, typer.Option(help="List older servers not associated to token.")
] = True,
] = False,
show_forgotten: Annotated[
bool, typer.Option(help="Show deleted and forgotten servers.")
] = False,
@ -414,6 +524,12 @@ def server_list(
str(info.autorenew),
)
if info.suspended_at is not None:
typer.echo(
f"Warning: {info.machine_id} was suspended at {info.suspended_at}!",
err=True,
)
printed_machine_ids.append(info.machine_id)
console.print(table)
@ -474,11 +590,29 @@ def _get_machine_id(machine_id: str, hostname: str, token: str) -> str:
api_client = APIClient(api_endpoint=get_api_endpoint())
candidates = []
for server in api_client.servers_launched_from_token(token=_token).servers:
if server.forgotten_at is not None:
continue
if server.hostname == hostname:
return server.machine_id
candidates.append(server)
if len(candidates) == 1:
return candidates[0].machine_id
remaining_candidates = []
for candidate in candidates:
if candidate.deleted_at == 0:
remaining_candidates.append(candidate)
if len(remaining_candidates) == 1:
return remaining_candidates[0].machine_id
elif len(remaining_candidates) > 1:
typer.echo(
"Too many servers match that hostname. Please use --machine-id, instead.",
err=True,
)
raise typer.Exit(code=1)
typer.echo(
f"Could not find any servers matching the hostname: {hostname}", err=True
@ -739,63 +873,52 @@ def save_token(token: str, key: str) -> None:
@token_cli.command(name="create")
def token_create(
dollars: Annotated[int, typer.Option()],
currency: Annotated[str, typer.Option()],
dollars: Annotated[
int,
typer.Option(help="How many dollars to add to the token.", show_default=False),
],
currency: Annotated[
Currency,
typer.Option(help="Which cryptocurrency to pay with.", show_default=False),
],
token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN,
wait: Annotated[
bool, typer.Option(help="Wait for the payment to be confirmed.")
] = True,
qr: Annotated[
bool, typer.Option(help="Show a QR code for the payment URI.")
] = True,
) -> None:
"""
Enables a new token.
Dollars is starting balance.
"""
from httpx import HTTPError
"""Enables a new token."""
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,
)
typer.echo(f"{token}'s key is {_token}.")
typer.echo("Save it, don't share it, and don't lose it!")
typer.echo()
typer.echo("Optional: Make a SSH key just for this token.")
token_ssh_key_path = ssh_key_path(token) / DEFAULT_TOKEN_SSH_KEY_PRIVATE
typer.echo(f'Run: ssh-keygen -C "" -t ed25519 -f "{token_ssh_key_path}"')
typer.echo(
"If you do this, servers launched from that token will default to use "
"that key and you won't have to pass --ssh-key-file every time you "
"launch a server!"
)
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")
@ -807,50 +930,89 @@ def token_import(
save_token(name, key)
@token_cli.command(name="topup")
def token_topup(
token: str = typer.Argument(DEFAULT_TOKEN),
dollars: int = typer.Option(...),
currency: str = typer.Option(...),
def token_add(
token: str, dollars: int, currency: Currency, wait: bool, token_name: str, qr: bool
) -> None:
"""Adds balance to an existing token."""
token = load_token(token)
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)
response = api_client.token_add(
token,
dollars,
currency=currency,
)
invoice = client.token.add(dollars, currency=currency)
make_payment(response.invoice)
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.")
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.
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:
response = api_client.token_add(
token=token,
dollars=dollars,
currency=currency,
)
invoice = client.token.invoice(invoice=invoice.id)
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}")
if invoice.paid:
typer.echo(
f"Added ${dollars} to {token_name} ({token}) with TXID {invoice.txid}"
)
return
raise ValueError(f"{token} did not get enabled in time.")
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(
currency: Annotated[
Currency,
typer.Option(help="Which cryptocurrency to pay with.", show_default=False),
],
dollars: Annotated[
int,
typer.Option(help="How many dollars to add to the token.", show_default=False),
],
token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN,
wait: Annotated[
bool, typer.Option(help="Wait for the payment to be confirmed.")
] = True,
qr: Annotated[
bool, typer.Option(help="Show a QR code for the payment URI.")
] = True,
) -> None:
"""Adds balance to an existing token."""
real_token = load_token(token)
token_add(
token=real_token,
dollars=dollars,
currency=currency,
wait=wait,
token_name=token,
qr=qr,
)
@token_cli.command()
@ -887,7 +1049,9 @@ def token_info(token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN) -> None:
info = client.token.info()
print(f"[bold]Token Information for {token} ({_token})[/bold]")
print(f"Balance: [green]{info.balance_usd}")
print(f"Total Servers: {info.servers}")
print(f"Total Servers (not deleted): {info.servers}")
print(f"Servers set to autorenew: {info.autorenew_servers}")
print(f"Suspended servers: {info.suspended_servers}")
print(
f"Burn Rate: [red]{info.burn_rate_usd}[/red] "
"(per day of servers set to autorenew)"
@ -900,7 +1064,7 @@ def token_info(token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN) -> None:
@token_cli.command()
def servers(token: Annotated[str, typer.Argument()] = DEFAULT_TOKEN) -> None:
"""Returns server info for servers launched by a given token."""
"""Use sporestack server list --token TOKEN instead!"""
_token = load_token(token)
from .api_client import APIClient
@ -984,6 +1148,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

@ -3,7 +3,7 @@ from typing import List, Union
from . import api
from .api_client import APIClient
from .models import Invoice, TokenInfo
from .models import Currency, Invoice, TokenInfo
from .utils import random_machine_id, random_token
@ -67,12 +67,19 @@ class Server:
@dataclass
class Token:
token: str = random_token()
token: str = field(default_factory=random_token)
api_client: APIClient = field(default_factory=APIClient)
ssh_key: Union[str, None] = None
"""SSH public key for launching new servers with."""
def add(self, dollars: int, currency: str) -> None:
"""Add to token"""
self.api_client.token_add(token=self.token, dollars=dollars, currency=currency)
def add(self, dollars: int, currency: Currency) -> Invoice:
"""Fund the token."""
response = self.api_client.token_add(
token=self.token,
dollars=dollars,
currency=currency,
)
return response.invoice
def balance(self) -> int:
"""Returns the token's balance in cents."""
@ -82,6 +89,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)
@ -110,15 +121,20 @@ class Token:
def launch_server(
self,
ssh_key: str,
flavor: str,
days: int,
operating_system: str,
ssh_key: Union[str, None] = None,
region: Union[str, None] = None,
hostname: str = "",
autorenew: bool = False,
machine_id: str = random_machine_id(),
) -> Server:
if ssh_key is None:
if self.ssh_key is not None:
ssh_key = self.ssh_key
else:
raise ValueError("ssh_key must be set in Client() or launch_server().")
self.api_client.server_launch(
machine_id=machine_id,
days=days,
@ -141,6 +157,8 @@ class Client:
"""Token to manage/pay for servers with."""
api_client: APIClient = field(default_factory=APIClient)
"""Your own API Client, perhaps if you want to connect through Tor."""
ssh_key: Union[str, None] = None
"""SSH public key for launching new servers with."""
def flavors(self) -> api.Flavors.Response:
"""Returns available flavors (server sizes)."""
@ -165,4 +183,6 @@ class Client:
@property
def token(self) -> Token:
"""Returns a Token object with the api_client and token specified."""
return Token(token=self.client_token, api_client=self.api_client)
return Token(
token=self.client_token, api_client=self.api_client, ssh_key=self.ssh_key
)

View File

@ -1,33 +1,17 @@
"""SporeStack API supplemental models"""
import sys
from enum import Enum
from typing import Optional, Union
from typing import Union
if sys.version_info >= (3, 9): # pragma: nocover
from typing import Annotated
else: # pragma: nocover
from typing_extensions import Annotated
# Re-export Currency
from pydantic import BaseModel, Field
class Currency(str, Enum):
xmr = "xmr"
"""Monero"""
btc = "btc"
"""Bitcoin"""
bch = "bch"
"""Bitcoin Cash"""
class Payment(BaseModel):
"""This is deprecated in favor of Invoice."""
txid: Optional[str]
uri: Optional[str]
usd: str
paid: bool
from ._models import Currency as Currency
class Flavor(BaseModel):
@ -62,12 +46,12 @@ class OperatingSystem(BaseModel):
class TokenInfo(BaseModel):
balance_cents: int
balance_usd: str
burn_rate: int
"""Deprecated."""
burn_rate_cents: int
burn_rate_usd: str
days_remaining: int
servers: int
autorenew_servers: int
suspended_servers: int
class Region(BaseModel):
@ -78,7 +62,7 @@ class Region(BaseModel):
class Invoice(BaseModel):
id: int
id: str
payment_uri: Annotated[
str, Field(description="Cryptocurrency URI for the payment.")
]
@ -114,7 +98,7 @@ class Invoice(BaseModel):
description="TXID of the transaction for this payment, if it was paid.",
min_length=64,
max_length=64,
regex="^[a-f0-9]+$",
pattern="^[a-f0-9]+$",
),
]
expired: Annotated[
@ -137,7 +121,7 @@ class ServerUpdateRequest(BaseModel):
title="Hostname",
description="Hostname to refer to your server by.",
example="web-1",
regex="(^$|^[a-zA-Z0-9-_. ]+$)",
pattern="(^$|^[a-zA-Z0-9-_. ]+$)",
),
] = None
autorenew: Annotated[

View File

@ -81,7 +81,8 @@ def test_token_info(respx_mock: respx.MockRouter) -> None:
"balance_cents": 0,
"balance_usd": "$0.00",
"servers": 0,
"burn_rate": 0,
"autorenew_servers": 0,
"suspended_servers": 0,
"burn_rate_usd": "$0.00",
"burn_rate_cents": 0,
"days_remaining": 0,
@ -95,7 +96,6 @@ def test_token_info(respx_mock: respx.MockRouter) -> None:
info_response = client.token_info(dummy_token)
assert info_response.balance_cents == 0
assert info_response.balance_usd == "$0.00"
assert info_response.burn_rate == 0
assert info_response.burn_rate_cents == 0
assert info_response.burn_rate_usd == "$0.00"
assert info_response.servers == 0
@ -133,6 +133,7 @@ def test_server_info(respx_mock: respx.MockRouter) -> None:
"deleted_at": 0,
"deleted_by": None,
"forgotten_at": None,
"suspended_at": None,
"operating_system": "debian-11",
}
route_response = httpx.Response(200, json=response_json)
@ -145,7 +146,7 @@ def test_server_info(respx_mock: respx.MockRouter) -> None:
# These aren't exhaustive, but there's a number here.
assert info_response.machine_id == dummy_machine_id
assert info_response.hostname == response_json["hostname"]
assert info_response.flavor == response_json["flavor"]
assert info_response.flavor.dict() == response_json["flavor"]
assert info_response.token == response_json["token"]
assert info_response.running == response_json["running"]
assert info_response.created_at == response_json["created_at"]

17
tox.ini
View File

@ -1,18 +1,19 @@
[tox]
env_list =
py37
py38
py39
py310
py311
py{38,39,310,311}-pydantic{1,2}
[testenv]
deps =
pytest~=7.2
pytest-socket~=0.6.0
deps =
pydantic1: pydantic~=1.10
pydantic2: pydantic~=2.0
pytest~=8.0
pytest-socket~=0.7.0
pytest-cov~=4.0
pytest-mock~=3.6
respx~=0.20.1
segno
typer~=0.9.0
rich
commands =
pytest --cov=sporestack --cov-fail-under=39 --cov-report=term --durations=3
sporestack api-endpoint