Compare commits
64 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 | |
Administrator | 6981ff00c7 | |
Administrator | a947d83669 | |
Administrator | ad6fb4ce2e | |
Administrator | e8a720e564 | |
Administrator | 92fbddc9e5 | |
Administrator | 5728118ec1 | |
Administrator | 8a83747243 | |
Administrator | 28892e8ff3 | |
Administrator | d8043414a8 | |
Administrator | 8cd2644c0c | |
Administrator | 7158b6953c | |
Administrator | 6582917898 | |
Administrator | 3f2cd2c20b | |
Administrator | 454a2a17c1 | |
Administrator | 3c5f69c549 | |
Administrator | 1e3514900d | |
Administrator | f62bfa0568 | |
Administrator | 94ecfccb99 | |
Administrator | 278c121afd | |
Administrator | 8d00120a13 | |
Administrator | 972f0b0a61 | |
Administrator | b3efea151e |
|
@ -8,3 +8,5 @@ dist
|
|||
__pycache__
|
||||
.pytest_cache
|
||||
.coverage
|
||||
.tox
|
||||
dummydotsporestackfolder
|
||||
|
|
|
@ -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: 8fe62d14e0b4d7d845a7022c5c2c3ae41bdd3f26 # frozen: v4.1.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/psf/black
|
||||
rev: fc0be6eb1e2a96091e6f64009ee5e9081bf8b6c6 # frozen: 22.1.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: e695ecd365119ab4e5463f6e49bea5f4b7ca786b # frozen: v2.31.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py37-plus]
|
||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||
rev: 58b14248db425913ea7502c0b1af9d6653403e07 # frozen: v1.20.0
|
||||
hooks:
|
||||
- id: setup-cfg-fmt
|
||||
- repo: https://github.com/jackdewinter/pymarkdown
|
||||
rev: cf71b3c9cb0c361c4a17eacb80ed52432b57b420 # frozen: 0.9.4
|
||||
hooks:
|
||||
- id: pymarkdown
|
||||
args: [--disable-rules=MD013, --set=plugins.md024.siblings_only=$!True, scan]
|
|
@ -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
|
||||
|
|
318
CHANGELOG.md
318
CHANGELOG.md
|
@ -5,15 +5,329 @@ 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
|
||||
|
||||
- Nothing yet.
|
||||
- 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
|
||||
|
||||
- Nothing yet.
|
||||
- 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
|
||||
|
||||
- Use specified API endpoint for `sporestack server list` command.
|
||||
|
||||
## [6.0.0a3 - 2022-04-05]
|
||||
|
||||
### Removed
|
||||
|
||||
- Get rid of deprecated TokenEnable usage.
|
||||
|
||||
## [6.0.0a2 - 2022-04-01]
|
||||
|
||||
### Added
|
||||
|
||||
- `--quote` / `--no-quote` to launch/topup. Prompt by default if price to draw from token is acceptable.
|
||||
|
||||
### Removed
|
||||
|
||||
- affiliate_amount
|
||||
|
||||
### Fixed
|
||||
|
||||
- Protect files in ~/.sporestack with aggressive `umask`.
|
||||
|
||||
## [6.0.0a1 - 2022-03-31]
|
||||
|
||||
|
|
21
Makefile
21
Makefile
|
@ -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
27
Pipfile
|
@ -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"
|
||||
|
|
File diff suppressed because it is too large
Load Diff
52
README.md
52
README.md
|
@ -1,43 +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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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"
|
||||
|
|
50
setup.cfg
50
setup.cfg
|
@ -1,50 +0,0 @@
|
|||
[metadata]
|
||||
name = sporestack
|
||||
version = 6.0.0a1
|
||||
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
|
|
@ -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"
|
||||
|
|
|
@ -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,28 +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
|
||||
|
||||
|
||||
class TokenEnable:
|
||||
url = "/token/{token}/enable"
|
||||
method = "POST"
|
||||
|
||||
class Request(BaseModel):
|
||||
currency: str
|
||||
dollars: int
|
||||
|
||||
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:
|
||||
|
@ -30,12 +20,12 @@ class TokenAdd:
|
|||
method = "POST"
|
||||
|
||||
class Request(BaseModel):
|
||||
currency: str
|
||||
currency: Currency
|
||||
dollars: int
|
||||
affiliate_token: Union[str, None] = None
|
||||
|
||||
class Response(BaseModel):
|
||||
token: str
|
||||
payment: Payment
|
||||
invoice: Invoice
|
||||
|
||||
|
||||
class TokenBalance:
|
||||
|
@ -43,42 +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
|
||||
currency: str
|
||||
flavor: str
|
||||
ssh_key: str
|
||||
operating_system: str
|
||||
region: Optional[str]
|
||||
organization: Optional[str]
|
||||
settlement_token: Optional[str]
|
||||
affiliate_amount: Optional[int]
|
||||
affiliate_token: Optional[str]
|
||||
|
||||
class Response(BaseModel):
|
||||
created_at: Optional[int]
|
||||
payment: Payment
|
||||
expiration: Optional[int]
|
||||
machine_id: str
|
||||
network_interfaces: List[NetworkInterface]
|
||||
region: str
|
||||
latest_api_version: int
|
||||
created: bool
|
||||
paid: bool
|
||||
warning: Optional[str]
|
||||
txid: Optional[str]
|
||||
operating_system: str
|
||||
flavor: str
|
||||
region: 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:
|
||||
|
@ -86,21 +85,17 @@ class ServerTopup:
|
|||
method = "POST"
|
||||
|
||||
class Request(BaseModel):
|
||||
machine_id: str
|
||||
days: int
|
||||
currency: str
|
||||
settlement_token: Optional[str]
|
||||
affiliate_amount: Optional[int]
|
||||
affiliate_token: Optional[str]
|
||||
token: Union[str, None] = None
|
||||
|
||||
class Response(BaseModel):
|
||||
machine_id: str
|
||||
payment: Payment
|
||||
paid: bool
|
||||
warning: Optional[str]
|
||||
expiration: int
|
||||
txid: Optional[str]
|
||||
latest_api_version: int
|
||||
|
||||
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:
|
||||
|
@ -112,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:
|
||||
|
@ -127,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.")
|
||||
]
|
||||
|
|
|
@ -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,219 +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
|
||||
|
||||
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
|
||||
)
|
||||
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:
|
||||
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 str(error["detail"])
|
||||
|
||||
status_code_first_digit = request.status_code // 100
|
||||
return response.text
|
||||
|
||||
|
||||
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,
|
||||
# User should probably retry.
|
||||
raise exceptions.SporeStackServerError(error_response_text)
|
||||
else:
|
||||
# This would be weird.
|
||||
raise exceptions.SporeStackServerError(error_response_text)
|
||||
|
||||
|
||||
@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
|
||||
)
|
||||
else:
|
||||
raise exceptions.SporeStackServerError(str(request.content))
|
||||
else:
|
||||
# Not sure why we'd get this.
|
||||
request.raise_for_status()
|
||||
raise Exception("Stuff broke strangely. Please contact SporeStack support.")
|
||||
|
||||
|
||||
def launch(
|
||||
def server_launch(
|
||||
self,
|
||||
machine_id: str,
|
||||
days: int,
|
||||
currency: str,
|
||||
flavor: str,
|
||||
operating_system: str,
|
||||
ssh_key: str,
|
||||
api_endpoint: str = API_ENDPOINT,
|
||||
token: str,
|
||||
region: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
retry: bool = False,
|
||||
affiliate_amount: Optional[int] = None,
|
||||
affiliate_token: Optional[str] = None,
|
||||
) -> api.ServerLaunch.Response:
|
||||
hostname: str = "",
|
||||
autorenew: bool = False,
|
||||
) -> None:
|
||||
"""Launch a server."""
|
||||
request = api.ServerLaunch.Request(
|
||||
machine_id=machine_id,
|
||||
days=days,
|
||||
currency=currency,
|
||||
settlement_token=token,
|
||||
affiliate_amount=affiliate_amount,
|
||||
affiliate_token=affiliate_token,
|
||||
token=token,
|
||||
flavor=flavor,
|
||||
region=region,
|
||||
operating_system=operating_system,
|
||||
ssh_key=ssh_key,
|
||||
hostname=hostname,
|
||||
autorenew=autorenew,
|
||||
)
|
||||
url = api_endpoint + api.ServerLaunch.url.format(machine_id=machine_id)
|
||||
response = _api_request(url=url, json_params=request.dict(), retry=retry)
|
||||
response_object = api.ServerLaunch.Response.parse_obj(response)
|
||||
assert response_object.machine_id == machine_id
|
||||
return response_object
|
||||
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 topup(
|
||||
def server_topup(
|
||||
self,
|
||||
machine_id: str,
|
||||
days: int,
|
||||
currency: str,
|
||||
api_endpoint: str = API_ENDPOINT,
|
||||
token: Optional[str] = None,
|
||||
retry: bool = False,
|
||||
affiliate_amount: Optional[int] = None,
|
||||
affiliate_token: Optional[str] = None,
|
||||
) -> api.ServerTopup.Response:
|
||||
"""
|
||||
Topup a server.
|
||||
"""
|
||||
request = api.ServerTopup.Request(
|
||||
machine_id=machine_id,
|
||||
days=days,
|
||||
currency=currency,
|
||||
settlement_token=token,
|
||||
affiliate_amount=affiliate_amount,
|
||||
affiliate_token=affiliate_token,
|
||||
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 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},
|
||||
)
|
||||
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
|
||||
_handle_response(response)
|
||||
return api.ServerQuote.Response.parse_obj(response.json())
|
||||
|
||||
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 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 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 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 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 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 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)
|
||||
|
||||
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_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 rebuild(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None:
|
||||
def server_rebuild(self, machine_id: str) -> None:
|
||||
"""
|
||||
Rebuilds the server with the operating system and SSH key set at launch time.
|
||||
|
||||
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)
|
||||
url = self.api_endpoint + api.ServerRebuild.url.format(machine_id=machine_id)
|
||||
response = self._httpx_client.post(url)
|
||||
_handle_response(response)
|
||||
|
||||
|
||||
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
|
||||
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 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 token_enable(
|
||||
token: str,
|
||||
dollars: int,
|
||||
currency: str,
|
||||
api_endpoint: str = API_ENDPOINT,
|
||||
retry: bool = False,
|
||||
) -> api.TokenEnable.Response:
|
||||
request = api.TokenEnable.Request(dollars=dollars, currency=currency)
|
||||
url = api_endpoint + api.TokenEnable.url.format(token=token)
|
||||
response = _api_request(url=url, json_params=request.dict(), retry=retry)
|
||||
response_object = api.TokenEnable.Response.parse_obj(response)
|
||||
assert response_object.token == token
|
||||
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 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 token_add(
|
||||
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: str,
|
||||
api_endpoint: str = API_ENDPOINT,
|
||||
retry: bool = False,
|
||||
) -> api.TokenAdd.Response:
|
||||
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)
|
||||
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
|
||||
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(
|
||||
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
|
||||
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
|
@ -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
|
||||
)
|
|
@ -8,6 +8,12 @@ class SporeStackUserError(SporeStackError):
|
|||
pass
|
||||
|
||||
|
||||
class SporeStackTooManyRequestsError(SporeStackError):
|
||||
"""HTTP 429, retry again later"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SporeStackServerError(SporeStackError):
|
||||
"""HTTP 5XX"""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)}"
|
||||
|
|
|
@ -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__)
|
|
@ -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,113 +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")
|
||||
)
|
||||
json_params = {
|
||||
"machine_id": "dummymachineid",
|
||||
"days": 1,
|
||||
"currency": "xmr",
|
||||
"flavor": "aflavor",
|
||||
"ssh_key": "id-rsa...",
|
||||
"operating_system": "freebsd-12",
|
||||
"region": None,
|
||||
"organization": None,
|
||||
"settlement_token": None,
|
||||
"affiliate_amount": None,
|
||||
"affiliate_token": None,
|
||||
== "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,
|
||||
}
|
||||
mock_api_request.assert_called_once_with(
|
||||
url="https://api.sporestack.com/server/dummymachineid/launch",
|
||||
json_params=json_params,
|
||||
retry=False,
|
||||
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_topup(mock_api_request: MagicMock) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
api_client.topup("dummymachineid", currency="xmr", days=1)
|
||||
json_params = {
|
||||
"machine_id": "dummymachineid",
|
||||
"days": 1,
|
||||
"currency": "xmr",
|
||||
"settlement_token": None,
|
||||
"affiliate_amount": None,
|
||||
"affiliate_token": None,
|
||||
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,
|
||||
}
|
||||
mock_api_request.assert_called_once_with(
|
||||
url="https://api.sporestack.com/server/dummymachineid/topup",
|
||||
json_params=json_params,
|
||||
retry=False,
|
||||
|
||||
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_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
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
|
||||
@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"
|
||||
)
|
||||
|
||||
|
||||
@patch("sporestack.api_client._api_request")
|
||||
def test_token_enable(mock_api_request: MagicMock) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
api_client.token_enable("dummytoken", currency="xmr", dollars=20)
|
||||
json_params = {"currency": "xmr", "dollars": 20}
|
||||
mock_api_request.assert_called_once_with(
|
||||
url="https://api.sporestack.com/token/dummytoken/enable",
|
||||
json_params=json_params,
|
||||
retry=False,
|
||||
)
|
||||
assert route.called
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
|
@ -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"
|
||||
|
|
|
@ -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