Compare commits

...

59 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
Administrator 92fbddc9e5 7.3.0: Fix sporestack server topup with recent API changes
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/tag/woodpecker Pipeline failed Details
2022-11-28 16:28:43 +00:00
Administrator 5728118ec1 Fix pre-commit in Woodpecker
ci/woodpecker/push/woodpecker Pipeline is pending Details
2022-11-02 00:04:13 +00:00
Administrator 8a83747243 v7.2.1: Fix on Python 3.7 and 3.8
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/tag/woodpecker Pipeline failed Details
2022-11-02 00:00:00 +00:00
Administrator 28892e8ff3 v7.2.0: Use new format for new tokens
ci/woodpecker/push/woodpecker Pipeline failed Details
ci/woodpecker/tag/woodpecker Pipeline failed Details
2022-11-01 23:25:24 +00:00
Administrator d8043414a8 v7.1.2: Fix launch output with latest API changes 2022-11-01 04:42:29 +00:00
Administrator 8cd2644c0c v7.1.1: Fix server launch error related to hostname 2022-09-29 15:21:49 +00:00
Administrator 7158b6953c v7.1.0: Add sporestack server autorenew-enable/disable commands 2022-09-27 18:52:36 +00:00
Administrator 6582917898 7.0.0 2022-09-07 23:07:02 +00:00
Administrator 3f2cd2c20b v6.2.0: Add beta flag: --autorenew for sporestack server launch 2022-09-07 21:40:02 +00:00
Administrator 454a2a17c1 Release 6.1.0 2022-06-14 02:40:11 +00:00
Administrator 3c5f69c549 6.0.3: Bug fixes 2022-04-22 01:45:50 +00:00
Administrator 1e3514900d 6.0.2: Switch from setuptools to flit 2022-04-22 01:10:34 +00:00
Administrator f62bfa0568 6.0.1: Speed things up with a requests session 2022-04-22 00:23:34 +00:00
27 changed files with 3253 additions and 1360 deletions

2
.gitignore vendored
View File

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

View File

