Compare commits

...

46 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
Administrator 5637790af5 v10.6.0: Add `sporestack server update-server` command.
Also improve test coverage slightly.
2023-05-25 16:24:08 +00:00
Administrator 16c790728d v10.5.0: Added `sporestack token invoices` command 2023-05-12 20:05:38 +00:00
Administrator 5ff095af3f v10.4.0: Implement Rich for prettier CLI output 2023-05-12 16:29:12 +00:00
Administrator 4311d86658 v10.3.0: Add `sporestack server regions` command 2023-05-12 14:27:13 +00:00
Administrator 9acfc88e2a pipenv update --dev 2023-05-04 16:40:10 +00:00
Administrator c5c4d9797d v10.2.0: Support new deleted_by and forgotten_at fields for servers 2023-05-03 23:24:18 +00:00
Administrator 7c6f57068d Minor fix for integration-test.sh 2023-04-27 22:18:18 +00:00
Administrator e2c90e3ae4 Minor integration-test.sh improvements 2023-04-27 22:15:38 +00:00
Administrator 1b71618d31 v10.1.2: Fix exception handling for HTTP 4XX responses 2023-04-14 23:02:04 +00:00
Administrator 65cdc9ada7 Add respx for better testing, fix HTTP 4XX handling 2023-04-14 22:45:43 +00:00
Administrator a0864e413a v10.1.1: Fixed `sporestack server operating-systems` 2023-04-14 21:57:08 +00:00
Administrator 834b1e1e33 v10.1.0: Added `sporestack token info` command, improved docstrings/help 2023-04-14 15:14:57 +00:00
Administrator 095fe103cb v10.0.1: Fix critical syntax issue on Python 3.9 and earlier 2023-04-13 19:33:40 +00:00
Administrator ba202eca05 v10.0.0: Cleanups/refactor
Added integration-test.sh
2023-04-13 00:01:33 +00:00
Administrator 6dc1c8acd5 Add tox.ini to test with multiple Python versions 2023-04-12 01:31:00 +00:00
Administrator 8b04220c9f Better test coverage 2023-04-12 00:40:45 +00:00
Administrator 07ef8df8ba 9.1.1: default_factory bugfix 2023-04-12 00:36:08 +00:00
Administrator 6c83286340 Upgrade dev dependencies 2023-04-12 00:13:47 +00:00
Administrator f70a1f65b0 9.1.0: Messaging and fixes for API changes 2023-03-29 01:51:06 +00:00
Administrator b4705c3634 Add token messages support 2023-03-15 04:06:30 +00:00
Administrator 8e00f28940 v9.0.0: Use httpx, /server/quote support, Client support 2023-02-08 21:06:24 +00:00
Administrator 7a6c09dad6 README improvements 2023-02-07 19:42:49 +00:00
Administrator b832c0045d v8.0.0 2023-02-07 19:38:47 +00:00
Spore d862f540ba Merge pull request 'Make APIClient, Token, and Server' (#1) from rewrite into master
Reviewed-on: #1
2023-02-07 19:35:17 +00:00
Administrator 6981ff00c7 Rewrite finished, for now 2023-02-07 19:30:24 +00:00
Administrator a947d83669 Progress + replace pre-commit with black+ruff 2023-02-07 18:22:20 +00:00
Administrator ad6fb4ce2e WIP: Make APIClient, Token, and Server
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/pr/woodpecker Pipeline failed Details
2022-11-29 17:01:01 +00:00
Administrator e8a720e564 Attempt CI fix
ci/woodpecker/push/woodpecker Pipeline is pending Details
2022-11-28 16:49:50 +00:00
23 changed files with 2858 additions and 1310 deletions

2
.gitignore vendored
View File

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

View File

@ -1,30 +0,0 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: db7346d375eda68a0174f2c057dd97f2fbffe030 # frozen: v4.2.0
hooks:
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: ae2c0758c9e61a385df9700dc9c231bf54887041 # frozen: 22.3.0
hooks:
- id: black
- repo: https://github.com/PyCQA/isort
rev: c5e8fa75dda5f764d20f66a215d71c21cfa198e1 # frozen: 5.10.1
hooks:
- id: isort
- repo: https://github.com/myint/autoflake
rev: 7a53fdafc82c33f446915b60fcac947c51279260 # frozen: v1.4
hooks:
- id: autoflake
- repo: https://github.com/asottile/pyupgrade
rev: 256bd84aa5a17edbd3dcfaaa4f30f870168d2838 # frozen: v2.32.0
hooks:
- id: pyupgrade
args: [--py37-plus]
- repo: https://github.com/jackdewinter/pymarkdown
rev: be56696256d5491e8a907b72e5a3852034546adb # frozen: v0.9.5
hooks:
- id: pymarkdown
args: [--disable-rules=MD013, --set=plugins.md024.siblings_only=$!True, scan]

View File

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

View File

@ -5,8 +5,212 @@ 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/), 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). 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] ## [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] ## [7.3.0 - 2022-11-28]
### Fixed ### Fixed

