Compare commits

..

No commits in common. "master" and "rewrite" have entirely different histories.

22 changed files with 1199 additions and 2506 deletions

2
.gitignore vendored
View File

@ -8,5 +8,3 @@ dist
__pycache__
.pytest_cache
.coverage
.tox
dummydotsporestackfolder

View File

@ -1,47 +1,54 @@
steps:
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
python-3.8:
group: test
image: python:3.8
commands:
- pip install pipenv==2023.12.1 tomli
- pip install pipenv==2022.10.25
- pipenv install --dev --deploy
- pipenv run almake test-typing
- pipenv run almake test-pytest
- pipenv run almake test
- pipenv run almake build-dist
- sha256sum dist/*
python-3.9:
group: test
image: python:3.9
commands:
- pip install pipenv==2023.12.1
- pip install pipenv==2022.10.25
- pipenv install --dev --deploy
- pipenv run almake test-typing
- pipenv run almake test-pytest
- pipenv run almake test
- pipenv run almake build-dist
- sha256sum dist/*
python-3.10:
group: test
image: python:3.10
commands:
- pip install pipenv==2023.12.1
- pip install pipenv==2022.10.25
- pipenv install --dev --deploy
- pipenv run almake test-typing
- pipenv run almake test-pytest
- pipenv run almake test
- pipenv run almake build-dist
- sha256sum dist/*
python-3.11:
group: test
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.12:
image: python:3.11
commands:
- pip install pipenv==2023.12.1
- pip install pipenv==2022.10.25
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake build-dist

View File

@ -5,212 +5,8 @@ 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 (12.X.X).
- `--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
- `sporestack server update-hostname` command.
## [10.5.0 - 2023-05-12]
## Changed
- Use fancy table output for `sporestack server list`.
## Added
- `sporestack token invoices` command.
## [10.4.0 - 2023-05-12]
## Changed
- `pip install sporestack[cli]` recommended if you wish to use CLI features. This will be required in version 11.
- Implement [Rich](https://github.com/Textualize/rich) for much prettier output on `token info`, `server regions`, `server flavors`, and `server operating-systems`. Other commands to follow.
## [10.3.0 - 2023-05-12]
## Added
- `regions` to `APIClient` and `Client`.
- `sporestack server regions` command.
## [10.2.0 - 2023-05-03]
## Changed
- Updated client to support new `forgotten_at` field and `deleted_by`.
## [10.1.2 - 2023-04-14]
## Fixed
- HTTP 4XX errors now raise a `SporeStackUserError` instead of `SporeStackServerError`.
## [10.1.1 - 2023-04-14]
## Added
- `burn_rate_cents` to `TokenInfo` to replace `burn_rate`.
- `burn_rate_usd` to `TokenInfo`.
## Changed
- `sporestack token info` will now show burn rate in dollar amount ($0.00) instead of cents.
## Fixed
- `sporestack server operating-systems` was updated to the new API behavior. (Unfortunately, was a breaking change.)
## [10.1.0 - 2023-04-14]
## Added
- `token_info()` to `APIClient`.
- `info()` to `Client.token`.
- `changelog()` to `APIClient`.
- `changelog()` to `Client`.
- `sporestack token info` command.
## Improved
- Improved some docstrings and help messages.
## [10.0.1 - 2023-04-13]
## Fixed
- Fixed critical issue on Python versions earlier than 3.10.
## [10.0.0 - 2023-04-12]
## Changed
- No more `retry` options in `api_client`. Use try/except for `SporeStackServerError`, instead, to retry on 500s.
- Exception messages may be improved.
## [9.1.1 - 2023-04-12]
### Changed
- Bug fix with `default_factory` issue.
## [9.1.0 - 2023-03-28]
### Added
- Token messages support.
- `deleted_at` field in Server Info respones.
### Changed
- Fixes to be compatible with API updates.
## [9.0.0 - 2023-02-08]
### Added
- `Client` added to `client`
- `/server/quote` support
- `--no-wait` option for `sporestack server launch` to not wait for an IP address to be assigned.
### Changed
- Now uses `httpx` instead of `requests`
## [8.0.0 - 2023-02-07]
### Changed
- `api_client` now exposes methods under APIClient()
- `client` added with Server and Token.
- CLI reworked some. `sporestack server info` now returns plain text info. `sporestack server json` returns info in JSON format.
## [7.3.0 - 2022-11-28]
### Fixed

View File

@ -1,18 +1,15 @@
format:
black .
ruff check --fix .
ruff --fix .
test:
black --check .
ruff check .
$(MAKE) test-typing
ruff .
python -m mypy --strict .
$(MAKE) test-pytest
test-typing:
mypy
test-pytest:
python -m pytest --cov=sporestack --cov-fail-under=39 --cov-report=term --durations=3 --cache-clear
python -m pytest --cov=sporestack --cov-fail-under=40 --cov-report=term --durations=3 --cache-clear
build-dist:
rm dist/* || true

19
Pipfile
View File

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

1322
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,51 +1,45 @@
# Python 3 library and CLI for [SporeStack](https://sporestack.com) ([SporeStack Tor Hidden Service](http://spore64i5sofqlfz5gq2ju4msgzojjwifls7rok2cti624zyq3fcelad.onion))
# Python 3 library and CLI for [SporeStack](https://sporestack.com) [.onion](http://spore64i5sofqlfz5gq2ju4msgzojjwifls7rok2cti624zyq3fcelad.onion)
[Changelog](CHANGELOG.md)
## Requirements
* Python 3.8-3.11 (and likely newer)
* Python 3.7-3.10 (or maybe newer)
## Installation
* `pip install sporestack`
* Recommended: Create a virtual environment, first, and use it inside there.
## Running without installing
* Make sure [pipx](https://pipx.pypya.io) is installed.
* `pipx run 'sporestack[cli]'`
* Make sure `pipx` is installed.
* `pipx run sporestack`
* 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/).
## Installation with pipx
## Usage
* 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 create --dollars 20 --currency xmr # Can use btc as well.`
* `sporestack token list`
* `sporestack token info`
* `sporestack server launch --hostname SomeHostname --operating-system debian-12 --days 1 # Will use ~/.ssh/id_rsa.pub as your SSH key, by default`
* `sporestack token balance`
* `sporestack server launch SomeHostname --operating-system debian-11 --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.`
* `sporestack server start --hostname SomeHostname`
* `sporestack server autorenew-enable --hostname SomeHostname`
* `sporestack server autorenew-disable --hostname SomeHostname`
* `sporestack server stop SomeHostname`
* `sporestack server start SomeHostname`
* `sporestack server list`
* `sporestack server delete --hostname SomeHostname`
* `sporestack server remove SomeHostname # If expired`
## Notes
* 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`.
* If you want to communicate with SporeStack APIs using Tor, set this environment variable: `SPORESTACK_USE_TOR_ENDPOINT=1`
## Developing
* `pip install pipenv pre-commit`
* `pre-commit install`
* `pipenv install --deploy --dev`
* `pipenv run make test`
* `pipenv run make format` to format files and apply ruff fixes.
* `pipenv run make test` (If you don't have `make`, use `almake`)
* `pre-commit run --all-files` (To format code, or wait for `git commit`)
## Licence

View File

@ -1,78 +0,0 @@
#!/bin/sh
# These are pretty hacky and need to be cleaned up, but serve a purpose.
# Set REAL_TESTING_TOKEN for more tests.
set -ex
export SPORESTACK_ENDPOINT=https://api.sporestack.com
# export SPORESTACK_ENDPOINT=http://127.0.0.1:8000
export SPORESTACK_DIR=$(pwd)/dummydotsporestackfolder
rm -r $SPORESTACK_DIR || true
mkdir $SPORESTACK_DIR
sporestack version
sporestack version | grep '[0-9]\.[0-9]\.[0-9]'
sporestack api-endpoint
sporestack api-endpoint | grep "$SPORESTACK_ENDPOINT"
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-12 --days 1 2>&1 | grep 'does not exist'
# Online tests start here.
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-12
sporestack server regions | grep sfo3
sporestack api-changelog
if [ -z "$REAL_TESTING_TOKEN" ]; then
rm -r $SPORESTACK_DIR
echo "REAL_TESTING_TOKEN not set, not finishing tests."
echo Success
exit 0
else
echo "REAL_TESTING_TOKEN is set, will continue testing."
fi
sporestack token import realtestingtoken --key "$REAL_TESTING_TOKEN"
sporestack token balance realtestingtoken | grep -F '$'
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-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
MACHINE_ID=$(sporestack server json --token realtestingtoken --hostname sporestackpythonintegrationtestdelme | jq -r .machine_id)
sporestack server autorenew-enable --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server autorenew-disable --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server update-hostname "$MACHINE_ID" --hostname "new" | grep sporestackpythonintegrationtestdelme
sporestack server update-hostname "$MACHINE_ID" --hostname ""
sporestack server update-hostname "$MACHINE_ID" --hostname "new again" | grep set
sporestack server update-hostname "$MACHINE_ID" --hostname sporestackpythonintegrationtestdelme
sporestack server start --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server stop --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
sporestack server rebuild --token realtestingtoken --hostname sporestackpythonintegrationtestdelme
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]
lint.select = [
select = [
"F", # pyflakes
"E", # pycodestyle errors
"W", # pycodestyle warnings
@ -13,18 +13,17 @@ lint.select = [
"UP", # pyupgrade
]
lint.ignore = [
ignore = [
"ANN101", # Type annotations for self
"ANN401", # Allow ANY
]
lint.unfixable = [
unfixable = [
"F401", # Don't try to automatically remove unused imports
"RUF100", # Unused noqa
"F841", # Unused variable
]
target-version = "py38"
target-version = "py37"
update-check = false
[tool.coverage.report]
show_missing = true
@ -48,20 +47,15 @@ warn_untyped_fields = true
name = "sporestack"
authors = [ {name = "SporeStack", email="support@sporestack.com"} ]
readme = "README.md"
requires-python = "~=3.8"
requires-python = "~=3.7"
dynamic = ["version", "description"]
keywords = ["bitcoin", "monero", "vps", "server"]
keywords = ["bitcoin", "monero", "vps"]
license = {file = "LICENSE.txt"}
dependencies = [
"pydantic>=1.10,<3",
"httpx[socks]",
]
[project.optional-dependencies]
cli = [
"pydantic",
"requests[socks]>=2.22.0",
"segno",
"typer>=0.9.0",
"rich",
"typer",
]
[project.urls]

View File

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

View File

@ -1,24 +0,0 @@
def cents_to_usd(cents: int) -> str:
"""cents_to_usd: Convert cents to USD string."""
return f"${cents * 0.01:,.2f}"
def mb_string(megabytes: int) -> str:
"""Returns a formatted string for megabytes."""
if megabytes < 1024:
return f"{megabytes} MiB"
return f"{megabytes // 1024} GiB"
def gb_string(gigabytes: int) -> str:
"""Returns a formatted string for gigabytes."""
if gigabytes < 1000:
return f"{gigabytes} GiB"
return f"{gigabytes / 1000} TiB"
def tb_string(terabytes: float) -> str:
"""Returns a formatted string for terabytes."""
return f"{terabytes} TiB"

View File

@ -1,11 +0,0 @@
# 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,18 +1,15 @@
"""SporeStack API request/response models"""
"""
import sys
from datetime import datetime
from enum import Enum
from typing import Dict, List, Optional, Union
SporeStack API request/response models
from pydantic import BaseModel, Field
"""
from .models import Currency, Flavor, Invoice, OperatingSystem, Region
if sys.version_info >= (3, 9): # pragma: nocover
from typing import Annotated
else: # pragma: nocover
from typing_extensions import Annotated
from typing import Dict, List, Optional
from pydantic import BaseModel
from .models import Flavor, NetworkInterface, Payment
class TokenAdd:
@ -20,12 +17,13 @@ class TokenAdd:
method = "POST"
class Request(BaseModel):
currency: Currency
currency: str
dollars: int
affiliate_token: Union[str, None] = None
affiliate_token: Optional[str] = None
class Response(BaseModel):
invoice: Invoice
token: str
payment: Payment
class TokenBalance:
@ -33,31 +31,11 @@ class TokenBalance:
method = "GET"
class Response(BaseModel):
token: str
cents: int
usd: str
class ServerQuote:
url = "/server/quote"
method = "GET"
"""Takes days and flavor as parameters."""
class Response(BaseModel):
cents: Annotated[
int, Field(ge=1, title="Cents", description="(US) cents", example=1_000_00)
]
usd: Annotated[
str,
Field(
min_length=5,
title="USD",
description="USD in $1,000.00 format",
example="$1,000.00",
),
]
class ServerLaunch:
url = "/server/{machine_id}/launch"
method = "POST"
@ -71,6 +49,8 @@ class ServerLaunch:
"""null is automatic, otherwise a string region slug."""
token: str
"""Token to draw from when launching the server."""
quote: bool = False
"""Don't launch, get a quote on how much it would cost"""
hostname: str = ""
"""Hostname to refer to your server by."""
autorenew: bool = False
@ -79,6 +59,17 @@ class ServerLaunch:
expiration.
"""
class Response(BaseModel):
payment: Payment
"""Deprecated, not needed when paying with token. Only used for quote."""
expiration: int
machine_id: str
created_at: int = 0
created: bool = False
paid: bool = False
"""Deprecated, not needed when paying with token."""
warning: Optional[str] = None
class ServerTopup:
url = "/server/{machine_id}/topup"
@ -86,16 +77,14 @@ class ServerTopup:
class Request(BaseModel):
days: int
token: Union[str, None] = None
token: str
class ServerDeletedBy(str, Enum):
EXPIRATION = "expiration"
"""The server was deleted automatically for being expired."""
MANUAL = "manual"
"""The server was deleted before its expiration via the API."""
SPORESTACK = "sporestack"
"""The server was deleted by SporeStack, likely due to an AUP violation."""
class Response(BaseModel):
machine_id: str
expiration: int
paid: bool = True
"""Deprecated, not needed when paying with token."""
warning: Optional[str] = None
class ServerInfo:
@ -112,10 +101,9 @@ class ServerInfo:
ipv6: str
region: str
flavor: Flavor
deleted_at: int
deleted_by: Union[ServerDeletedBy, None]
forgotten_at: Union[datetime, None]
suspended_at: Union[datetime, None]
deleted: bool
network_interfaces: List[NetworkInterface]
"""Deprecated, use ipv4/ipv6 instead."""
operating_system: str
hostname: str
autorenew: bool
@ -132,8 +120,13 @@ class ServerStop:
class ServerDelete:
url = "/server/{machine_id}"
method = "DELETE"
url = "/server/{machine_id}/delete"
method = "POST"
class ServerDestroy:
url = "/server/{machine_id}/destroy"
method = "POST"
class ServerForget:
@ -177,38 +170,4 @@ class OperatingSystems:
method = "GET"
class Response(BaseModel):
operating_systems: Dict[str, OperatingSystem]
class Regions:
url = "/regions"
method = "GET"
class Response(BaseModel):
regions: Dict[str, Region]
class TokenMessageSender(str, Enum):
USER = "User"
SPORESTACK = "SporeStack"
class TokenMessage(BaseModel):
message: Annotated[
str,
Field(
title="Message",
min_length=1,
max_length=10_000,
),
]
sent_at: Annotated[
datetime,
Field(
title="Sent At",
description="When the message was sent.",
),
]
sender: Annotated[
TokenMessageSender, Field(title="Sender", description="Who sent the message.")
]
operating_systems: List[str]

View File

@ -1,18 +1,16 @@
import logging
import os
from dataclasses import dataclass
from typing import List, Optional, Union
from time import sleep
from typing import Any, Dict, Optional
import httpx
from pydantic import parse_obj_as
import requests
from . import __version__, api, exceptions
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 = (
@ -21,16 +19,19 @@ TOR_ENDPOINT = (
API_ENDPOINT = CLEARNET_ENDPOINT
TIMEOUT = httpx.Timeout(60.0)
GET_TIMEOUT = 60
POST_TIMEOUT = 90
USE_TOR_PROXY = "auto"
HEADERS = {"User-Agent": f"sporestack-python/{__version__}"}
session = requests.Session()
def _get_tor_proxy() -> str:
"""
This makes testing easier.
"""
return os.getenv("TOR_PROXY", "socks5://127.0.0.1:9050")
return os.getenv("TOR_PROXY", "socks5h://127.0.0.1:9050")
# For requests module
@ -58,53 +59,82 @@ def _is_onion_url(url: str) -> bool:
return False
def _get_response_error_text(response: httpx.Response) -> str:
"""Get a response's error text. Assumes the response is actually an error."""
if (
"content-type" in response.headers
and response.headers["content-type"] == "application/json"
):
error = response.json()
if "detail" in error:
if isinstance(error["detail"], str):
return error["detail"]
else:
return str(error["detail"])
def _api_request(
url: str,
empty_post: bool = False,
json_params: Optional[Dict[str, Any]] = None,
retry: bool = False,
) -> Any:
headers = {"User-Agent": f"sporestack-python/{__version__}"}
proxies = {}
if _is_onion_url(url) is True:
log.debug("Got a .onion API endpoint, using local Tor SOCKS proxy.")
proxies = TOR_PROXY_REQUESTS
return response.text
try:
if empty_post is True:
request = session.post(
url, timeout=POST_TIMEOUT, proxies=proxies, headers=headers
)
elif json_params is None:
request = session.get(
url, timeout=GET_TIMEOUT, proxies=proxies, headers=headers
)
else:
request = session.post(
url,
json=json_params,
timeout=POST_TIMEOUT,
proxies=proxies,
headers=headers,
)
except Exception as e:
if retry is True:
log.warning(f"Got an error, but retrying: {e}")
sleep(5)
# Try again.
return _api_request(
url,
empty_post=empty_post,
json_params=json_params,
retry=retry,
)
else:
raise
def _handle_response(response: httpx.Response) -> None:
status_code_first_digit = response.status_code // 100
status_code_first_digit = request.status_code // 100
if status_code_first_digit == 2:
return
error_response_text = _get_response_error_text(response)
if response.status_code == 429:
raise exceptions.SporeStackTooManyRequestsError(error_response_text)
try:
return request.json()
except Exception:
return request.content
elif status_code_first_digit == 4:
raise exceptions.SporeStackUserError(error_response_text)
log.debug("HTTP status code: {request.status_code}")
raise exceptions.SporeStackUserError(request.content.decode("utf-8"))
elif status_code_first_digit == 5:
# User should probably retry.
raise exceptions.SporeStackServerError(error_response_text)
if retry is True:
log.warning(request.content.decode("utf-8"))
log.warning("Got a 500, retrying in 5 seconds...")
sleep(5)
# Try again if we get a 500
return _api_request(
url,
empty_post=empty_post,
json_params=json_params,
retry=retry,
)
else:
raise exceptions.SporeStackServerError(str(request.content))
else:
# This would be weird.
raise exceptions.SporeStackServerError(error_response_text)
# Not sure why we'd get this.
request.raise_for_status()
raise Exception("Stuff broke strangely. Please contact SporeStack support.")
@dataclass
class APIClient:
api_endpoint: str = API_ENDPOINT
def __post_init__(self) -> None:
headers = httpx.Headers(HEADERS)
proxy = None
if _is_onion_url(self.api_endpoint):
proxy = _get_tor_proxy()
self._httpx_client = httpx.Client(
headers=headers, proxies=proxy, timeout=TIMEOUT
)
def server_launch(
self,
machine_id: str,
@ -114,10 +144,10 @@ class APIClient:
ssh_key: str,
token: str,
region: Optional[str] = None,
quote: bool = False,
hostname: str = "",
autorenew: bool = False,
) -> None:
"""Launch a server."""
) -> api.ServerLaunch.Response:
request = api.ServerLaunch.Request(
days=days,
token=token,
@ -125,75 +155,77 @@ class APIClient:
region=region,
operating_system=operating_system,
ssh_key=ssh_key,
quote=quote,
hostname=hostname,
autorenew=autorenew,
)
url = self.api_endpoint + api.ServerLaunch.url.format(machine_id=machine_id)
response = self._httpx_client.post(url=url, json=request.dict())
_handle_response(response)
response = _api_request(url=url, json_params=request.dict())
response_object = api.ServerLaunch.Response.parse_obj(response)
assert response_object.machine_id == machine_id
return response_object
def server_topup(
self,
machine_id: str,
days: int,
token: Union[str, None] = None,
) -> None:
"""Topup a server."""
token: str,
) -> api.ServerTopup.Response:
"""
Topup a server.
"""
request = api.ServerTopup.Request(days=days, token=token)
url = self.api_endpoint + api.ServerTopup.url.format(machine_id=machine_id)
response = self._httpx_client.post(url=url, json=request.dict())
_handle_response(response)
def server_quote(self, days: int, flavor: str) -> api.ServerQuote.Response:
"""Get a quote for how much a server will cost."""
url = self.api_endpoint + api.ServerQuote.url
response = self._httpx_client.get(
url,
params={"days": days, "flavor": flavor},
)
_handle_response(response)
return api.ServerQuote.Response.parse_obj(response.json())
response = _api_request(url=url, json_params=request.dict())
response_object = api.ServerTopup.Response.parse_obj(response)
assert response_object.machine_id == machine_id
return response_object
def autorenew_enable(self, machine_id: str) -> None:
"""Enable autorenew on a server."""
"""
Enable autorenew on a server.
"""
url = self.api_endpoint + api.ServerEnableAutorenew.url.format(
machine_id=machine_id
)
response = self._httpx_client.post(url)
_handle_response(response)
_api_request(url, empty_post=True)
def autorenew_disable(self, machine_id: str) -> None:
"""Disable autorenew on a server."""
"""
Disable autorenew on a server.
"""
url = self.api_endpoint + api.ServerDisableAutorenew.url.format(
machine_id=machine_id
)
response = self._httpx_client.post(url)
_handle_response(response)
_api_request(url, empty_post=True)
def server_start(self, machine_id: str) -> None:
"""Power on a server."""
"""
Power on the server.
"""
url = self.api_endpoint + api.ServerStart.url.format(machine_id=machine_id)
response = self._httpx_client.post(url)
_handle_response(response)
_api_request(url, empty_post=True)
def server_stop(self, machine_id: str) -> None:
"""Power off a server."""
"""
Power off the server.
"""
url = self.api_endpoint + api.ServerStop.url.format(machine_id=machine_id)
response = self._httpx_client.post(url)
_handle_response(response)
_api_request(url, empty_post=True)
def server_delete(self, machine_id: str) -> None:
"""Delete a server."""
"""
Delete the server.
"""
url = self.api_endpoint + api.ServerDelete.url.format(machine_id=machine_id)
response = self._httpx_client.delete(url)
_handle_response(response)
_api_request(url, empty_post=True)
def server_forget(self, machine_id: str) -> None:
"""Forget about a deleted server to hide it from view."""
"""
Forget about a destroyed/deleted server.
"""
url = self.api_endpoint + api.ServerForget.url.format(machine_id=machine_id)
response = self._httpx_client.post(url)
_handle_response(response)
_api_request(url, empty_post=True)
def server_rebuild(self, machine_id: str) -> None:
"""
@ -202,29 +234,18 @@ class APIClient:
Deletes all of the data on the server!
"""
url = self.api_endpoint + api.ServerRebuild.url.format(machine_id=machine_id)
response = self._httpx_client.post(url)
_handle_response(response)
_api_request(url, empty_post=True)
def server_info(self, machine_id: str) -> api.ServerInfo.Response:
"""Returns info about the server."""
"""
Returns info about the server.
"""
url = self.api_endpoint + api.ServerInfo.url.format(machine_id=machine_id)
response = self._httpx_client.get(url)
_handle_response(response)
response_object = api.ServerInfo.Response.parse_obj(response.json())
response = _api_request(url)
response_object = api.ServerInfo.Response.parse_obj(response)
assert response_object.machine_id == machine_id
return response_object
def server_update(
self,
machine_id: str,
hostname: Union[str, None] = None,
autorenew: Union[bool, None] = None,
) -> None:
"""Update server settings."""
request = ServerUpdateRequest(hostname=hostname, autorenew=autorenew)
url = self.api_endpoint + f"/server/{machine_id}"
response = self._httpx_client.patch(url=url, json=request.dict())
_handle_response(response)
def servers_launched_from_token(
self, token: str
) -> api.ServersLaunchedFromToken.Response:
@ -232,100 +253,45 @@ class APIClient:
Returns info of servers launched from a given token.
"""
url = self.api_endpoint + api.ServersLaunchedFromToken.url.format(token=token)
response = self._httpx_client.get(url)
_handle_response(response)
response_object = api.ServersLaunchedFromToken.Response.parse_obj(
response.json()
)
response = _api_request(url)
response_object = api.ServersLaunchedFromToken.Response.parse_obj(response)
return response_object
def flavors(self) -> api.Flavors.Response:
"""Returns available flavors (server sizes)."""
"""
Returns available flavors.
"""
url = self.api_endpoint + api.Flavors.url
response = self._httpx_client.get(url)
_handle_response(response)
response_object = api.Flavors.Response.parse_obj(response.json())
response = _api_request(url)
response_object = api.Flavors.Response.parse_obj(response)
return response_object
def operating_systems(self) -> api.OperatingSystems.Response:
"""Returns available operating systems."""
"""
Returns available operating systems.
"""
url = self.api_endpoint + api.OperatingSystems.url
response = self._httpx_client.get(url)
_handle_response(response)
response_object = api.OperatingSystems.Response.parse_obj(response.json())
response = _api_request(url)
response_object = api.OperatingSystems.Response.parse_obj(response)
return response_object
def regions(self) -> api.Regions.Response:
"""Returns regions that you can launch a server in."""
url = self.api_endpoint + api.Regions.url
response = self._httpx_client.get(url)
_handle_response(response)
response_object = api.Regions.Response.parse_obj(response.json())
return response_object
def changelog(self) -> str:
"""Returns the API changelog."""
url = self.api_endpoint + "/changelog"
response = self._httpx_client.get(url)
_handle_response(response)
return response.text
def token_add(
self,
token: str,
dollars: int,
currency: Currency,
currency: str,
retry: bool = False,
) -> 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)
response = self._httpx_client.post(url, json=request.dict())
_handle_response(response)
response_object = api.TokenAdd.Response.parse_obj(response.json())
url = self.api_endpoint + api.TokenAdd.url.format(token=token)
response = _api_request(url=url, json_params=request.dict(), retry=retry)
response_object = api.TokenAdd.Response.parse_obj(response)
assert response_object.token == token
return response_object
def token_balance(self, token: str) -> api.TokenBalance.Response:
"""Return a token's balance."""
url = self.api_endpoint + api.TokenBalance.url.format(token=token)
response = self._httpx_client.get(url)
_handle_response(response)
response_object = api.TokenBalance.Response.parse_obj(response.json())
response = _api_request(url=url)
response_object = api.TokenBalance.Response.parse_obj(response)
assert response_object.token == token
return response_object
def token_info(self, token: str) -> TokenInfo:
"""Return information about a token, including balance."""
url = self.api_endpoint + f"/token/{token}/info"
response = self._httpx_client.get(url)
_handle_response(response)
response_object = TokenInfo.parse_obj(response.json())
return response_object
def token_get_messages(self, token: str) -> List[api.TokenMessage]:
"""Get messages for/from the token."""
url = self.api_endpoint + f"/token/{token}/messages"
response = self._httpx_client.get(url=url)
_handle_response(response)
return parse_obj_as(List[api.TokenMessage], response.json())
def token_send_message(self, token: str, message: str) -> None:
"""Send a message to SporeStack support."""
url = self.api_endpoint + f"/token/{token}/messages"
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"
response = self._httpx_client.get(url=url)
_handle_response(response)
return parse_obj_as(List[Invoice], response.json())

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +1,27 @@
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import List, Union
from . import api
from .api_client import APIClient
from .models import Currency, Invoice, TokenInfo
from .utils import random_machine_id, random_token
@dataclass
class Server:
machine_id: str
api_client: APIClient = field(default_factory=APIClient)
api_client: APIClient = APIClient()
token: Union[str, None] = None
def info(self) -> api.ServerInfo.Response:
"""Returns information about the server."""
return self.api_client.server_info(self.machine_id)
def rebuild(self) -> None:
"""Delete all data on the server and reinstall it."""
self.api_client.server_rebuild(self.machine_id)
def forget(self) -> None:
"""Forget about the server so it doesn't show up when listing servers."""
self.api_client.server_forget(self.machine_id)
def delete(self) -> None:
"""Delete the server."""
self.api_client.server_delete(self.machine_id)
def start(self) -> None:
@ -39,24 +34,15 @@ class Server:
def autorenew_enable(self) -> None:
"""Enables autorenew on the server."""
self.api_client.server_update(self.machine_id, autorenew=True)
self.api_client.autorenew_enable(self.machine_id)
def autorenew_disable(self) -> None:
"""Disables autorenew on the server."""
self.api_client.server_update(self.machine_id, autorenew=False)
def update(
self, hostname: Union[str, None] = None, autorenew: Union[bool, None] = None
) -> None:
"""Update details about a server."""
self.api_client.server_update(
self.machine_id, hostname=hostname, autorenew=autorenew
)
self.api_client.autorenew_disable(self.machine_id)
def topup(self, days: int) -> None:
"""
Renew the server for the amount of days specified, from the token that
launched the server.
Renew the server for the amount of days specified, from the token specified.
"""
if self.token is None:
raise ValueError("token must be set to top up a server!")
@ -67,49 +53,20 @@ class Server:
@dataclass
class 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."""
token: str = random_token()
api_client: APIClient = APIClient()
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 add(self, dollars: int, currency: str) -> None:
"""Add to token"""
self.api_client.token_add(token=self.token, dollars=dollars, currency=currency)
def balance(self) -> int:
"""Returns the token's balance in cents."""
return self.api_client.token_balance(token=self.token).cents
def info(self) -> TokenInfo:
"""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)
def messages(self) -> List[api.TokenMessage]:
"""Returns support messages for/from the token."""
return self.api_client.token_get_messages(token=self.token)
def send_message(self, message: str) -> None:
"""Returns support messages for/from the token."""
self.api_client.token_send_message(token=self.token, message=message)
def servers(self, show_forgotten: bool = False) -> List[Server]:
def servers(self) -> List[Server]:
server_classes: List[Server] = []
for server in self.api_client.servers_launched_from_token(self.token).servers:
if not show_forgotten and server.forgotten_at is not None:
continue
server_classes.append(
Server(
machine_id=server.machine_id,
@ -121,20 +78,15 @@ 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,
@ -149,40 +101,3 @@ class Token:
return Server(
machine_id=machine_id, api_client=self.api_client, token=self.token
)
@dataclass
class Client:
client_token: str = ""
"""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)."""
return self.api_client.flavors()
def operating_systems(self) -> api.OperatingSystems.Response:
"""Returns available operating systems."""
return self.api_client.operating_systems()
def regions(self) -> api.Regions.Response:
"""Returns regions that servers can be launched in."""
return self.api_client.regions()
def server_quote(self, days: int, flavor: str) -> api.ServerQuote.Response:
"""Get a quote for how much a server will cost."""
return self.api_client.server_quote(days=days, flavor=flavor)
def changelog(self) -> str:
"""Read the API changeog."""
return self.api_client.changelog()
@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, ssh_key=self.ssh_key
)

View File

@ -8,12 +8,6 @@ class SporeStackUserError(SporeStackError):
pass
class SporeStackTooManyRequestsError(SporeStackError):
"""HTTP 429, retry again later"""
pass
class SporeStackServerError(SporeStackError):
"""HTTP 5XX"""

View File

@ -1,17 +1,25 @@
"""SporeStack API supplemental models"""
"""
import sys
from typing import Union
SporeStack API supplemental models
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
from ._models import Currency as Currency
from typing import Optional
from pydantic import BaseModel
class NetworkInterface(BaseModel):
ipv4: str
ipv6: str
class Payment(BaseModel):
txid: Optional[str]
uri: Optional[str]
usd: str
paid: bool
class Flavor(BaseModel):
@ -29,109 +37,5 @@ class Flavor(BaseModel):
ipv4: str
# IPv6 connectivity: "/128"
ipv6: str
"""Gigabytes of bandwidth per day."""
bandwidth_per_month: float
"""Gigabytes of bandwidth per month."""
class OperatingSystem(BaseModel):
slug: str
"""Unique string to identify the operating system."""
minimum_disk: int
"""Minimum disk storage required in GiB"""
provider_slug: str
"""Unique string to identify the operating system."""
class TokenInfo(BaseModel):
balance_cents: int
balance_usd: str
burn_rate_cents: int
burn_rate_usd: str
days_remaining: int
servers: int
autorenew_servers: int
suspended_servers: int
class Region(BaseModel):
# Unique string to identify the region that's sort of human readable.
slug: str
# Actually human readable string describing the region.
name: str
class Invoice(BaseModel):
id: str
payment_uri: Annotated[
str, Field(description="Cryptocurrency URI for the payment.")
]
cryptocurrency: Annotated[
Currency,
Field(description="Cryptocurrency that will be used to pay this invoice."),
]
amount: Annotated[
int,
Field(
description="Amount of cents to add to the token if this invoice is paid."
),
]
fiat_per_coin: Annotated[
str,
Field(
description="Stringified float of the price when this was made.",
example="100.00",
),
]
created: Annotated[
int, Field(description="Timestamp of when this invoice was created.")
]
expires: Annotated[
int, Field(description="Timestamp of when this invoice will expire.")
]
paid: Annotated[
int, Field(description="Timestamp of when this invoice was paid. 0 if unpaid.")
]
txid: Annotated[
Union[str, None],
Field(
description="TXID of the transaction for this payment, if it was paid.",
min_length=64,
max_length=64,
pattern="^[a-f0-9]+$",
),
]
expired: Annotated[
bool,
Field(
description=(
"Whether or not the invoice has expired (only applicable if "
"unpaid, or payment not yet confirmed."
),
),
]
class ServerUpdateRequest(BaseModel):
hostname: Annotated[
Union[str, None],
Field(
min_length=0,
max_length=128,
title="Hostname",
description="Hostname to refer to your server by.",
example="web-1",
pattern="(^$|^[a-zA-Z0-9-_. ]+$)",
),
] = None
autorenew: Annotated[
Union[bool, None],
Field(
title="Autorenew",
description=(
"Automatically renew the server from the token, "
"keeping it at 1 week expiration."
),
example=True,
),
] = None
# Gigabytes of bandwidth per day
bandwidth: int

View File

@ -1,9 +1,4 @@
import httpx
import pytest
import respx
from sporestack import api_client, exceptions
# respx seems to ignore the uri://domain if you don't specify it.
from sporestack import api_client
def test__is_onion_url() -> None:
@ -18,142 +13,3 @@ def test__is_onion_url() -> None:
assert api_client._is_onion_url("http://onion.domain.com/.onion/") is False
assert api_client._is_onion_url("http://me.me/file.onion/") is False
assert api_client._is_onion_url("http://me.me/file.onion") is False
def test_get_response_error_text() -> None:
assert (
api_client._get_response_error_text(
httpx.Response(status_code=422, text="just text")
)
== "just text"
)
assert (
api_client._get_response_error_text(
httpx.Response(status_code=422, json={"detail": "detail text"})
)
== "detail text"
)
# This may not be the best behavior overall.
assert (
api_client._get_response_error_text(
httpx.Response(status_code=422, json={"detail": {"msg": "nested message"}})
)
== "{'msg': 'nested message'}"
)
def test_handle_response() -> None:
with pytest.raises(exceptions.SporeStackServerError, match="What is this?"):
api_client._handle_response(
httpx.Response(status_code=100, text="What is this?")
)
api_client._handle_response(httpx.Response(status_code=200))
api_client._handle_response(httpx.Response(status_code=201))
api_client._handle_response(httpx.Response(status_code=204))
with pytest.raises(exceptions.SporeStackUserError, match="Invalid arguments"):
api_client._handle_response(
httpx.Response(status_code=400, text="Invalid arguments")
)
with pytest.raises(exceptions.SporeStackUserError, match="Invalid arguments"):
api_client._handle_response(
httpx.Response(status_code=422, text="Invalid arguments")
)
with pytest.raises(
exceptions.SporeStackTooManyRequestsError, match="Too many requests"
):
api_client._handle_response(
httpx.Response(status_code=429, text="Too many requests")
)
with pytest.raises(exceptions.SporeStackServerError, match="Try again"):
api_client._handle_response(httpx.Response(status_code=500, text="Try again"))
def test_token_info(respx_mock: respx.MockRouter) -> None:
dummy_token = "dummyinvalidtoken"
response_json = {
"balance_cents": 0,
"balance_usd": "$0.00",
"servers": 0,
"autorenew_servers": 0,
"suspended_servers": 0,
"burn_rate_usd": "$0.00",
"burn_rate_cents": 0,
"days_remaining": 0,
}
route_response = httpx.Response(200, json=response_json)
route = respx_mock.get(f"/token/{dummy_token}/info").mock(
return_value=route_response
)
client = api_client.APIClient()
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_cents == 0
assert info_response.burn_rate_usd == "$0.00"
assert info_response.servers == 0
assert info_response.days_remaining == 0
assert route.called
def test_server_info(respx_mock: respx.MockRouter) -> None:
dummy_machine_id = "dummyinvalidmachineid"
flavor = {
"slug": "a flavor slug",
"cores": 1,
"memory": 1024,
"disk": 25,
"price": 38,
"ipv4": "/32",
"ipv6": "/128",
"bandwidth_per_month": 1.0,
}
response_json = {
"machine_id": dummy_machine_id,
"hostname": "a hostname",
"flavor": flavor,
"region": "a region",
"token": "a token",
"running": True,
"created_at": 1,
"expiration": 2,
"autorenew": False,
"ipv4": "0.0.0.0",
"ipv6": "::0",
"deleted": False,
"deleted_at": 0,
"deleted_by": None,
"forgotten_at": None,
"suspended_at": None,
"operating_system": "debian-11",
}
route_response = httpx.Response(200, json=response_json)
route = respx_mock.get(f"/server/{dummy_machine_id}/info").mock(
return_value=route_response
)
client = api_client.APIClient()
info_response = client.server_info(dummy_machine_id)
# 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.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"]
assert info_response.expiration == response_json["expiration"]
assert info_response.autorenew == response_json["autorenew"]
assert info_response.forgotten_at == response_json["forgotten_at"]
# Not sure why mypy dislikes this. It passes pytest.
assert info_response.flavor.slug == response_json["flavor"]["slug"] # type: ignore
assert route.called

View File

@ -36,12 +36,12 @@ def test_cli_api_endpoint(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setenv("SPORESTACK_USE_TOR_ENDPOINT", "1")
result = runner.invoke(cli.cli, ["api-endpoint"])
assert result.output == TOR_ENDPOINT + " using socks5://127.0.0.1:9050\n"
assert result.output == TOR_ENDPOINT + " using socks5h://127.0.0.1:9050\n"
assert result.exit_code == 0
monkeypatch.setenv("TOR_PROXY", "socks5://127.0.0.1:1337")
monkeypatch.setenv("TOR_PROXY", "socks5h://127.0.0.1:1337")
result = runner.invoke(cli.cli, ["api-endpoint"])
assert result.output == TOR_ENDPOINT + " using socks5://127.0.0.1:1337\n"
assert result.output == TOR_ENDPOINT + " using socks5h://127.0.0.1:1337\n"
assert result.exit_code == 0

View File

@ -1,18 +0,0 @@
from sporestack.api_client import APIClient
from sporestack.client import Client, Server, Token
def test_client() -> None:
client = Client()
assert isinstance(client.api_client, APIClient)
def test_server() -> None:
server = Server(machine_id="foobar")
assert isinstance(server.api_client, APIClient)
def test_token() -> None:
token = Token()
assert token.token.startswith("ss_t_")
assert isinstance(token.api_client, APIClient)

19
tox.ini
View File

@ -1,19 +0,0 @@
[tox]
env_list =
py{38,39,310,311}-pydantic{1,2}
[testenv]
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