@ -1,34 +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/asottile/setup-cfg-fmt
rev: ce506f9063be3e882d391cdf7b6a3d5f8359add8 # frozen: v1.20.1
hooks:
- id: setup-cfg-fmt
- 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,40 +1,47 @@
pipeline:
python-3.7:
group: test
image: python:3.7
steps:
python-3.8:
image: python:3.8
commands:
- pip install pipenv==2022.1.8
- pip install pipenv==2023.12.1 tomli
- pipenv install --dev --deploy
- pipenv run almake test-pytest # We only test with pytest on 3.7
# More than three jobs seems to cause issues with Woodpecker?
# python-3.8:
# group: test
# image: python:3.8-slim
# commands:
# - pip install pipenv==2021.11.23
# - pipenv install --dev --deploy
# - pipenv run almake test
# - pipenv run almake build-dist
# - sha256sum dist/*
- pipenv run almake test-typing
- pipenv run almake test-pytest
- pipenv run almake build-dist
- sha256sum dist/*
python-3.9:
group: test
image: python:3.9
commands:
- pip install pipenv==2022.1.8 pre-commit==2.17.0
- pre-commit run --all-files
- pip install pipenv==2023.12.1
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake test-typing
- pipenv run almake test-pytest
- pipenv run almake build-dist
- sha256sum dist/*
python-3.10:
group: test
image: python:3.10
commands:
- pip install pipenv==2022.1.8 pre-commit==2.17.0
- pre-commit run --all-files
- pip install pipenv==2023.12.1
- pipenv install --dev --deploy
- pipenv run almake test-typing
- pipenv run almake test-pytest
- pipenv run almake build-dist
- sha256sum dist/*
python-3.11:
image: python:3.11
commands:
- pip install pipenv==2023.12.1
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake build-dist
- sha256sum dist/*
python-3.12:
image: python:3.11
commands:
- pip install pipenv==2023.12.1
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake build-dist

View File

@ -5,8 +5,304 @@ 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
- Fixed broken `sporestack server topup` after API changes.
## [7.2.1 - 2022-11-01]
### Changed
- Fixed on Python 3.7 and 3.8.
## [7.2.0 - 2022-11-01]
### Changed
- Use new format for new tokens.
## [7.1.2 - 2022-11-01]
### Changed
- Fixed launch output with recent API changes.
## [7.1.1 - 2022-09-29]
### Changed
- Fixed hostname related bug when launching a server.
## [7.1.0 - 2022-09-27]
### Added
- `sporestack server autorenew-enable/disable`
### Changed
- Show autorenew status and associated token in `sporestack server list` (not in all cases, however)
## [7.0.0 - 2022-09-07]
### Added
- `sporestack server list` now accepts `--local` or `--no-local`.
- `sporestack server operating-systems`
### Changed
- `sporestack server` subcommands take `--hostname` or `--machine-id`.
- `sporestack server flavors` output is slightly more readable.
### Removed
- `sporestack server delete` (in favor of: `sporestack server destroy`)
- `sporestack server get-attribute`
## [6.2.0 - 2022-09-07]
### Added
- Allow for new *beta* `--autorenew` feature with `sporestack server launch`.
### Changed
- No longer save server JSON to disk for new servers.
## [6.1.0 - 2022-06-14]
### Changed
- Use servers launched by token endpoint in `sporestack server list`.
- Send server hostname to SporeStack API at launch time.
## [6.0.3 - 2022-04-22]
### Changed
- Bug fixes.
## [6.0.2 - 2022-04-22]
### Changed
- Replace setuptools with flit.
## [6.0.1 - 2022-04-22]
### Changed
- Use `requests` session for improved performance, in particular for `sporestack server list`.
## [6.0.0 - 2022-04-14]
### Fixed

View File

@ -1,16 +1,27 @@
format:
black .
ruff check --fix .
test:
python -m pflake8 .
python -m mypy --strict .
black --check .
ruff check .
$(MAKE) test-typing
$(MAKE) test-pytest
test-typing:
mypy
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:
rm dist/* || true
# This should result in a reproducible wheel.
SOURCE_DATE_EPOCH=1309379017 python -m build --no-isolation
python -m twine check --strict dist/*
SOURCE_DATE_EPOCH=$$(git log -1 --format=%ct) flit build
# This shouldn't be needed often, but is nice for validation.
twine-check:
twine check --strict dist/*
servedocs:
pdoc sporestack

27
Pipfile
View File

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

1280
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)
## Requirements
* Python 3.7-3.10 (or maybe newer)
## Installation
* `pip install sporestack`
* Recommended: Create a virtual environment, first, and use it inside there.
* Python 3.8-3.11 (and likely newer)
## Running without installing
* 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/).
* Make sure [pipx](https://pipx.pypya.io) is installed.
* `pipx run 'sporestack[cli]'`
## 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 balance`
* `sporestack server launch SomeHostname --operating-system debian-11 --days 1 # Will use ~/.ssh/id_rsa.pub as your SSH key, by default`
* `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`
(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 start SomeHostname`
* `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 list`
* `sporestack server remove SomeHostname # If expired`
* `sporestack server delete --hostname SomeHostname`
## 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
* `pip install pipenv pre-commit`
* `pre-commit install`
* `pipenv install --deploy --dev`
* `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`)
* `pipenv run make test`
* `pipenv run make format` to format files and apply ruff fixes.
## 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]
show_missing = true
[tool.coverage.run]
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]
files = "."
plugins = ["pydantic.mypy"]
exclude = "(build|site-packages|__pycache__)"
strict = true
[tool.pydantic-mypy]
init_forbid_extra = true
@ -25,6 +44,34 @@ init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = true
[project]
name = "sporestack"
authors = [ {name = "SporeStack", email="support@sporestack.com"} ]
readme = "README.md"
requires-python = "~=3.8"
dynamic = ["version", "description"]
keywords = ["bitcoin", "monero", "vps", "server"]
license = {file = "LICENSE.txt"}
dependencies = [
"pydantic>=1.10,<3",
"httpx[socks]",
]
[project.optional-dependencies]
cli = [
"segno",
"typer>=0.9.0",
"rich",
]
[project.urls]
Homepage = "https://sporestack.com"
Source = "https://git.sporestack.com/SporeStack/sporestack-python"
Changelog = "https://git.sporestack.com/SporeStack/sporestack-python/src/branch/master/CHANGELOG.md"
[project.scripts]
sporestack = "sporestack.cli:cli"
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
requires = ["flit_core >=3.2,<4"]
build-backend = "flit_core.buildapi"

View File

@ -1,50 +0,0 @@
[metadata]
name = sporestack
version = 6.0.0
description = SporeStack.com library and client. Launch servers with Monero or Bitcoin.
long_description = file: README.md
long_description_content_type = text/markdown
url = https://sporestack.com/
author = SporeStack
author_email = admin@sporestack.com
license = Unlicense
license_file = LICENSE.txt
classifiers =
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
keywords =
bitcoin
bitcoincash
bitcoinsv
monero
servers
infrastructure
vps
virtual private server
[options]
packages = find:
install_requires =
pydantic
requests[socks]>=2.22.0
segno
typer
importlib-metadata;python_version<"3.8"
python_requires = >=3.7
package_dir = =src
zip_safe = False
[options.packages.find]
where = src
[options.entry_points]
console_scripts =
sporestack = sporestack.cli:cli
[options.package_data]
sporestack =
py.typed

View File

@ -1 +1,5 @@
__all__ = ["api", "api_client", "exceptions"]
"""SporeStack API library and CLI for launching servers with Monero or Bitcoin"""
__all__ = ["api", "api_client", "client", "exceptions"]
__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,33 +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 List, Optional
from pydantic import BaseModel
from .models import NetworkInterface, Payment
LATEST_API_VERSION = 3
class TokenEnable:
"""Deprecated: Use TokenAdd instead."""
url = "/token/{token}/enable"
method = "POST"
class Request(BaseModel):
currency: str
dollars: int
affiliate_token: Optional[str] = None
class Response(BaseModel):
token: str
payment: Payment
if sys.version_info >= (3, 9): # pragma: nocover
from typing import Annotated
else: # pragma: nocover
from typing_extensions import Annotated
class TokenAdd:
@ -35,13 +20,12 @@ class TokenAdd:
method = "POST"
class Request(BaseModel):
currency: str
currency: Currency
dollars: int
affiliate_token: Optional[str] = None
affiliate_token: Union[str, None] = None
class Response(BaseModel):
token: str
payment: Payment
invoice: Invoice
class TokenBalance:
@ -49,47 +33,51 @@ 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"
class Request(BaseModel):
machine_id: str
days: int
flavor: str
ssh_key: str
operating_system: str
currency: Optional[str] = None
"""Currency only needs to be set if not paying with a token."""
region: Optional[str] = None
organization: Optional[str] = None
token: Optional[str] = None
quote: bool = False
affiliate_token: Optional[str] = None
affiliate_amount: None = None
"""Deprecated field"""
settlement_token: Optional[str] = None
"""Deprecated field. Use token instead."""
class Response(BaseModel):
payment: Payment
expiration: int
machine_id: str
operating_system: str
flavor: str
network_interfaces: List[NetworkInterface] = []
created_at: int = 0
region: Optional[str] = None
latest_api_version: int = LATEST_API_VERSION
created: bool = False
paid: bool = False
warning: Optional[str] = None
txid: Optional[str] = None
"""null is automatic, otherwise a string region slug."""
token: str
"""Token to draw from when launching the server."""
hostname: str = ""
"""Hostname to refer to your server by."""
autorenew: bool = False
"""
Automatically renew the server with the token used, keeping it at 1 week
expiration.
"""
class ServerTopup:
@ -97,26 +85,17 @@ class ServerTopup:
method = "POST"
class Request(BaseModel):
machine_id: str
days: int
token: Optional[str] = None
quote: bool = False
currency: Optional[str] = None
"""Currency only needs to be set if not paying with a token."""
affiliate_token: Optional[str] = None
affiliate_amount: None = None
"""Deprecated field"""
settlement_token: Optional[str] = None
"""Deprecated field. Use token instead."""
token: Union[str, None] = None
class Response(BaseModel):
machine_id: str
payment: Payment
expiration: int
paid: bool = False
warning: Optional[str] = None
txid: Optional[str] = None
latest_api_version: int = LATEST_API_VERSION
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 ServerInfo:
@ -128,8 +107,18 @@ class ServerInfo:
expiration: int
running: bool
machine_id: str
network_interfaces: List[NetworkInterface]
token: str
ipv4: str
ipv6: str
region: str
flavor: Flavor
deleted_at: int
deleted_by: Union[ServerDeletedBy, None]
forgotten_at: Union[datetime, None]
suspended_at: Union[datetime, None]
operating_system: str
hostname: str
autorenew: bool
class ServerStart:
@ -143,10 +132,83 @@ class ServerStop:
class ServerDelete:
url = "/server/{machine_id}/delete"
url = "/server/{machine_id}"
method = "DELETE"
class ServerForget:
url = "/server/{machine_id}/forget"
method = "POST"
class ServerRebuild:
url = "/server/{machine_id}/rebuild"
method = "POST"
class ServerEnableAutorenew:
url = "/server/{machine_id}/autorenew/enable"
method = "POST"
class ServerDisableAutorenew:
url = "/server/{machine_id}/autorenew/disable"
method = "POST"
class ServersLaunchedFromToken:
url = "/token/{token}/servers"
method = "GET"
class Response(BaseModel):
servers: List[ServerInfo.Response]
class Flavors:
url = "/flavors"
method = "GET"
class Response(BaseModel):
flavors: Dict[str, Flavor]
class OperatingSystems:
url = "/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.")
]

View File

@ -1,16 +1,18 @@
import logging
import os
from time import sleep
from typing import Any, Dict, Optional
from dataclasses import dataclass
from typing import List, Optional, Union
import requests
import httpx
from pydantic import parse_obj_as
from . import api, exceptions
from .version import __version__
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 = (
@ -19,16 +21,16 @@ TOR_ENDPOINT = (
API_ENDPOINT = CLEARNET_ENDPOINT
GET_TIMEOUT = 60
POST_TIMEOUT = 90
USE_TOR_PROXY = "auto"
TIMEOUT = httpx.Timeout(60.0)
HEADERS = {"User-Agent": f"sporestack-python/{__version__}"}
def _get_tor_proxy() -> str:
"""
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
@ -56,204 +58,274 @@ def _is_onion_url(url: str) -> bool:
return False
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
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"])
try:
if empty_post is True:
request = requests.post(
url, timeout=POST_TIMEOUT, proxies=proxies, headers=headers
)
elif json_params is None:
request = requests.get(
url, timeout=GET_TIMEOUT, proxies=proxies, headers=headers
)
else:
request = requests.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
return response.text
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:
try:
return request.json()
except Exception:
return request.content
return
error_response_text = _get_response_error_text(response)
if response.status_code == 429:
raise exceptions.SporeStackTooManyRequestsError(error_response_text)
elif status_code_first_digit == 4:
log.debug("HTTP status code: {request.status_code}")
raise exceptions.SporeStackUserError(request.content.decode("utf-8"))
raise exceptions.SporeStackUserError(error_response_text)
elif status_code_first_digit == 5:
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))
# User should probably retry.
raise exceptions.SporeStackServerError(error_response_text)
else:
# Not sure why we'd get this.
request.raise_for_status()
raise Exception("Stuff broke strangely. Please contact SporeStack support.")
# This would be weird.
raise exceptions.SporeStackServerError(error_response_text)
def launch(
machine_id: str,
days: int,
currency: str,
flavor: str,
operating_system: str,
ssh_key: str,
api_endpoint: str = API_ENDPOINT,
region: Optional[str] = None,
token: Optional[str] = None,
retry: bool = False,
affiliate_token: Optional[str] = None,
quote: bool = False,
) -> api.ServerLaunch.Response:
request = api.ServerLaunch.Request(
machine_id=machine_id,
days=days,
currency=currency,
token=token,
affiliate_token=affiliate_token,
flavor=flavor,
region=region,
operating_system=operating_system,
ssh_key=ssh_key,
quote=quote,
)
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
@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 topup(
machine_id: str,
days: int,
currency: str,
api_endpoint: str = API_ENDPOINT,
token: Optional[str] = None,
retry: bool = False,
affiliate_token: Optional[str] = None,
quote: bool = False,
) -> api.ServerTopup.Response:
"""
Topup a server.
"""
request = api.ServerTopup.Request(
machine_id=machine_id,
days=days,
currency=currency,
token=token,
affiliate_token=affiliate_token,
quote=quote,
)
url = api_endpoint + api.ServerTopup.url.format(machine_id=machine_id)
response = _api_request(url=url, json_params=request.dict(), retry=retry)
response_object = api.ServerTopup.Response.parse_obj(response)
assert response_object.machine_id == machine_id
return response_object
def server_launch(
self,
machine_id: str,
days: int,
flavor: str,
operating_system: str,
ssh_key: str,
token: str,
region: Optional[str] = None,
hostname: str = "",
autorenew: bool = False,
) -> None:
"""Launch a server."""
request = api.ServerLaunch.Request(
days=days,
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 start(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None:
"""
Boots the server.
"""
url = api_endpoint + api.ServerStart.url.format(machine_id=machine_id)
_api_request(url, empty_post=True)
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())
def stop(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None:
"""
Powers off the server.
"""
url = api_endpoint + api.ServerStop.url.format(machine_id=machine_id)
_api_request(url, empty_post=True)
def autorenew_enable(self, machine_id: str) -> None:
"""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)
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 delete(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None:
"""
Deletes the server.
"""
url = api_endpoint + api.ServerDelete.url.format(machine_id=machine_id)
_api_request(url, empty_post=True)
def server_start(self, machine_id: str) -> None:
"""Power on a server."""
url = self.api_endpoint + api.ServerStart.url.format(machine_id=machine_id)
response = self._httpx_client.post(url)
_handle_response(response)
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 rebuild(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None:
"""
Rebuilds the server with the operating system and SSH key set at launch time.
def server_delete(self, machine_id: str) -> None:
"""Delete a server."""
url = self.api_endpoint + api.ServerDelete.url.format(machine_id=machine_id)
response = self._httpx_client.delete(url)
_handle_response(response)
Deletes all of the data on the server!
"""
url = api_endpoint + api.ServerRebuild.url.format(machine_id=machine_id)
_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 server_rebuild(self, machine_id: str) -> None:
"""
Rebuilds the server with the operating system and SSH key set at launch time.
def info(machine_id: str, api_endpoint: str = API_ENDPOINT) -> api.ServerInfo.Response:
"""
Returns info about the server.
"""
url = api_endpoint + api.ServerInfo.url.format(machine_id=machine_id)
response = _api_request(url)
response_object = api.ServerInfo.Response.parse_obj(response)
assert response_object.machine_id == machine_id
return response_object
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 server_info(self, machine_id: str) -> api.ServerInfo.Response:
"""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())
return response_object
def token_add(
token: str,
dollars: int,
currency: str,
api_endpoint: str = API_ENDPOINT,
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
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:
"""
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()
)
return response_object
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
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 operating_systems(self) -> api.OperatingSystems.Response:
"""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())
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,
) -> 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())
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 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

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
class SporeStackTooManyRequestsError(SporeStackError):
"""HTTP 429, retry again later"""
pass
class SporeStackServerError(SporeStackError):
"""HTTP 5XX"""

View File

@ -1,22 +1,137 @@
"""
"""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 ._models import Currency as Currency
from typing import Optional
from pydantic import BaseModel
class NetworkInterface(BaseModel):
class Flavor(BaseModel):
# Unique string to identify the flavor that's sort of human readable.
slug: str
# Number of vCPU cores the server is given.
cores: int
# Memory in Megabytes
memory: int
# Disk in Gigabytes
disk: int
# USD cents per day
price: int
# IPv4 connectivity: "/32"
ipv4: str
# IPv6 connectivity: "/128"
ipv6: str
"""Gigabytes of bandwidth per day."""
bandwidth_per_month: float
"""Gigabytes of bandwidth per month."""
class Payment(BaseModel):
txid: Optional[str]
uri: Optional[str]
usd: str
paid: bool
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

@ -14,7 +14,7 @@ def checksum(to_hash: str) -> str:
def random_machine_id() -> str:
"""
These used to be 64 hex characters. Now they have a new format.
Machine IDs have a 32 character format with a checksum.
"""
to_hash = f"ss_m_{secrets.token_hex(11)}"
return f"{to_hash}_{checksum(to_hash)}"
@ -22,6 +22,7 @@ def random_machine_id() -> str:
def random_token() -> str:
"""
64 hex characters.
Tokens have a 32 character format with a checksum.
"""
return secrets.token_hex(32)
to_hash = f"ss_t_{secrets.token_hex(11)}"
return f"{to_hash}_{checksum(to_hash)}"

View File

@ -1,10 +0,0 @@
import sys
if sys.version_info[:2] >= (3, 8): # pragma: nocover
from importlib.metadata import version as importlib_metadata_version
else: # pragma: nocover
# Python 3.7 doesn't have this.
from importlib_metadata import version as importlib_metadata_version
__version__ = importlib_metadata_version(__package__)

View File

@ -1,9 +1,9 @@
from unittest.mock import MagicMock, patch
import httpx
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:
@ -20,70 +20,140 @@ def test__is_onion_url() -> None:
assert api_client._is_onion_url("http://me.me/file.onion") is False
@patch("sporestack.api_client._api_request")
def test_launch(mock_api_request: MagicMock) -> None:
with pytest.raises(ValidationError):
api_client.launch(
"dummymachineid",
currency="xmr",
days=1,
operating_system="freebsd-12",
ssh_key="id-rsa...",
flavor="aflavor",
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))
@patch("sporestack.api_client._api_request")
def test_topup(mock_api_request: MagicMock) -> None:
with pytest.raises(ValidationError):
api_client.topup("dummymachineid", currency="xmr", days=1)
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"))
@patch("sporestack.api_client._api_request")
def test_start(mock_api_request: MagicMock) -> None:
api_client.start("dummymachineid")
mock_api_request.assert_called_once_with(
"https://api.sporestack.com/server/dummymachineid/start", empty_post=True
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
@patch("sporestack.api_client._api_request")
def test_stop(mock_api_request: MagicMock) -> None:
api_client.stop("dummymachineid")
mock_api_request.assert_called_once_with(
"https://api.sporestack.com/server/dummymachineid/stop", empty_post=True
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
@patch("sporestack.api_client._api_request")
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/delete", 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"
)
assert route.called

View File

@ -1,8 +1,9 @@
import pytest
import typer
from _pytest.monkeypatch import MonkeyPatch
from typer.testing import CliRunner
from sporestack import cli
from sporestack.api_client import TOR_ENDPOINT
from typer.testing import CliRunner
runner = CliRunner()
@ -35,10 +36,22 @@ 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 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
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"])
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
def test_get_machine_id() -> None:
assert cli._get_machine_id("machine_id", "", "token") == "machine_id"
# machine_id and hostname set
with pytest.raises(typer.Exit):
cli._get_machine_id("machine_id", "hostname", "token")
# Neither is set
with pytest.raises(typer.Exit):
cli._get_machine_id("", "", "token")

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)

View File

@ -7,5 +7,11 @@ def test_random_machine_id() -> None:
assert utils.random_machine_id().startswith("ss_m_")
def test_random_token() -> None:
assert utils.random_token() != utils.random_token()
assert len(utils.random_token()) == 32
assert utils.random_token().startswith("ss_t_")
def test_hash() -> None:
assert utils.checksum("ss_m_1deadbeefcafedeadbeef1") == "0892"

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