View File

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

27
Pipfile
View File

@ -7,24 +7,25 @@ name = "pypi"
sporestack = {editable = true, path = "."} sporestack = {editable = true, path = "."}
[dev-packages] [dev-packages]
flake8 = "~=4.0" black = "~=24.0"
pyproject-flake8 = "==0.0.1a2" mypy = "~=1.0"
flake8-noqa = "~=1.2" pytest = "~=8.0"
pep8-naming = "~=0.12.1" pytest-cov = "~=4.0"
mypy = "==0.942" pytest-mock = "~=3.6"
pytest = "~=6.2" pytest-socket = "~=0.7.0"
pytest-cov = "~=3.0" ruff = "~=0.3.4"
types-requests = "~=2.25" respx = "~=0.20.1"
# Building # Building
wheel = "~=0.37.0" flit = "~=3.8"
build = "~=0.7.0" wheel = "*"
build = "~=1.0"
# Publishing # Publishing
twine = "~=3.4" twine = "~=5.0"
# Docs # Docs
pdoc = "~=9.0" pdoc = "~=14.0"
# Python `make` implementation # Python `make` implementation
almost-make = "~=0.5.1" almost-make = "~=0.5.2"

1302
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,45 +1,51 @@
# 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) [Changelog](CHANGELOG.md)
## Requirements ## Requirements
* Python 3.7-3.10 (or maybe newer) * Python 3.8-3.11 (and likely newer)
## Installation
* `pip install sporestack`
* Recommended: Create a virtual environment, first, and use it inside there.
## Running without installing ## Running without installing
* Make sure `pipx` is installed. * Make sure [pipx](https://pipx.pypya.io) is installed.
* `pipx run sporestack` * `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 list`
* `sporestack token balance` * `sporestack token info`
* `sporestack server launch 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`.) (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 SomeHostname` * `sporestack server stop --hostname SomeHostname`
* `sporestack server start 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 list` * `sporestack server list`
* `sporestack server remove SomeHostname # If expired` * `sporestack server delete --hostname SomeHostname`
## Notes ## 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 ## Developing
* `pip install pipenv pre-commit`
* `pre-commit install`
* `pipenv install --deploy --dev` * `pipenv install --deploy --dev`
* `pipenv run make test` (If you don't have `make`, use `almake`) * `pipenv run make test`
* `pre-commit run --all-files` (To format code, or wait for `git commit`) * `pipenv run make format` to format files and apply ruff fixes.
## Licence ## Licence

78
integration-test.sh Executable file
View File

@ -0,0 +1,78 @@
#!/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

@ -1,23 +1,42 @@
[tool.pytest.ini_options]
addopts = "--strict-markers --disable-socket"
[tool.ruff]
lint.select = [
"F", # pyflakes
"E", # pycodestyle errors
"W", # pycodestyle warnings
"I", # isort
"N", # pep8-naming
"RUF", # Unused noqa + more
"ANN", # Type annotations
"UP", # pyupgrade
]
lint.ignore = [
"ANN101", # Type annotations for self
"ANN401", # Allow ANY
]
lint.unfixable = [
"F401", # Don't try to automatically remove unused imports
"RUF100", # Unused noqa
"F841", # Unused variable
]
target-version = "py38"
[tool.coverage.report] [tool.coverage.report]
show_missing = true show_missing = true
[tool.coverage.run] [tool.coverage.run]
omit = ["tests/*", "build/*"] omit = ["tests/*", "build/*"]
# Have to use `pflake8` instead of `flake8`
[tool.flake8]
max-line-length = 88
noqa-require-code = "true"
exclude = ".git,__pycache__,build,dist"
max-complexity = 15
[tool.isort]
profile = "black"
[tool.mypy] [tool.mypy]
files = "." files = "."
plugins = ["pydantic.mypy"] plugins = ["pydantic.mypy"]
exclude = "(build|site-packages|__pycache__)" exclude = "(build|site-packages|__pycache__)"
strict = true
[tool.pydantic-mypy] [tool.pydantic-mypy]
init_forbid_extra = true init_forbid_extra = true
@ -29,15 +48,20 @@ warn_untyped_fields = true
name = "sporestack" name = "sporestack"
authors = [ {name = "SporeStack", email="support@sporestack.com"} ] authors = [ {name = "SporeStack", email="support@sporestack.com"} ]
readme = "README.md" readme = "README.md"
requires-python = "~=3.7" requires-python = "~=3.8"
dynamic = ["version", "description"] dynamic = ["version", "description"]
keywords = ["bitcoin", "monero", "vps"] keywords = ["bitcoin", "monero", "vps", "server"]
license = {file = "LICENSE.txt"} license = {file = "LICENSE.txt"}
dependencies = [ dependencies = [
"pydantic", "pydantic>=1.10,<3",
"requests[socks]>=2.22.0", "httpx[socks]",
]
[project.optional-dependencies]
cli = [
"segno", "segno",
"typer", "typer>=0.9.0",
"rich",
] ]
[project.urls] [project.urls]

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__ = "7.3.0" __version__ = "11.1.0"

View File

@ -0,0 +1,24 @@
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"

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,15 +1,18 @@
""" """SporeStack API request/response models"""
SporeStack API request/response models import sys
from datetime import datetime
from enum import Enum
from typing import Dict, List, Optional, Union
""" from pydantic import BaseModel, Field
from .models import Currency, Flavor, Invoice, OperatingSystem, Region
from typing import Dict, List, Optional if sys.version_info >= (3, 9): # pragma: nocover
from typing import Annotated
from pydantic import BaseModel else: # pragma: nocover
from typing_extensions import Annotated
from .models import Flavor, NetworkInterface, Payment
class TokenAdd: class TokenAdd:
@ -17,13 +20,12 @@ class TokenAdd:
method = "POST" method = "POST"
class Request(BaseModel): class Request(BaseModel):
currency: str currency: Currency
dollars: int dollars: int
affiliate_token: Optional[str] = None affiliate_token: Union[str, None] = None
class Response(BaseModel): class Response(BaseModel):
token: str invoice: Invoice
payment: Payment
class TokenBalance: class TokenBalance:
@ -31,11 +33,31 @@ class TokenBalance:
method = "GET" method = "GET"
class Response(BaseModel): class Response(BaseModel):
token: str
cents: int cents: int
usd: str 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: class ServerLaunch:
url = "/server/{machine_id}/launch" url = "/server/{machine_id}/launch"
method = "POST" method = "POST"
@ -49,8 +71,6 @@ class ServerLaunch:
"""null is automatic, otherwise a string region slug.""" """null is automatic, otherwise a string region slug."""
token: str token: str
"""Token to draw from when launching the server.""" """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: str = ""
"""Hostname to refer to your server by.""" """Hostname to refer to your server by."""
autorenew: bool = False autorenew: bool = False
@ -59,17 +79,6 @@ class ServerLaunch:
expiration. 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: class ServerTopup:
url = "/server/{machine_id}/topup" url = "/server/{machine_id}/topup"
@ -77,14 +86,16 @@ class ServerTopup:
class Request(BaseModel): class Request(BaseModel):
days: int days: int
token: str token: Union[str, None] = None
class Response(BaseModel):
machine_id: str class ServerDeletedBy(str, Enum):
expiration: int EXPIRATION = "expiration"
paid: bool = True """The server was deleted automatically for being expired."""
"""Deprecated, not needed when paying with token.""" MANUAL = "manual"
warning: Optional[str] = None """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 ServerInfo: class ServerInfo:
@ -101,9 +112,10 @@ class ServerInfo:
ipv6: str ipv6: str
region: str region: str
flavor: Flavor flavor: Flavor
deleted: bool deleted_at: int
network_interfaces: List[NetworkInterface] deleted_by: Union[ServerDeletedBy, None]
"""Deprecated, use ipv4/ipv6 instead.""" forgotten_at: Union[datetime, None]
suspended_at: Union[datetime, None]
operating_system: str operating_system: str
hostname: str hostname: str
autorenew: bool autorenew: bool
@ -120,13 +132,8 @@ class ServerStop:
class ServerDelete: class ServerDelete:
url = "/server/{machine_id}/delete" url = "/server/{machine_id}"
method = "POST" method = "DELETE"
class ServerDestroy:
url = "/server/{machine_id}/destroy"
method = "POST"
class ServerForget: class ServerForget:
@ -170,4 +177,38 @@ class OperatingSystems:
method = "GET" method = "GET"
class Response(BaseModel): class Response(BaseModel):
operating_systems: List[str] 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.")
]

View File

@ -1,15 +1,18 @@
import logging import logging
import os import os
from time import sleep from dataclasses import dataclass
from typing import Any, Dict, Optional from typing import List, Optional, Union
import requests import httpx
from pydantic import parse_obj_as
from . import __version__, api, exceptions from . import __version__, api, exceptions
from .models import Currency, Invoice, ServerUpdateRequest, TokenInfo
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
LATEST_API_VERSION = 2 LATEST_API_VERSION = 2
"""This is probably not used anymore."""
CLEARNET_ENDPOINT = "https://api.sporestack.com" CLEARNET_ENDPOINT = "https://api.sporestack.com"
TOR_ENDPOINT = ( TOR_ENDPOINT = (
@ -18,19 +21,16 @@ TOR_ENDPOINT = (
API_ENDPOINT = CLEARNET_ENDPOINT API_ENDPOINT = CLEARNET_ENDPOINT
GET_TIMEOUT = 60 TIMEOUT = httpx.Timeout(60.0)
POST_TIMEOUT = 90
USE_TOR_PROXY = "auto"
HEADERS = {"User-Agent": f"sporestack-python/{__version__}"}
session = requests.Session()
def _get_tor_proxy() -> str: def _get_tor_proxy() -> str:
""" """
This makes testing easier. This makes testing easier.
""" """
return os.getenv("TOR_PROXY", "socks5h://127.0.0.1:9050") return os.getenv("TOR_PROXY", "socks5://127.0.0.1:9050")
# For requests module # For requests module
@ -58,258 +58,274 @@ def _is_onion_url(url: str) -> bool:
return False return False
def _api_request( def _get_response_error_text(response: httpx.Response) -> str:
url: str, """Get a response's error text. Assumes the response is actually an error."""
empty_post: bool = False, if (
json_params: Optional[Dict[str, Any]] = None, "content-type" in response.headers
retry: bool = False, and response.headers["content-type"] == "application/json"
) -> Any: ):
headers = {"User-Agent": f"sporestack-python/{__version__}"} error = response.json()
proxies = {} if "detail" in error:
if _is_onion_url(url) is True: if isinstance(error["detail"], str):
log.debug("Got a .onion API endpoint, using local Tor SOCKS proxy.") return error["detail"]
proxies = TOR_PROXY_REQUESTS else:
return str(error["detail"])
try: return response.text
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
status_code_first_digit = request.status_code // 100
def _handle_response(response: httpx.Response) -> None:
status_code_first_digit = response.status_code // 100
if status_code_first_digit == 2: if status_code_first_digit == 2:
try: return
return request.json()
except Exception: error_response_text = _get_response_error_text(response)
return request.content if response.status_code == 429:
raise exceptions.SporeStackTooManyRequestsError(error_response_text)
elif status_code_first_digit == 4: elif status_code_first_digit == 4:
log.debug("HTTP status code: {request.status_code}") raise exceptions.SporeStackUserError(error_response_text)
raise exceptions.SporeStackUserError(request.content.decode("utf-8"))
elif status_code_first_digit == 5: elif status_code_first_digit == 5:
if retry is True: # User should probably retry.
log.warning(request.content.decode("utf-8")) raise exceptions.SporeStackServerError(error_response_text)
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: else:
# Not sure why we'd get this. # This would be weird.
request.raise_for_status() raise exceptions.SporeStackServerError(error_response_text)
raise Exception("Stuff broke strangely. Please contact SporeStack support.")
def launch( @dataclass
machine_id: str, class APIClient:
days: int, api_endpoint: str = API_ENDPOINT
flavor: str,
operating_system: str,
ssh_key: str,
token: str,
api_endpoint: str = API_ENDPOINT,
region: Optional[str] = None,
retry: bool = False,
quote: bool = False,
hostname: str = "",
autorenew: bool = False,
) -> api.ServerLaunch.Response:
request = api.ServerLaunch.Request(
days=days,
token=token,
flavor=flavor,
region=region,
operating_system=operating_system,
ssh_key=ssh_key,
quote=quote,
hostname=hostname,
autorenew=autorenew,
)
url = api_endpoint + api.ServerLaunch.url.format(machine_id=machine_id)
response = _api_request(url=url, json_params=request.dict(), retry=retry)
response_object = api.ServerLaunch.Response.parse_obj(response)
assert response_object.machine_id == machine_id
return response_object
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 topup( def server_launch(
machine_id: str, self,
days: int, machine_id: str,
token: str, days: int,
api_endpoint: str = API_ENDPOINT, flavor: str,
retry: bool = False, operating_system: str,
) -> api.ServerTopup.Response: ssh_key: str,
""" token: str,
Topup a server. region: Optional[str] = None,
""" hostname: str = "",
request = api.ServerTopup.Request(days=days, token=token) autorenew: bool = False,
url = api_endpoint + api.ServerTopup.url.format(machine_id=machine_id) ) -> None:
response = _api_request(url=url, json_params=request.dict(), retry=retry) """Launch a server."""
response_object = api.ServerTopup.Response.parse_obj(response) request = api.ServerLaunch.Request(
assert response_object.machine_id == machine_id days=days,
return response_object token=token,
flavor=flavor,
region=region,
operating_system=operating_system,
ssh_key=ssh_key,
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)
def server_topup(
self,
machine_id: str,
days: int,
token: Union[str, None] = None,
) -> None:
"""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 autorenew_enable(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None: def server_quote(self, days: int, flavor: str) -> api.ServerQuote.Response:
""" """Get a quote for how much a server will cost."""
Enable autorenew on a server.
"""
url = api_endpoint + api.ServerEnableAutorenew.url.format(machine_id=machine_id)
_api_request(url, empty_post=True)
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())
def autorenew_disable(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None: def autorenew_enable(self, machine_id: str) -> None:
""" """Enable autorenew on a server."""
Disable autorenew on a server. url = self.api_endpoint + api.ServerEnableAutorenew.url.format(
""" machine_id=machine_id
url = api_endpoint + api.ServerDisableAutorenew.url.format(machine_id=machine_id) )
_api_request(url, empty_post=True) response = self._httpx_client.post(url)
_handle_response(response)
def autorenew_disable(self, machine_id: str) -> None:
"""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)
def start(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None: def server_start(self, machine_id: str) -> None:
""" """Power on a server."""
Boots the server. url = self.api_endpoint + api.ServerStart.url.format(machine_id=machine_id)
""" response = self._httpx_client.post(url)
url = api_endpoint + api.ServerStart.url.format(machine_id=machine_id) _handle_response(response)
_api_request(url, empty_post=True)
def server_stop(self, machine_id: str) -> None:
"""Power off a server."""
url = self.api_endpoint + api.ServerStop.url.format(machine_id=machine_id)
response = self._httpx_client.post(url)
_handle_response(response)
def stop(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None: def server_delete(self, machine_id: str) -> None:
""" """Delete a server."""
Powers off the server. url = self.api_endpoint + api.ServerDelete.url.format(machine_id=machine_id)
""" response = self._httpx_client.delete(url)
url = api_endpoint + api.ServerStop.url.format(machine_id=machine_id) _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."""
url = self.api_endpoint + api.ServerForget.url.format(machine_id=machine_id)
response = self._httpx_client.post(url)
_handle_response(response)
def destroy(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None: def server_rebuild(self, machine_id: str) -> None:
""" """
Destroys the server. Rebuilds the server with the operating system and SSH key set at launch time.
"""
url = api_endpoint + api.ServerDestroy.url.format(machine_id=machine_id)
_api_request(url, empty_post=True)
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)
def delete(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None: def server_info(self, machine_id: str) -> api.ServerInfo.Response:
""" """Returns info about the server."""
Deletes the server. (Deprecated, use destroy instead) url = self.api_endpoint + api.ServerInfo.url.format(machine_id=machine_id)
""" response = self._httpx_client.get(url)
destroy(machine_id, api_endpoint) _handle_response(response)
response_object = api.ServerInfo.Response.parse_obj(response.json())
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 forget(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None: def servers_launched_from_token(
""" self, token: str
Forget about a destroyed/deleted server. ) -> api.ServersLaunchedFromToken.Response:
""" """
url = api_endpoint + api.ServerForget.url.format(machine_id=machine_id) Returns info of servers launched from a given token.
_api_request(url, empty_post=True) """
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()
)
return response_object
def flavors(self) -> api.Flavors.Response:
"""Returns available flavors (server sizes)."""
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())
return response_object
def rebuild(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None: def operating_systems(self) -> api.OperatingSystems.Response:
""" """Returns available operating systems."""
Rebuilds the server with the operating system and SSH key set at launch time. 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())
return response_object
Deletes all of the data on the server! def regions(self) -> api.Regions.Response:
""" """Returns regions that you can launch a server in."""
url = api_endpoint + api.ServerRebuild.url.format(machine_id=machine_id) url = self.api_endpoint + api.Regions.url
_api_request(url, empty_post=True) 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 info(machine_id: str, api_endpoint: str = API_ENDPOINT) -> api.ServerInfo.Response: def token_add(
""" self,
Returns info about the server. token: str,
""" dollars: int,
url = api_endpoint + api.ServerInfo.url.format(machine_id=machine_id) currency: Currency,
response = _api_request(url) ) -> api.TokenAdd.Response:
response_object = api.ServerInfo.Response.parse_obj(response) """Add balance (money) to a token."""
assert response_object.machine_id == machine_id url = self.api_endpoint + api.TokenAdd.url.format(token=token)
return response_object 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())
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())
return response_object
def servers_launched_from_token( def token_info(self, token: str) -> TokenInfo:
token: str, api_endpoint: str = API_ENDPOINT """Return information about a token, including balance."""
) -> api.ServersLaunchedFromToken.Response: url = self.api_endpoint + f"/token/{token}/info"
""" response = self._httpx_client.get(url)
Returns info of servers launched from a given token. _handle_response(response)
""" response_object = TokenInfo.parse_obj(response.json())
url = api_endpoint + api.ServersLaunchedFromToken.url.format(token=token) return response_object
response = _api_request(url)
response_object = api.ServersLaunchedFromToken.Response.parse_obj(response)
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)
def flavors(api_endpoint: str = API_ENDPOINT) -> api.Flavors.Response: return parse_obj_as(List[api.TokenMessage], response.json())
"""
Returns available flavors.
"""
url = api_endpoint + api.Flavors.url
response = _api_request(url)
response_object = api.Flavors.Response.parse_obj(response)
return response_object
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 operating_systems( def token_invoice(self, token: str, invoice: str) -> Invoice:
api_endpoint: str = API_ENDPOINT, """Get a particular invoice."""
) -> api.OperatingSystems.Response: url = self.api_endpoint + f"/token/{token}/invoices/{invoice}"
""" response = self._httpx_client.get(url=url)
Returns available operating systems. _handle_response(response)
"""
url = api_endpoint + api.OperatingSystems.url
response = _api_request(url)
response_object = api.OperatingSystems.Response.parse_obj(response)
return response_object
return parse_obj_as(Invoice, response.json())
def token_add( def token_invoices(self, token: str) -> List[Invoice]:
token: str, """Get token invoices."""
dollars: int, url = self.api_endpoint + f"/token/{token}/invoices"
currency: str, response = self._httpx_client.get(url=url)
api_endpoint: str = API_ENDPOINT, _handle_response(response)
retry: bool = False,
) -> api.TokenAdd.Response:
request = api.TokenAdd.Request(dollars=dollars, currency=currency)
url = 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
return parse_obj_as(List[Invoice], response.json())
def token_balance(
token: str, api_endpoint: str = API_ENDPOINT
) -> api.TokenBalance.Response:
url = api_endpoint + api.TokenBalance.url.format(token=token)
response = _api_request(url=url)
response_object = api.TokenBalance.Response.parse_obj(response)
assert response_object.token == token
return response_object

File diff suppressed because it is too large Load Diff

188
src/sporestack/client.py Normal file
View File

@ -0,0 +1,188 @@
from dataclasses import dataclass, field
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)
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:
"""Powers on the server."""
self.api_client.server_start(self.machine_id)
def stop(self) -> None:
"""Powers off the server."""
self.api_client.server_stop(self.machine_id)
def autorenew_enable(self) -> None:
"""Enables autorenew on the server."""
self.api_client.server_update(self.machine_id, autorenew=True)
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
)
def topup(self, days: int) -> None:
"""
Renew the server for the amount of days specified, from the token that
launched the server.
"""
if self.token is None:
raise ValueError("token must be set to top up a server!")
self.api_client.server_topup(
machine_id=self.machine_id, days=days, token=self.token
)
@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."""
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."""
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]:
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,
api_client=self.api_client,
token=self.token,
)
)
return server_classes
def launch_server(
self,
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,
token=self.token,
region=region,
flavor=flavor,
operating_system=operating_system,
ssh_key=ssh_key,
hostname=hostname,
autorenew=autorenew,
)
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,6 +8,12 @@ class SporeStackUserError(SporeStackError):
pass pass
class SporeStackTooManyRequestsError(SporeStackError):
"""HTTP 429, retry again later"""
pass
class SporeStackServerError(SporeStackError): class SporeStackServerError(SporeStackError):
"""HTTP 5XX""" """HTTP 5XX"""

