Compare commits
42 Commits
Author | SHA1 | Date |
---|---|---|
Administrator | 6eaa839d51 | |
Administrator | 6cad92952a | |
Administrator | 6bc5791980 | |
Administrator | c28e8b45fc | |
Administrator | 9582ce66b7 | |
Administrator | 1ef7224b1a | |
Administrator | c0ccff7e74 | |
Administrator | 4de17915cb | |
Administrator | 7398ebd1a2 | |
Administrator | 7a4f228625 | |
Administrator | 0881d28222 | |
Administrator | bd4be5d999 | |
Administrator | 691fe4d5f8 | |
Administrator | ac7eb3a186 | |
Administrator | 190b94746c | |
Administrator | 027bc13ad2 | |
Administrator | d84c2975ee | |
Administrator | 396dbee6f6 | |
Administrator | 5637790af5 | |
Administrator | 16c790728d | |
Administrator | 5ff095af3f | |
Administrator | 4311d86658 | |
Administrator | 9acfc88e2a | |
Administrator | c5c4d9797d | |
Administrator | 7c6f57068d | |
Administrator | e2c90e3ae4 | |
Administrator | 1b71618d31 | |
Administrator | 65cdc9ada7 | |
Administrator | a0864e413a | |
Administrator | 834b1e1e33 | |
Administrator | 095fe103cb | |
Administrator | ba202eca05 | |
Administrator | 6dc1c8acd5 | |
Administrator | 8b04220c9f | |
Administrator | 07ef8df8ba | |
Administrator | 6c83286340 | |
Administrator | f70a1f65b0 | |
Administrator | b4705c3634 | |
Administrator | 8e00f28940 | |
Administrator | 7a6c09dad6 | |
Administrator | b832c0045d | |
Spore | d862f540ba |
|
@ -8,3 +8,5 @@ dist
|
||||||
__pycache__
|
__pycache__
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
.coverage
|
.coverage
|
||||||
|
.tox
|
||||||
|
dummydotsporestackfolder
|
||||||
|
|
|
@ -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
|
||||||
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
|
||||||
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
|
||||||
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
|
||||||
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
|
||||||
|
|
204
CHANGELOG.md
204
CHANGELOG.md
|
@ -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
|
||||||
|
|
11
Makefile
11
Makefile
|
@ -1,15 +1,18 @@
|
||||||
format:
|
format:
|
||||||
black .
|
black .
|
||||||
ruff --fix .
|
ruff check --fix .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
black --check .
|
black --check .
|
||||||
ruff .
|
ruff check .
|
||||||
python -m mypy --strict .
|
$(MAKE) test-typing
|
||||||
$(MAKE) test-pytest
|
$(MAKE) test-pytest
|
||||||
|
|
||||||
|
test-typing:
|
||||||
|
mypy
|
||||||
|
|
||||||
test-pytest:
|
test-pytest:
|
||||||
python -m pytest --cov=sporestack --cov-fail-under=40 --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
|
||||||
|
|
19
Pipfile
19
Pipfile
|
@ -7,24 +7,25 @@ name = "pypi"
|
||||||
sporestack = {editable = true, path = "."}
|
sporestack = {editable = true, path = "."}
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
black = "~=23.1"
|
black = "~=24.0"
|
||||||
mypy = "~=1.0"
|
mypy = "~=1.0"
|
||||||
pytest = "~=7.2"
|
pytest = "~=8.0"
|
||||||
pytest-cov = "~=4.0"
|
pytest-cov = "~=4.0"
|
||||||
pytest-mock = "~=3.6"
|
pytest-mock = "~=3.6"
|
||||||
pytest-socket = "~=0.5.1"
|
pytest-socket = "~=0.7.0"
|
||||||
ruff = "==0.0.239"
|
ruff = "~=0.3.4"
|
||||||
|
|
||||||
types-requests = "~=2.25"
|
respx = "~=0.20.1"
|
||||||
|
|
||||||
# Building
|
# Building
|
||||||
wheel = "~=0.38.0"
|
flit = "~=3.8"
|
||||||
build = "~=0.7.0"
|
wheel = "*"
|
||||||
|
build = "~=1.0"
|
||||||
# Publishing
|
# Publishing
|
||||||
twine = "~=3.4"
|
twine = "~=5.0"
|
||||||
|
|
||||||
# Docs
|
# Docs
|
||||||
pdoc = "~=12.0"
|
pdoc = "~=14.0"
|
||||||
|
|
||||||
# Python `make` implementation
|
# Python `make` implementation
|
||||||
almost-make = "~=0.5.2"
|
almost-make = "~=0.5.2"
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
50
README.md
50
README.md
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
@ -2,7 +2,7 @@
|
||||||
addopts = "--strict-markers --disable-socket"
|
addopts = "--strict-markers --disable-socket"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
select = [
|
lint.select = [
|
||||||
"F", # pyflakes
|
"F", # pyflakes
|
||||||
"E", # pycodestyle errors
|
"E", # pycodestyle errors
|
||||||
"W", # pycodestyle warnings
|
"W", # pycodestyle warnings
|
||||||
|
@ -13,17 +13,18 @@ select = [
|
||||||
"UP", # pyupgrade
|
"UP", # pyupgrade
|
||||||
]
|
]
|
||||||
|
|
||||||
ignore = [
|
lint.ignore = [
|
||||||
"ANN101", # Type annotations for self
|
"ANN101", # Type annotations for self
|
||||||
"ANN401", # Allow ANY
|
"ANN401", # Allow ANY
|
||||||
]
|
]
|
||||||
|
|
||||||
unfixable = [
|
lint.unfixable = [
|
||||||
"F401", # Don't try to automatically remove unused imports
|
"F401", # Don't try to automatically remove unused imports
|
||||||
|
"RUF100", # Unused noqa
|
||||||
|
"F841", # Unused variable
|
||||||
]
|
]
|
||||||
|
|
||||||
target-version = "py37"
|
target-version = "py38"
|
||||||
update-check = false
|
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
show_missing = true
|
show_missing = true
|
||||||
|
@ -47,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]
|
||||||
|
|
|
@ -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__ = "8.0.0"
|
__version__ = "11.1.0"
|
||||||
|
|
|
@ -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"
|
|
@ -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"""
|
|
@ -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.")
|
||||||
|
]
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from time import sleep
|
from typing import List, Optional, Union
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
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 = (
|
||||||
|
@ -19,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
|
||||||
|
@ -59,82 +58,53 @@ 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.")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class APIClient:
|
class APIClient:
|
||||||
api_endpoint: str = API_ENDPOINT
|
api_endpoint: str = API_ENDPOINT
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
headers = httpx.Headers(HEADERS)
|
||||||
|
proxy = None
|
||||||
|
if _is_onion_url(self.api_endpoint):
|
||||||
|
proxy = _get_tor_proxy()
|
||||||
|
self._httpx_client = httpx.Client(
|
||||||
|
headers=headers, proxies=proxy, timeout=TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
def server_launch(
|
def server_launch(
|
||||||
self,
|
self,
|
||||||
machine_id: str,
|
machine_id: str,
|
||||||
|
@ -144,10 +114,10 @@ class APIClient:
|
||||||
ssh_key: str,
|
ssh_key: str,
|
||||||
token: str,
|
token: str,
|
||||||
region: Optional[str] = None,
|
region: Optional[str] = None,
|
||||||
quote: bool = False,
|
|
||||||
hostname: str = "",
|
hostname: str = "",
|
||||||
autorenew: bool = False,
|
autorenew: bool = False,
|
||||||
) -> api.ServerLaunch.Response:
|
) -> None:
|
||||||
|
"""Launch a server."""
|
||||||
request = api.ServerLaunch.Request(
|
request = api.ServerLaunch.Request(
|
||||||
days=days,
|
days=days,
|
||||||
token=token,
|
token=token,
|
||||||
|
@ -155,77 +125,75 @@ class APIClient:
|
||||||
region=region,
|
region=region,
|
||||||
operating_system=operating_system,
|
operating_system=operating_system,
|
||||||
ssh_key=ssh_key,
|
ssh_key=ssh_key,
|
||||||
quote=quote,
|
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
autorenew=autorenew,
|
autorenew=autorenew,
|
||||||
)
|
)
|
||||||
url = self.api_endpoint + api.ServerLaunch.url.format(machine_id=machine_id)
|
url = self.api_endpoint + api.ServerLaunch.url.format(machine_id=machine_id)
|
||||||
response = _api_request(url=url, json_params=request.dict())
|
response = self._httpx_client.post(url=url, json=request.dict())
|
||||||
response_object = api.ServerLaunch.Response.parse_obj(response)
|
_handle_response(response)
|
||||||
assert response_object.machine_id == machine_id
|
|
||||||
return response_object
|
|
||||||
|
|
||||||
def server_topup(
|
def server_topup(
|
||||||
self,
|
self,
|
||||||
machine_id: str,
|
machine_id: str,
|
||||||
days: int,
|
days: int,
|
||||||
token: str,
|
token: Union[str, None] = None,
|
||||||
) -> api.ServerTopup.Response:
|
) -> None:
|
||||||
"""
|
"""Topup a server."""
|
||||||
Topup a server.
|
|
||||||
"""
|
|
||||||
request = api.ServerTopup.Request(days=days, token=token)
|
request = api.ServerTopup.Request(days=days, token=token)
|
||||||
url = self.api_endpoint + api.ServerTopup.url.format(machine_id=machine_id)
|
url = self.api_endpoint + api.ServerTopup.url.format(machine_id=machine_id)
|
||||||
response = _api_request(url=url, json_params=request.dict())
|
response = self._httpx_client.post(url=url, json=request.dict())
|
||||||
response_object = api.ServerTopup.Response.parse_obj(response)
|
_handle_response(response)
|
||||||
assert response_object.machine_id == machine_id
|
|
||||||
return response_object
|
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 autorenew_enable(self, machine_id: str) -> None:
|
def autorenew_enable(self, machine_id: str) -> None:
|
||||||
"""
|
"""Enable autorenew on a server."""
|
||||||
Enable autorenew on a server.
|
|
||||||
"""
|
|
||||||
url = self.api_endpoint + api.ServerEnableAutorenew.url.format(
|
url = self.api_endpoint + api.ServerEnableAutorenew.url.format(
|
||||||
machine_id=machine_id
|
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:
|
def autorenew_disable(self, machine_id: str) -> None:
|
||||||
"""
|
"""Disable autorenew on a server."""
|
||||||
Disable autorenew on a server.
|
|
||||||
"""
|
|
||||||
url = self.api_endpoint + api.ServerDisableAutorenew.url.format(
|
url = self.api_endpoint + api.ServerDisableAutorenew.url.format(
|
||||||
machine_id=machine_id
|
machine_id=machine_id
|
||||||
)
|
)
|
||||||
_api_request(url, empty_post=True)
|
response = self._httpx_client.post(url)
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
def server_start(self, machine_id: str) -> None:
|
def server_start(self, machine_id: str) -> None:
|
||||||
"""
|
"""Power on a server."""
|
||||||
Power on the server.
|
|
||||||
"""
|
|
||||||
url = self.api_endpoint + api.ServerStart.url.format(machine_id=machine_id)
|
url = self.api_endpoint + api.ServerStart.url.format(machine_id=machine_id)
|
||||||
_api_request(url, empty_post=True)
|
response = self._httpx_client.post(url)
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
def server_stop(self, machine_id: str) -> None:
|
def server_stop(self, machine_id: str) -> None:
|
||||||
"""
|
"""Power off a server."""
|
||||||
Power off the server.
|
|
||||||
"""
|
|
||||||
url = self.api_endpoint + api.ServerStop.url.format(machine_id=machine_id)
|
url = self.api_endpoint + api.ServerStop.url.format(machine_id=machine_id)
|
||||||
_api_request(url, empty_post=True)
|
response = self._httpx_client.post(url)
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
def server_delete(self, machine_id: str) -> None:
|
def server_delete(self, machine_id: str) -> None:
|
||||||
"""
|
"""Delete a server."""
|
||||||
Delete the server.
|
|
||||||
"""
|
|
||||||
url = self.api_endpoint + api.ServerDelete.url.format(machine_id=machine_id)
|
url = self.api_endpoint + api.ServerDelete.url.format(machine_id=machine_id)
|
||||||
_api_request(url, empty_post=True)
|
response = self._httpx_client.delete(url)
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
def server_forget(self, machine_id: str) -> None:
|
def server_forget(self, machine_id: str) -> None:
|
||||||
"""
|
"""Forget about a deleted server to hide it from view."""
|
||||||
Forget about a destroyed/deleted server.
|
|
||||||
"""
|
|
||||||
url = self.api_endpoint + api.ServerForget.url.format(machine_id=machine_id)
|
url = self.api_endpoint + api.ServerForget.url.format(machine_id=machine_id)
|
||||||
_api_request(url, empty_post=True)
|
response = self._httpx_client.post(url)
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
def server_rebuild(self, machine_id: str) -> None:
|
def server_rebuild(self, machine_id: str) -> None:
|
||||||
"""
|
"""
|
||||||
|
@ -234,18 +202,29 @@ class APIClient:
|
||||||
Deletes all of the data on the server!
|
Deletes all of the data on the server!
|
||||||
"""
|
"""
|
||||||
url = self.api_endpoint + api.ServerRebuild.url.format(machine_id=machine_id)
|
url = self.api_endpoint + api.ServerRebuild.url.format(machine_id=machine_id)
|
||||||
_api_request(url, empty_post=True)
|
response = self._httpx_client.post(url)
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
def server_info(self, machine_id: str) -> api.ServerInfo.Response:
|
def server_info(self, machine_id: str) -> api.ServerInfo.Response:
|
||||||
"""
|
"""Returns info about the server."""
|
||||||
Returns info about the server.
|
|
||||||
"""
|
|
||||||
url = self.api_endpoint + api.ServerInfo.url.format(machine_id=machine_id)
|
url = self.api_endpoint + api.ServerInfo.url.format(machine_id=machine_id)
|
||||||
response = _api_request(url)
|
response = self._httpx_client.get(url)
|
||||||
response_object = api.ServerInfo.Response.parse_obj(response)
|
_handle_response(response)
|
||||||
assert response_object.machine_id == machine_id
|
response_object = api.ServerInfo.Response.parse_obj(response.json())
|
||||||
return response_object
|
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(
|
def servers_launched_from_token(
|
||||||
self, token: str
|
self, token: str
|
||||||
) -> api.ServersLaunchedFromToken.Response:
|
) -> api.ServersLaunchedFromToken.Response:
|
||||||
|
@ -253,45 +232,100 @@ class APIClient:
|
||||||
Returns info of servers launched from a given token.
|
Returns info of servers launched from a given token.
|
||||||
"""
|
"""
|
||||||
url = self.api_endpoint + api.ServersLaunchedFromToken.url.format(token=token)
|
url = self.api_endpoint + api.ServersLaunchedFromToken.url.format(token=token)
|
||||||
response = _api_request(url)
|
response = self._httpx_client.get(url)
|
||||||
response_object = api.ServersLaunchedFromToken.Response.parse_obj(response)
|
_handle_response(response)
|
||||||
|
response_object = api.ServersLaunchedFromToken.Response.parse_obj(
|
||||||
|
response.json()
|
||||||
|
)
|
||||||
return response_object
|
return response_object
|
||||||
|
|
||||||
def flavors(self) -> api.Flavors.Response:
|
def flavors(self) -> api.Flavors.Response:
|
||||||
"""
|
"""Returns available flavors (server sizes)."""
|
||||||
Returns available flavors.
|
|
||||||
"""
|
|
||||||
url = self.api_endpoint + api.Flavors.url
|
url = self.api_endpoint + api.Flavors.url
|
||||||
response = _api_request(url)
|
response = self._httpx_client.get(url)
|
||||||
response_object = api.Flavors.Response.parse_obj(response)
|
_handle_response(response)
|
||||||
|
response_object = api.Flavors.Response.parse_obj(response.json())
|
||||||
return response_object
|
return response_object
|
||||||
|
|
||||||
def operating_systems(self) -> api.OperatingSystems.Response:
|
def operating_systems(self) -> api.OperatingSystems.Response:
|
||||||
"""
|
"""Returns available operating systems."""
|
||||||
Returns available operating systems.
|
|
||||||
"""
|
|
||||||
url = self.api_endpoint + api.OperatingSystems.url
|
url = self.api_endpoint + api.OperatingSystems.url
|
||||||
response = _api_request(url)
|
response = self._httpx_client.get(url)
|
||||||
response_object = api.OperatingSystems.Response.parse_obj(response)
|
_handle_response(response)
|
||||||
|
response_object = api.OperatingSystems.Response.parse_obj(response.json())
|
||||||
return response_object
|
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(
|
def token_add(
|
||||||
self,
|
self,
|
||||||
token: str,
|
token: str,
|
||||||
dollars: int,
|
dollars: int,
|
||||||
currency: str,
|
currency: Currency,
|
||||||
retry: bool = False,
|
|
||||||
) -> api.TokenAdd.Response:
|
) -> api.TokenAdd.Response:
|
||||||
request = api.TokenAdd.Request(dollars=dollars, currency=currency)
|
"""Add balance (money) to a token."""
|
||||||
url = self.api_endpoint + api.TokenAdd.url.format(token=token)
|
url = self.api_endpoint + api.TokenAdd.url.format(token=token)
|
||||||
response = _api_request(url=url, json_params=request.dict(), retry=retry)
|
request = api.TokenAdd.Request(dollars=dollars, currency=currency)
|
||||||
response_object = api.TokenAdd.Response.parse_obj(response)
|
response = self._httpx_client.post(url, json=request.dict())
|
||||||
assert response_object.token == token
|
_handle_response(response)
|
||||||
|
response_object = api.TokenAdd.Response.parse_obj(response.json())
|
||||||
return response_object
|
return response_object
|
||||||
|
|
||||||
def token_balance(self, token: str) -> api.TokenBalance.Response:
|
def token_balance(self, token: str) -> api.TokenBalance.Response:
|
||||||
|
"""Return a token's balance."""
|
||||||
url = self.api_endpoint + api.TokenBalance.url.format(token=token)
|
url = self.api_endpoint + api.TokenBalance.url.format(token=token)
|
||||||
response = _api_request(url=url)
|
response = self._httpx_client.get(url)
|
||||||
response_object = api.TokenBalance.Response.parse_obj(response)
|
_handle_response(response)
|
||||||
assert response_object.token == token
|
response_object = api.TokenBalance.Response.parse_obj(response.json())
|
||||||
return response_object
|
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
|
@ -1,27 +1,32 @@
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import List, Union
|
from typing import List, Union
|
||||||
|
|
||||||
from . import api
|
from . import api
|
||||||
from .api_client import APIClient
|
from .api_client import APIClient
|
||||||
|
from .models import Currency, Invoice, TokenInfo
|
||||||
from .utils import random_machine_id, random_token
|
from .utils import random_machine_id, random_token
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Server:
|
class Server:
|
||||||
machine_id: str
|
machine_id: str
|
||||||
api_client: APIClient = APIClient()
|
api_client: APIClient = field(default_factory=APIClient)
|
||||||
token: Union[str, None] = None
|
token: Union[str, None] = None
|
||||||
|
|
||||||
def info(self) -> api.ServerInfo.Response:
|
def info(self) -> api.ServerInfo.Response:
|
||||||
|
"""Returns information about the server."""
|
||||||
return self.api_client.server_info(self.machine_id)
|
return self.api_client.server_info(self.machine_id)
|
||||||
|
|
||||||
def rebuild(self) -> None:
|
def rebuild(self) -> None:
|
||||||
|
"""Delete all data on the server and reinstall it."""
|
||||||
self.api_client.server_rebuild(self.machine_id)
|
self.api_client.server_rebuild(self.machine_id)
|
||||||
|
|
||||||
def forget(self) -> None:
|
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)
|
self.api_client.server_forget(self.machine_id)
|
||||||
|
|
||||||
def delete(self) -> None:
|
def delete(self) -> None:
|
||||||
|
"""Delete the server."""
|
||||||
self.api_client.server_delete(self.machine_id)
|
self.api_client.server_delete(self.machine_id)
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
|
@ -34,15 +39,24 @@ class Server:
|
||||||
|
|
||||||
def autorenew_enable(self) -> None:
|
def autorenew_enable(self) -> None:
|
||||||
"""Enables autorenew on the server."""
|
"""Enables autorenew on the server."""
|
||||||
self.api_client.autorenew_enable(self.machine_id)
|
self.api_client.server_update(self.machine_id, autorenew=True)
|
||||||
|
|
||||||
def autorenew_disable(self) -> None:
|
def autorenew_disable(self) -> None:
|
||||||
"""Disables autorenew on the server."""
|
"""Disables autorenew on the server."""
|
||||||
self.api_client.autorenew_disable(self.machine_id)
|
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:
|
def topup(self, days: int) -> None:
|
||||||
"""
|
"""
|
||||||
Renew the server for the amount of days specified, from the token specified.
|
Renew the server for the amount of days specified, from the token that
|
||||||
|
launched the server.
|
||||||
"""
|
"""
|
||||||
if self.token is None:
|
if self.token is None:
|
||||||
raise ValueError("token must be set to top up a server!")
|
raise ValueError("token must be set to top up a server!")
|
||||||
|
@ -53,20 +67,49 @@ class Server:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Token:
|
class Token:
|
||||||
token: str = random_token()
|
token: str = field(default_factory=random_token)
|
||||||
api_client: APIClient = APIClient()
|
api_client: APIClient = field(default_factory=APIClient)
|
||||||
|
ssh_key: Union[str, None] = None
|
||||||
|
"""SSH public key for launching new servers with."""
|
||||||
|
|
||||||
def add(self, dollars: int, currency: str) -> None:
|
def add(self, dollars: int, currency: Currency) -> Invoice:
|
||||||
"""Add to token"""
|
"""Fund the token."""
|
||||||
self.api_client.token_add(token=self.token, dollars=dollars, currency=currency)
|
response = self.api_client.token_add(
|
||||||
|
token=self.token,
|
||||||
|
dollars=dollars,
|
||||||
|
currency=currency,
|
||||||
|
)
|
||||||
|
return response.invoice
|
||||||
|
|
||||||
def balance(self) -> int:
|
def balance(self) -> int:
|
||||||
"""Returns the token's balance in cents."""
|
"""Returns the token's balance in cents."""
|
||||||
return self.api_client.token_balance(token=self.token).cents
|
return self.api_client.token_balance(token=self.token).cents
|
||||||
|
|
||||||
def servers(self) -> List[Server]:
|
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] = []
|
server_classes: List[Server] = []
|
||||||
for server in self.api_client.servers_launched_from_token(self.token).servers:
|
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_classes.append(
|
||||||
Server(
|
Server(
|
||||||
machine_id=server.machine_id,
|
machine_id=server.machine_id,
|
||||||
|
@ -78,15 +121,20 @@ class Token:
|
||||||
|
|
||||||
def launch_server(
|
def launch_server(
|
||||||
self,
|
self,
|
||||||
ssh_key: str,
|
|
||||||
flavor: str,
|
flavor: str,
|
||||||
days: int,
|
days: int,
|
||||||
operating_system: str,
|
operating_system: str,
|
||||||
|
ssh_key: Union[str, None] = None,
|
||||||
region: Union[str, None] = None,
|
region: Union[str, None] = None,
|
||||||
hostname: str = "",
|
hostname: str = "",
|
||||||
autorenew: bool = False,
|
autorenew: bool = False,
|
||||||
machine_id: str = random_machine_id(),
|
machine_id: str = random_machine_id(),
|
||||||
) -> Server:
|
) -> 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(
|
self.api_client.server_launch(
|
||||||
machine_id=machine_id,
|
machine_id=machine_id,
|
||||||
days=days,
|
days=days,
|
||||||
|
@ -101,3 +149,40 @@ class Token:
|
||||||
return Server(
|
return Server(
|
||||||
machine_id=machine_id, api_client=self.api_client, token=self.token
|
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
|
||||||
|
)
|
||||||
|
|
|
@ -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"""
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
from sporestack import api_client
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
from sporestack import api_client, exceptions
|
||||||
|
|
||||||
|
# respx seems to ignore the uri://domain if you don't specify it.
|
||||||
|
|
||||||
|
|
||||||
def test__is_onion_url() -> None:
|
def test__is_onion_url() -> None:
|
||||||
|
@ -13,3 +18,142 @@ def test__is_onion_url() -> None:
|
||||||
assert api_client._is_onion_url("http://onion.domain.com/.onion/") is False
|
assert api_client._is_onion_url("http://onion.domain.com/.onion/") is False
|
||||||
assert api_client._is_onion_url("http://me.me/file.onion/") is False
|
assert api_client._is_onion_url("http://me.me/file.onion/") is False
|
||||||
assert api_client._is_onion_url("http://me.me/file.onion") is False
|
assert api_client._is_onion_url("http://me.me/file.onion") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_response_error_text() -> None:
|
||||||
|
assert (
|
||||||
|
api_client._get_response_error_text(
|
||||||
|
httpx.Response(status_code=422, text="just text")
|
||||||
|
)
|
||||||
|
== "just text"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
api_client._get_response_error_text(
|
||||||
|
httpx.Response(status_code=422, json={"detail": "detail text"})
|
||||||
|
)
|
||||||
|
== "detail text"
|
||||||
|
)
|
||||||
|
|
||||||
|
# This may not be the best behavior overall.
|
||||||
|
assert (
|
||||||
|
api_client._get_response_error_text(
|
||||||
|
httpx.Response(status_code=422, json={"detail": {"msg": "nested message"}})
|
||||||
|
)
|
||||||
|
== "{'msg': 'nested message'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_response() -> None:
|
||||||
|
with pytest.raises(exceptions.SporeStackServerError, match="What is this?"):
|
||||||
|
api_client._handle_response(
|
||||||
|
httpx.Response(status_code=100, text="What is this?")
|
||||||
|
)
|
||||||
|
|
||||||
|
api_client._handle_response(httpx.Response(status_code=200))
|
||||||
|
api_client._handle_response(httpx.Response(status_code=201))
|
||||||
|
api_client._handle_response(httpx.Response(status_code=204))
|
||||||
|
|
||||||
|
with pytest.raises(exceptions.SporeStackUserError, match="Invalid arguments"):
|
||||||
|
api_client._handle_response(
|
||||||
|
httpx.Response(status_code=400, text="Invalid arguments")
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(exceptions.SporeStackUserError, match="Invalid arguments"):
|
||||||
|
api_client._handle_response(
|
||||||
|
httpx.Response(status_code=422, text="Invalid arguments")
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
exceptions.SporeStackTooManyRequestsError, match="Too many requests"
|
||||||
|
):
|
||||||
|
api_client._handle_response(
|
||||||
|
httpx.Response(status_code=429, text="Too many requests")
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(exceptions.SporeStackServerError, match="Try again"):
|
||||||
|
api_client._handle_response(httpx.Response(status_code=500, text="Try again"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_info(respx_mock: respx.MockRouter) -> None:
|
||||||
|
dummy_token = "dummyinvalidtoken"
|
||||||
|
response_json = {
|
||||||
|
"balance_cents": 0,
|
||||||
|
"balance_usd": "$0.00",
|
||||||
|
"servers": 0,
|
||||||
|
"autorenew_servers": 0,
|
||||||
|
"suspended_servers": 0,
|
||||||
|
"burn_rate_usd": "$0.00",
|
||||||
|
"burn_rate_cents": 0,
|
||||||
|
"days_remaining": 0,
|
||||||
|
}
|
||||||
|
route_response = httpx.Response(200, json=response_json)
|
||||||
|
route = respx_mock.get(f"/token/{dummy_token}/info").mock(
|
||||||
|
return_value=route_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = api_client.APIClient()
|
||||||
|
info_response = client.token_info(dummy_token)
|
||||||
|
assert info_response.balance_cents == 0
|
||||||
|
assert info_response.balance_usd == "$0.00"
|
||||||
|
assert info_response.burn_rate_cents == 0
|
||||||
|
assert info_response.burn_rate_usd == "$0.00"
|
||||||
|
assert info_response.servers == 0
|
||||||
|
assert info_response.days_remaining == 0
|
||||||
|
|
||||||
|
assert route.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_info(respx_mock: respx.MockRouter) -> None:
|
||||||
|
dummy_machine_id = "dummyinvalidmachineid"
|
||||||
|
flavor = {
|
||||||
|
"slug": "a flavor slug",
|
||||||
|
"cores": 1,
|
||||||
|
"memory": 1024,
|
||||||
|
"disk": 25,
|
||||||
|
"price": 38,
|
||||||
|
"ipv4": "/32",
|
||||||
|
"ipv6": "/128",
|
||||||
|
"bandwidth_per_month": 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
response_json = {
|
||||||
|
"machine_id": dummy_machine_id,
|
||||||
|
"hostname": "a hostname",
|
||||||
|
"flavor": flavor,
|
||||||
|
"region": "a region",
|
||||||
|
"token": "a token",
|
||||||
|
"running": True,
|
||||||
|
"created_at": 1,
|
||||||
|
"expiration": 2,
|
||||||
|
"autorenew": False,
|
||||||
|
"ipv4": "0.0.0.0",
|
||||||
|
"ipv6": "::0",
|
||||||
|
"deleted": False,
|
||||||
|
"deleted_at": 0,
|
||||||
|
"deleted_by": None,
|
||||||
|
"forgotten_at": None,
|
||||||
|
"suspended_at": None,
|
||||||
|
"operating_system": "debian-11",
|
||||||
|
}
|
||||||
|
route_response = httpx.Response(200, json=response_json)
|
||||||
|
route = respx_mock.get(f"/server/{dummy_machine_id}/info").mock(
|
||||||
|
return_value=route_response
|
||||||
|
)
|
||||||
|
|
||||||
|
client = api_client.APIClient()
|
||||||
|
info_response = client.server_info(dummy_machine_id)
|
||||||
|
# These aren't exhaustive, but there's a number here.
|
||||||
|
assert info_response.machine_id == dummy_machine_id
|
||||||
|
assert info_response.hostname == response_json["hostname"]
|
||||||
|
assert info_response.flavor.dict() == response_json["flavor"]
|
||||||
|
assert info_response.token == response_json["token"]
|
||||||
|
assert info_response.running == response_json["running"]
|
||||||
|
assert info_response.created_at == response_json["created_at"]
|
||||||
|
assert info_response.expiration == response_json["expiration"]
|
||||||
|
assert info_response.autorenew == response_json["autorenew"]
|
||||||
|
assert info_response.forgotten_at == response_json["forgotten_at"]
|
||||||
|
# Not sure why mypy dislikes this. It passes pytest.
|
||||||
|
assert info_response.flavor.slug == response_json["flavor"]["slug"] # type: ignore
|
||||||
|
|
||||||
|
assert route.called
|
||||||
|
|
|
@ -36,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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
@ -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
|
Loading…
Reference in New Issue