View File

@ -1,25 +1,17 @@
""" """SporeStack API supplemental models"""
SporeStack API supplemental models import sys
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
from typing import Optional from ._models import Currency as Currency
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): class Flavor(BaseModel):
@ -37,5 +29,109 @@ class Flavor(BaseModel):
ipv4: str ipv4: str
# IPv6 connectivity: "/128" # IPv6 connectivity: "/128"
ipv6: str ipv6: str
# Gigabytes of bandwidth per day """Gigabytes of bandwidth per day."""
bandwidth: int 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

View File

@ -1,9 +1,9 @@
from unittest.mock import MagicMock, patch import httpx
import pytest import pytest
from pydantic import ValidationError import respx
from sporestack import api_client, exceptions
from sporestack import api_client # respx seems to ignore the uri://domain if you don't specify it.
def test__is_onion_url() -> None: def test__is_onion_url() -> None:
@ -20,70 +20,140 @@ def test__is_onion_url() -> None:
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
@patch("sporestack.api_client._api_request") def test_get_response_error_text() -> None:
def test_launch(mock_api_request: MagicMock) -> None: assert (
with pytest.raises(ValidationError): api_client._get_response_error_text(
api_client.launch( httpx.Response(status_code=422, text="just text")
"dummymachineid", )
days=1, == "just text"
operating_system="freebsd-12", )
ssh_key="id-rsa...",
flavor="aflavor", assert (
token="f" * 64, 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))
@patch("sporestack.api_client._api_request") with pytest.raises(exceptions.SporeStackUserError, match="Invalid arguments"):
def test_topup(mock_api_request: MagicMock) -> None: api_client._handle_response(
with pytest.raises(ValidationError): httpx.Response(status_code=400, text="Invalid arguments")
api_client.topup("dummymachineid", token="f" * 64, days=1) )
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"))
@patch("sporestack.api_client._api_request") def test_token_info(respx_mock: respx.MockRouter) -> None:
def test_start(mock_api_request: MagicMock) -> None: dummy_token = "dummyinvalidtoken"
api_client.start("dummymachineid") response_json = {
mock_api_request.assert_called_once_with( "balance_cents": 0,
"https://api.sporestack.com/server/dummymachineid/start", empty_post=True "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
@patch("sporestack.api_client._api_request") assert route.called
def test_stop(mock_api_request: MagicMock) -> None:
api_client.stop("dummymachineid")
mock_api_request.assert_called_once_with( def test_server_info(respx_mock: respx.MockRouter) -> None:
"https://api.sporestack.com/server/dummymachineid/stop", empty_post=True 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
@patch("sporestack.api_client._api_request") assert route.called
def test_rebuild(mock_api_request: MagicMock) -> None:
api_client.rebuild("dummymachineid")
mock_api_request.assert_called_once_with(
"https://api.sporestack.com/server/dummymachineid/rebuild", empty_post=True
)
@patch("sporestack.api_client._api_request")
def test_info(mock_api_request: MagicMock) -> None:
with pytest.raises(ValidationError):
api_client.info("dummymachineid")
mock_api_request.assert_called_once_with(
"https://api.sporestack.com/server/dummymachineid/info"
)
@patch("sporestack.api_client._api_request")
def test_delete(mock_api_request: MagicMock) -> None:
api_client.delete("dummymachineid")
mock_api_request.assert_called_once_with(
"https://api.sporestack.com/server/dummymachineid/destroy", empty_post=True
)
@patch("sporestack.api_client._api_request")
def test_token_balance(mock_api_request: MagicMock) -> None:
with pytest.raises(ValidationError):
api_client.token_balance("dummytoken")
mock_api_request.assert_called_once_with(
url="https://api.sporestack.com/token/dummytoken/balance"
)

View File

@ -1,10 +1,9 @@
import pytest import pytest
import typer import typer
from _pytest.monkeypatch import MonkeyPatch from _pytest.monkeypatch import MonkeyPatch
from typer.testing import CliRunner
from sporestack import cli from sporestack import cli
from sporestack.api_client import TOR_ENDPOINT from sporestack.api_client import TOR_ENDPOINT
from typer.testing import CliRunner
runner = CliRunner() runner = CliRunner()
@ -37,12 +36,12 @@ def test_cli_api_endpoint(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setenv("SPORESTACK_USE_TOR_ENDPOINT", "1") monkeypatch.setenv("SPORESTACK_USE_TOR_ENDPOINT", "1")
result = runner.invoke(cli.cli, ["api-endpoint"]) result = runner.invoke(cli.cli, ["api-endpoint"])
assert result.output == TOR_ENDPOINT + " using socks5h://127.0.0.1:9050\n" assert result.output == TOR_ENDPOINT + " using socks5://127.0.0.1:9050\n"
assert result.exit_code == 0 assert result.exit_code == 0
monkeypatch.setenv("TOR_PROXY", "socks5h://127.0.0.1:1337") monkeypatch.setenv("TOR_PROXY", "socks5://127.0.0.1:1337")
result = runner.invoke(cli.cli, ["api-endpoint"]) result = runner.invoke(cli.cli, ["api-endpoint"])
assert result.output == TOR_ENDPOINT + " using socks5h://127.0.0.1:1337\n" assert result.output == TOR_ENDPOINT + " using socks5://127.0.0.1:1337\n"
assert result.exit_code == 0 assert result.exit_code == 0

18
tests/test_client.py Normal file
View File

@ -0,0 +1,18 @@
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 Normal file
View File

@ -0,0 +1,19 @@
[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