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__
|
__pycache__
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
.coverage
|
.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:
|
steps:
|
||||||
python-3.7:
|
python-3.8:
|
||||||
group: test
|
image: python:3.8
|
||||||
image: python:3.7
|
|
||||||
commands:
|
commands:
|
||||||
- pip install pipenv==2022.1.8
|
- pip install pipenv==2023.12.1 tomli
|
||||||
- pipenv install --dev --deploy
|
- pipenv install --dev --deploy
|
||||||
- pipenv run almake test-pytest # We only test with pytest on 3.7
|
- pipenv run almake test-typing
|
||||||
|
- pipenv run almake test-pytest
|
||||||
# More than three jobs seems to cause issues with Woodpecker?
|
- pipenv run almake build-dist
|
||||||
# python-3.8:
|
- sha256sum dist/*
|
||||||
# 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/*
|
|
||||||
|
|
||||||
python-3.9:
|
python-3.9:
|
||||||
group: test
|
|
||||||
image: python:3.9
|
image: python:3.9
|
||||||
commands:
|
commands:
|
||||||
- pip install pipenv==2022.1.8 pre-commit==2.17.0
|
- pip install pipenv==2023.12.1
|
||||||
- pre-commit run --all-files
|
|
||||||
- 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.1.8 pre-commit==2.17.0
|
- pip install pipenv==2023.12.1
|
||||||
- pre-commit run --all-files
|
- 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 install --dev --deploy
|
||||||
- pipenv run almake test
|
- pipenv run almake test
|
||||||
- pipenv run almake build-dist
|
- 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/),
|
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
|
### 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
|
### 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]
|
## [6.0.0a1 - 2022-03-31]
|
||||||
|
|
||||||
|
|
21
Makefile
21
Makefile
|
@ -1,16 +1,27 @@
|
||||||
|
format:
|
||||||
|
black .
|
||||||
|
ruff check --fix .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
python -m pflake8 .
|
black --check .
|
||||||
python -m mypy --strict .
|
ruff check .
|
||||||
|
$(MAKE) test-typing
|
||||||
$(MAKE) test-pytest
|
$(MAKE) test-pytest
|
||||||
|
|
||||||
|
test-typing:
|
||||||
|
mypy
|
||||||
|
|
||||||
test-pytest:
|
test-pytest:
|
||||||
python -m pytest --cov=sporestack --cov-fail-under=49 --cov-report=term --durations=3 --cache-clear
|
python -m pytest --cov=sporestack --cov-fail-under=39 --cov-report=term --durations=3 --cache-clear
|
||||||
|
|
||||||
build-dist:
|
build-dist:
|
||||||
rm dist/* || true
|
rm dist/* || true
|
||||||
# This should result in a reproducible wheel.
|
# This should result in a reproducible wheel.
|
||||||
SOURCE_DATE_EPOCH=1309379017 python -m build --no-isolation
|
SOURCE_DATE_EPOCH=$$(git log -1 --format=%ct) flit build
|
||||||
python -m twine check --strict dist/*
|
|
||||||
|
# This shouldn't be needed often, but is nice for validation.
|
||||||
|
twine-check:
|
||||||
|
twine check --strict dist/*
|
||||||
|
|
||||||
servedocs:
|
servedocs:
|
||||||
pdoc sporestack
|
pdoc sporestack
|
||||||
|
|
27
Pipfile
27
Pipfile
|
@ -7,24 +7,25 @@ name = "pypi"
|
||||||
sporestack = {editable = true, path = "."}
|
sporestack = {editable = true, path = "."}
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
flake8 = "~=4.0"
|
black = "~=24.0"
|
||||||
pyproject-flake8 = "==0.0.1a2"
|
mypy = "~=1.0"
|
||||||
flake8-noqa = "~=1.2"
|
pytest = "~=8.0"
|
||||||
pep8-naming = "~=0.12.1"
|
pytest-cov = "~=4.0"
|
||||||
mypy = "==0.942"
|
pytest-mock = "~=3.6"
|
||||||
pytest = "~=6.2"
|
pytest-socket = "~=0.7.0"
|
||||||
pytest-cov = "~=3.0"
|
ruff = "~=0.3.4"
|
||||||
|
|
||||||
types-requests = "~=2.25"
|
respx = "~=0.20.1"
|
||||||
|
|
||||||
# Building
|
# Building
|
||||||
wheel = "~=0.37.0"
|
flit = "~=3.8"
|
||||||
build = "~=0.7.0"
|
wheel = "*"
|
||||||
|
build = "~=1.0"
|
||||||
# Publishing
|
# Publishing
|
||||||
twine = "~=3.4"
|
twine = "~=5.0"
|
||||||
|
|
||||||
# Docs
|
# Docs
|
||||||
pdoc = "~=9.0"
|
pdoc = "~=14.0"
|
||||||
|
|
||||||
# Python `make` implementation
|
# Python `make` implementation
|
||||||
almost-make = "~=0.5.1"
|
almost-make = "~=0.5.2"
|
||||||
|
|
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
|
## 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
|
|
@ -1,23 +1,42 @@
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = "--strict-markers --disable-socket"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
lint.select = [
|
||||||
|
"F", # pyflakes
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"I", # isort
|
||||||
|
"N", # pep8-naming
|
||||||
|
"RUF", # Unused noqa + more
|
||||||
|
"ANN", # Type annotations
|
||||||
|
"UP", # pyupgrade
|
||||||
|
]
|
||||||
|
|
||||||
|
lint.ignore = [
|
||||||
|
"ANN101", # Type annotations for self
|
||||||
|
"ANN401", # Allow ANY
|
||||||
|
]
|
||||||
|
|
||||||
|
lint.unfixable = [
|
||||||
|
"F401", # Don't try to automatically remove unused imports
|
||||||
|
"RUF100", # Unused noqa
|
||||||
|
"F841", # Unused variable
|
||||||
|
]
|
||||||
|
|
||||||
|
target-version = "py38"
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
show_missing = true
|
show_missing = true
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
omit = ["tests/*", "build/*"]
|
omit = ["tests/*", "build/*"]
|
||||||
|
|
||||||
# Have to use `pflake8` instead of `flake8`
|
|
||||||
[tool.flake8]
|
|
||||||
max-line-length = 88
|
|
||||||
noqa-require-code = "true"
|
|
||||||
exclude = ".git,__pycache__,build,dist"
|
|
||||||
max-complexity = 15
|
|
||||||
|
|
||||||
[tool.isort]
|
|
||||||
profile = "black"
|
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
files = "."
|
files = "."
|
||||||
plugins = ["pydantic.mypy"]
|
plugins = ["pydantic.mypy"]
|
||||||
exclude = "(build|site-packages|__pycache__)"
|
exclude = "(build|site-packages|__pycache__)"
|
||||||
|
strict = true
|
||||||
|
|
||||||
[tool.pydantic-mypy]
|
[tool.pydantic-mypy]
|
||||||
init_forbid_extra = true
|
init_forbid_extra = true
|
||||||
|
@ -25,6 +44,34 @@ init_typed = true
|
||||||
warn_required_dynamic_aliases = true
|
warn_required_dynamic_aliases = true
|
||||||
warn_untyped_fields = 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]
|
[build-system]
|
||||||
requires = ["setuptools", "wheel"]
|
requires = ["flit_core >=3.2,<4"]
|
||||||
build-backend = "setuptools.build_meta"
|
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
|
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 NetworkInterface, Payment
|
|
||||||
|
|
||||||
|
|
||||||
class TokenEnable:
|
|
||||||
url = "/token/{token}/enable"
|
|
||||||
method = "POST"
|
|
||||||
|
|
||||||
class Request(BaseModel):
|
|
||||||
currency: str
|
|
||||||
dollars: int
|
|
||||||
|
|
||||||
class Response(BaseModel):
|
|
||||||
token: str
|
|
||||||
payment: Payment
|
|
||||||
|
|
||||||
|
|
||||||
class TokenAdd:
|
class TokenAdd:
|
||||||
|
@ -30,12 +20,12 @@ class TokenAdd:
|
||||||
method = "POST"
|
method = "POST"
|
||||||
|
|
||||||
class Request(BaseModel):
|
class Request(BaseModel):
|
||||||
currency: str
|
currency: Currency
|
||||||
dollars: int
|
dollars: int
|
||||||
|
affiliate_token: Union[str, None] = None
|
||||||
|
|
||||||
class Response(BaseModel):
|
class Response(BaseModel):
|
||||||
token: str
|
invoice: Invoice
|
||||||
payment: Payment
|
|
||||||
|
|
||||||
|
|
||||||
class TokenBalance:
|
class TokenBalance:
|
||||||
|
@ -43,42 +33,51 @@ 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"
|
||||||
|
|
||||||
class Request(BaseModel):
|
class Request(BaseModel):
|
||||||
machine_id: str
|
|
||||||
days: int
|
days: int
|
||||||
currency: str
|
|
||||||
flavor: str
|
flavor: str
|
||||||
ssh_key: str
|
ssh_key: str
|
||||||
operating_system: str
|
operating_system: str
|
||||||
region: Optional[str]
|
region: Optional[str] = None
|
||||||
organization: Optional[str]
|
"""null is automatic, otherwise a string region slug."""
|
||||||
settlement_token: Optional[str]
|
token: str
|
||||||
affiliate_amount: Optional[int]
|
"""Token to draw from when launching the server."""
|
||||||
affiliate_token: Optional[str]
|
hostname: str = ""
|
||||||
|
"""Hostname to refer to your server by."""
|
||||||
class Response(BaseModel):
|
autorenew: bool = False
|
||||||
created_at: Optional[int]
|
"""
|
||||||
payment: Payment
|
Automatically renew the server with the token used, keeping it at 1 week
|
||||||
expiration: Optional[int]
|
expiration.
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class ServerTopup:
|
class ServerTopup:
|
||||||
|
@ -86,21 +85,17 @@ class ServerTopup:
|
||||||
method = "POST"
|
method = "POST"
|
||||||
|
|
||||||
class Request(BaseModel):
|
class Request(BaseModel):
|
||||||
machine_id: str
|
|
||||||
days: int
|
days: int
|
||||||
currency: str
|
token: Union[str, None] = None
|
||||||
settlement_token: Optional[str]
|
|
||||||
affiliate_amount: Optional[int]
|
|
||||||
affiliate_token: Optional[str]
|
|
||||||
|
|
||||||
class Response(BaseModel):
|
|
||||||
machine_id: str
|
class ServerDeletedBy(str, Enum):
|
||||||
payment: Payment
|
EXPIRATION = "expiration"
|
||||||
paid: bool
|
"""The server was deleted automatically for being expired."""
|
||||||
warning: Optional[str]
|
MANUAL = "manual"
|
||||||
expiration: int
|
"""The server was deleted before its expiration via the API."""
|
||||||
txid: Optional[str]
|
SPORESTACK = "sporestack"
|
||||||
latest_api_version: int
|
"""The server was deleted by SporeStack, likely due to an AUP violation."""
|
||||||
|
|
||||||
|
|
||||||
class ServerInfo:
|
class ServerInfo:
|
||||||
|
@ -112,8 +107,18 @@ class ServerInfo:
|
||||||
expiration: int
|
expiration: int
|
||||||
running: bool
|
running: bool
|
||||||
machine_id: str
|
machine_id: str
|
||||||
network_interfaces: List[NetworkInterface]
|
token: str
|
||||||
|
ipv4: str
|
||||||
|
ipv6: str
|
||||||
region: 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:
|
class ServerStart:
|
||||||
|
@ -127,10 +132,83 @@ class ServerStop:
|
||||||
|
|
||||||
|
|
||||||
class ServerDelete:
|
class ServerDelete:
|
||||||
url = "/server/{machine_id}/delete"
|
url = "/server/{machine_id}"
|
||||||
|
method = "DELETE"
|
||||||
|
|
||||||
|
|
||||||
|
class ServerForget:
|
||||||
|
url = "/server/{machine_id}/forget"
|
||||||
method = "POST"
|
method = "POST"
|
||||||
|
|
||||||
|
|
||||||
class ServerRebuild:
|
class ServerRebuild:
|
||||||
url = "/server/{machine_id}/rebuild"
|
url = "/server/{machine_id}/rebuild"
|
||||||
method = "POST"
|
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 logging
|
||||||
import os
|
import os
|
||||||
from time import sleep
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, Optional
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
import requests
|
import httpx
|
||||||
|
from pydantic import parse_obj_as
|
||||||
|
|
||||||
from . import api, exceptions
|
from . import __version__, api, exceptions
|
||||||
from .version import __version__
|
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,16 +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__}"}
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
@ -56,219 +58,274 @@ def _is_onion_url(url: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _api_request(
|
def _get_response_error_text(response: httpx.Response) -> str:
|
||||||
url: str,
|
"""Get a response's error text. Assumes the response is actually an error."""
|
||||||
empty_post: bool = False,
|
if (
|
||||||
json_params: Optional[Dict[str, Any]] = None,
|
"content-type" in response.headers
|
||||||
retry: bool = False,
|
and response.headers["content-type"] == "application/json"
|
||||||
) -> Any:
|
):
|
||||||
headers = {"User-Agent": f"sporestack-python/{__version__}"}
|
error = response.json()
|
||||||
proxies = {}
|
if "detail" in error:
|
||||||
if _is_onion_url(url) is True:
|
if isinstance(error["detail"], str):
|
||||||
log.debug("Got a .onion API endpoint, using local Tor SOCKS proxy.")
|
return error["detail"]
|
||||||
proxies = TOR_PROXY_REQUESTS
|
else:
|
||||||
|
return str(error["detail"])
|
||||||
|
|
||||||
try:
|
return response.text
|
||||||
if empty_post is True:
|
|
||||||
request = requests.post(
|
|
||||||
url, timeout=POST_TIMEOUT, proxies=proxies, headers=headers
|
|
||||||
)
|
|
||||||
elif json_params is None:
|
|
||||||
request = requests.get(
|
|
||||||
url, timeout=GET_TIMEOUT, proxies=proxies, headers=headers
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
request = requests.post(
|
|
||||||
url,
|
|
||||||
json=json_params,
|
|
||||||
timeout=POST_TIMEOUT,
|
|
||||||
proxies=proxies,
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
if retry is True:
|
|
||||||
log.warning(f"Got an error, but retrying: {e}")
|
|
||||||
sleep(5)
|
|
||||||
# Try again.
|
|
||||||
return _api_request(
|
|
||||||
url,
|
|
||||||
empty_post=empty_post,
|
|
||||||
json_params=json_params,
|
|
||||||
retry=retry,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
status_code_first_digit = request.status_code // 100
|
|
||||||
|
def _handle_response(response: httpx.Response) -> None:
|
||||||
|
status_code_first_digit = response.status_code // 100
|
||||||
if status_code_first_digit == 2:
|
if status_code_first_digit == 2:
|
||||||
try:
|
return
|
||||||
return request.json()
|
|
||||||
except Exception:
|
error_response_text = _get_response_error_text(response)
|
||||||
return request.content
|
if response.status_code == 429:
|
||||||
|
raise exceptions.SporeStackTooManyRequestsError(error_response_text)
|
||||||
elif status_code_first_digit == 4:
|
elif status_code_first_digit == 4:
|
||||||
log.debug("HTTP status code: {request.status_code}")
|
raise exceptions.SporeStackUserError(error_response_text)
|
||||||
raise exceptions.SporeStackUserError(request.content.decode("utf-8"))
|
|
||||||
elif status_code_first_digit == 5:
|
elif status_code_first_digit == 5:
|
||||||
if retry is True:
|
# User should probably retry.
|
||||||
log.warning(request.content.decode("utf-8"))
|
raise exceptions.SporeStackServerError(error_response_text)
|
||||||
log.warning("Got a 500, retrying in 5 seconds...")
|
|
||||||
sleep(5)
|
|
||||||
# Try again if we get a 500
|
|
||||||
return _api_request(
|
|
||||||
url,
|
|
||||||
empty_post=empty_post,
|
|
||||||
json_params=json_params,
|
|
||||||
retry=retry,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise exceptions.SporeStackServerError(str(request.content))
|
|
||||||
else:
|
else:
|
||||||
# Not sure why we'd get this.
|
# This would be weird.
|
||||||
request.raise_for_status()
|
raise exceptions.SporeStackServerError(error_response_text)
|
||||||
raise Exception("Stuff broke strangely. Please contact SporeStack support.")
|
|
||||||
|
|
||||||
|
|
||||||
def launch(
|
@dataclass
|
||||||
machine_id: str,
|
class APIClient:
|
||||||
days: int,
|
api_endpoint: str = API_ENDPOINT
|
||||||
currency: str,
|
|
||||||
flavor: str,
|
|
||||||
operating_system: str,
|
|
||||||
ssh_key: str,
|
|
||||||
api_endpoint: str = API_ENDPOINT,
|
|
||||||
region: Optional[str] = None,
|
|
||||||
token: Optional[str] = None,
|
|
||||||
retry: bool = False,
|
|
||||||
affiliate_amount: Optional[int] = None,
|
|
||||||
affiliate_token: Optional[str] = None,
|
|
||||||
) -> api.ServerLaunch.Response:
|
|
||||||
request = api.ServerLaunch.Request(
|
|
||||||
machine_id=machine_id,
|
|
||||||
days=days,
|
|
||||||
currency=currency,
|
|
||||||
settlement_token=token,
|
|
||||||
affiliate_amount=affiliate_amount,
|
|
||||||
affiliate_token=affiliate_token,
|
|
||||||
flavor=flavor,
|
|
||||||
region=region,
|
|
||||||
operating_system=operating_system,
|
|
||||||
ssh_key=ssh_key,
|
|
||||||
)
|
|
||||||
url = api_endpoint + api.ServerLaunch.url.format(machine_id=machine_id)
|
|
||||||
response = _api_request(url=url, json_params=request.dict(), retry=retry)
|
|
||||||
response_object = api.ServerLaunch.Response.parse_obj(response)
|
|
||||||
assert response_object.machine_id == machine_id
|
|
||||||
return response_object
|
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
headers = httpx.Headers(HEADERS)
|
||||||
|
proxy = None
|
||||||
|
if _is_onion_url(self.api_endpoint):
|
||||||
|
proxy = _get_tor_proxy()
|
||||||
|
self._httpx_client = httpx.Client(
|
||||||
|
headers=headers, proxies=proxy, timeout=TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
def topup(
|
def server_launch(
|
||||||
machine_id: str,
|
self,
|
||||||
days: int,
|
machine_id: str,
|
||||||
currency: str,
|
days: int,
|
||||||
api_endpoint: str = API_ENDPOINT,
|
flavor: str,
|
||||||
token: Optional[str] = None,
|
operating_system: str,
|
||||||
retry: bool = False,
|
ssh_key: str,
|
||||||
affiliate_amount: Optional[int] = None,
|
token: str,
|
||||||
affiliate_token: Optional[str] = None,
|
region: Optional[str] = None,
|
||||||
) -> api.ServerTopup.Response:
|
hostname: str = "",
|
||||||
"""
|
autorenew: bool = False,
|
||||||
Topup a server.
|
) -> None:
|
||||||
"""
|
"""Launch a server."""
|
||||||
request = api.ServerTopup.Request(
|
request = api.ServerLaunch.Request(
|
||||||
machine_id=machine_id,
|
days=days,
|
||||||
days=days,
|
token=token,
|
||||||
currency=currency,
|
flavor=flavor,
|
||||||
settlement_token=token,
|
region=region,
|
||||||
affiliate_amount=affiliate_amount,
|
operating_system=operating_system,
|
||||||
affiliate_token=affiliate_token,
|
ssh_key=ssh_key,
|
||||||
)
|
hostname=hostname,
|
||||||
url = api_endpoint + api.ServerTopup.url.format(machine_id=machine_id)
|
autorenew=autorenew,
|
||||||
response = _api_request(url=url, json_params=request.dict(), retry=retry)
|
)
|
||||||
response_object = api.ServerTopup.Response.parse_obj(response)
|
url = self.api_endpoint + api.ServerLaunch.url.format(machine_id=machine_id)
|
||||||
assert response_object.machine_id == machine_id
|
response = self._httpx_client.post(url=url, json=request.dict())
|
||||||
return response_object
|
_handle_response(response)
|
||||||
|
|
||||||
|
def server_topup(
|
||||||
|
self,
|
||||||
|
machine_id: str,
|
||||||
|
days: int,
|
||||||
|
token: Union[str, None] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Topup a server."""
|
||||||
|
request = api.ServerTopup.Request(days=days, token=token)
|
||||||
|
url = self.api_endpoint + api.ServerTopup.url.format(machine_id=machine_id)
|
||||||
|
response = self._httpx_client.post(url=url, json=request.dict())
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
def start(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None:
|
def server_quote(self, days: int, flavor: str) -> api.ServerQuote.Response:
|
||||||
"""
|
"""Get a quote for how much a server will cost."""
|
||||||
Boots the server.
|
|
||||||
"""
|
|
||||||
url = api_endpoint + api.ServerStart.url.format(machine_id=machine_id)
|
|
||||||
_api_request(url, empty_post=True)
|
|
||||||
|
|
||||||
|
url = self.api_endpoint + api.ServerQuote.url
|
||||||
|
response = self._httpx_client.get(
|
||||||
|
url,
|
||||||
|
params={"days": days, "flavor": flavor},
|
||||||
|
)
|
||||||
|
_handle_response(response)
|
||||||
|
return api.ServerQuote.Response.parse_obj(response.json())
|
||||||
|
|
||||||
def stop(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None:
|
def autorenew_enable(self, machine_id: str) -> None:
|
||||||
"""
|
"""Enable autorenew on a server."""
|
||||||
Powers off the server.
|
url = self.api_endpoint + api.ServerEnableAutorenew.url.format(
|
||||||
"""
|
machine_id=machine_id
|
||||||
url = 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 autorenew_disable(self, machine_id: str) -> None:
|
||||||
|
"""Disable autorenew on a server."""
|
||||||
|
url = self.api_endpoint + api.ServerDisableAutorenew.url.format(
|
||||||
|
machine_id=machine_id
|
||||||
|
)
|
||||||
|
response = self._httpx_client.post(url)
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
def delete(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None:
|
def server_start(self, machine_id: str) -> None:
|
||||||
"""
|
"""Power on a server."""
|
||||||
Deletes the server.
|
url = self.api_endpoint + api.ServerStart.url.format(machine_id=machine_id)
|
||||||
"""
|
response = self._httpx_client.post(url)
|
||||||
url = api_endpoint + api.ServerDelete.url.format(machine_id=machine_id)
|
_handle_response(response)
|
||||||
_api_request(url, empty_post=True)
|
|
||||||
|
|
||||||
|
def server_stop(self, machine_id: str) -> None:
|
||||||
|
"""Power off a server."""
|
||||||
|
url = self.api_endpoint + api.ServerStop.url.format(machine_id=machine_id)
|
||||||
|
response = self._httpx_client.post(url)
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
def rebuild(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None:
|
def server_delete(self, machine_id: str) -> None:
|
||||||
"""
|
"""Delete a server."""
|
||||||
Rebuilds the server with the operating system and SSH key set at launch time.
|
url = self.api_endpoint + api.ServerDelete.url.format(machine_id=machine_id)
|
||||||
|
response = self._httpx_client.delete(url)
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
Deletes all of the data on the server!
|
def server_forget(self, machine_id: str) -> None:
|
||||||
"""
|
"""Forget about a deleted server to hide it from view."""
|
||||||
url = api_endpoint + api.ServerRebuild.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:
|
||||||
|
"""
|
||||||
|
Rebuilds the server with the operating system and SSH key set at launch time.
|
||||||
|
|
||||||
def info(machine_id: str, api_endpoint: str = API_ENDPOINT) -> api.ServerInfo.Response:
|
Deletes all of the data on the server!
|
||||||
"""
|
"""
|
||||||
Returns info about the server.
|
url = self.api_endpoint + api.ServerRebuild.url.format(machine_id=machine_id)
|
||||||
"""
|
response = self._httpx_client.post(url)
|
||||||
url = api_endpoint + api.ServerInfo.url.format(machine_id=machine_id)
|
_handle_response(response)
|
||||||
response = _api_request(url)
|
|
||||||
response_object = api.ServerInfo.Response.parse_obj(response)
|
|
||||||
assert response_object.machine_id == machine_id
|
|
||||||
return response_object
|
|
||||||
|
|
||||||
|
def server_info(self, machine_id: str) -> api.ServerInfo.Response:
|
||||||
|
"""Returns info about the server."""
|
||||||
|
url = self.api_endpoint + api.ServerInfo.url.format(machine_id=machine_id)
|
||||||
|
response = self._httpx_client.get(url)
|
||||||
|
_handle_response(response)
|
||||||
|
response_object = api.ServerInfo.Response.parse_obj(response.json())
|
||||||
|
return response_object
|
||||||
|
|
||||||
def token_enable(
|
def server_update(
|
||||||
token: str,
|
self,
|
||||||
dollars: int,
|
machine_id: str,
|
||||||
currency: str,
|
hostname: Union[str, None] = None,
|
||||||
api_endpoint: str = API_ENDPOINT,
|
autorenew: Union[bool, None] = None,
|
||||||
retry: bool = False,
|
) -> None:
|
||||||
) -> api.TokenEnable.Response:
|
"""Update server settings."""
|
||||||
request = api.TokenEnable.Request(dollars=dollars, currency=currency)
|
request = ServerUpdateRequest(hostname=hostname, autorenew=autorenew)
|
||||||
url = api_endpoint + api.TokenEnable.url.format(token=token)
|
url = self.api_endpoint + f"/server/{machine_id}"
|
||||||
response = _api_request(url=url, json_params=request.dict(), retry=retry)
|
response = self._httpx_client.patch(url=url, json=request.dict())
|
||||||
response_object = api.TokenEnable.Response.parse_obj(response)
|
_handle_response(response)
|
||||||
assert response_object.token == token
|
|
||||||
return response_object
|
|
||||||
|
|
||||||
|
def servers_launched_from_token(
|
||||||
|
self, token: str
|
||||||
|
) -> api.ServersLaunchedFromToken.Response:
|
||||||
|
"""
|
||||||
|
Returns info of servers launched from a given token.
|
||||||
|
"""
|
||||||
|
url = self.api_endpoint + api.ServersLaunchedFromToken.url.format(token=token)
|
||||||
|
response = self._httpx_client.get(url)
|
||||||
|
_handle_response(response)
|
||||||
|
response_object = api.ServersLaunchedFromToken.Response.parse_obj(
|
||||||
|
response.json()
|
||||||
|
)
|
||||||
|
return response_object
|
||||||
|
|
||||||
def token_add(
|
def flavors(self) -> api.Flavors.Response:
|
||||||
token: str,
|
"""Returns available flavors (server sizes)."""
|
||||||
dollars: int,
|
url = self.api_endpoint + api.Flavors.url
|
||||||
currency: str,
|
response = self._httpx_client.get(url)
|
||||||
api_endpoint: str = API_ENDPOINT,
|
_handle_response(response)
|
||||||
retry: bool = False,
|
response_object = api.Flavors.Response.parse_obj(response.json())
|
||||||
) -> api.TokenAdd.Response:
|
return response_object
|
||||||
request = api.TokenAdd.Request(dollars=dollars, currency=currency)
|
|
||||||
url = api_endpoint + api.TokenAdd.url.format(token=token)
|
|
||||||
response = _api_request(url=url, json_params=request.dict(), retry=retry)
|
|
||||||
response_object = api.TokenAdd.Response.parse_obj(response)
|
|
||||||
assert response_object.token == token
|
|
||||||
return response_object
|
|
||||||
|
|
||||||
|
def 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 token_balance(
|
def regions(self) -> api.Regions.Response:
|
||||||
token: str, api_endpoint: str = API_ENDPOINT
|
"""Returns regions that you can launch a server in."""
|
||||||
) -> api.TokenBalance.Response:
|
url = self.api_endpoint + api.Regions.url
|
||||||
url = api_endpoint + api.TokenBalance.url.format(token=token)
|
response = self._httpx_client.get(url)
|
||||||
response = _api_request(url=url)
|
_handle_response(response)
|
||||||
response_object = api.TokenBalance.Response.parse_obj(response)
|
response_object = api.Regions.Response.parse_obj(response.json())
|
||||||
assert response_object.token == token
|
return response_object
|
||||||
return response_object
|
|
||||||
|
def changelog(self) -> str:
|
||||||
|
"""Returns the API changelog."""
|
||||||
|
url = self.api_endpoint + "/changelog"
|
||||||
|
response = self._httpx_client.get(url)
|
||||||
|
_handle_response(response)
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
def token_add(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
dollars: int,
|
||||||
|
currency: Currency,
|
||||||
|
) -> api.TokenAdd.Response:
|
||||||
|
"""Add balance (money) to a token."""
|
||||||
|
url = self.api_endpoint + api.TokenAdd.url.format(token=token)
|
||||||
|
request = api.TokenAdd.Request(dollars=dollars, currency=currency)
|
||||||
|
response = self._httpx_client.post(url, json=request.dict())
|
||||||
|
_handle_response(response)
|
||||||
|
response_object = api.TokenAdd.Response.parse_obj(response.json())
|
||||||
|
return response_object
|
||||||
|
|
||||||
|
def token_balance(self, token: str) -> api.TokenBalance.Response:
|
||||||
|
"""Return a token's balance."""
|
||||||
|
url = self.api_endpoint + api.TokenBalance.url.format(token=token)
|
||||||
|
response = self._httpx_client.get(url)
|
||||||
|
_handle_response(response)
|
||||||
|
response_object = api.TokenBalance.Response.parse_obj(response.json())
|
||||||
|
return response_object
|
||||||
|
|
||||||
|
def token_info(self, token: str) -> TokenInfo:
|
||||||
|
"""Return information about a token, including balance."""
|
||||||
|
url = self.api_endpoint + f"/token/{token}/info"
|
||||||
|
response = self._httpx_client.get(url)
|
||||||
|
_handle_response(response)
|
||||||
|
response_object = TokenInfo.parse_obj(response.json())
|
||||||
|
return response_object
|
||||||
|
|
||||||
|
def token_get_messages(self, token: str) -> List[api.TokenMessage]:
|
||||||
|
"""Get messages for/from the token."""
|
||||||
|
url = self.api_endpoint + f"/token/{token}/messages"
|
||||||
|
response = self._httpx_client.get(url=url)
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
|
return parse_obj_as(List[api.TokenMessage], response.json())
|
||||||
|
|
||||||
|
def token_send_message(self, token: str, message: str) -> None:
|
||||||
|
"""Send a message to SporeStack support."""
|
||||||
|
url = self.api_endpoint + f"/token/{token}/messages"
|
||||||
|
response = self._httpx_client.post(url=url, json={"message": message})
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
|
def token_invoice(self, token: str, invoice: str) -> Invoice:
|
||||||
|
"""Get a particular invoice."""
|
||||||
|
url = self.api_endpoint + f"/token/{token}/invoices/{invoice}"
|
||||||
|
response = self._httpx_client.get(url=url)
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
|
return parse_obj_as(Invoice, response.json())
|
||||||
|
|
||||||
|
def token_invoices(self, token: str) -> List[Invoice]:
|
||||||
|
"""Get token invoices."""
|
||||||
|
url = self.api_endpoint + f"/token/{token}/invoices"
|
||||||
|
response = self._httpx_client.get(url=url)
|
||||||
|
_handle_response(response)
|
||||||
|
|
||||||
|
return parse_obj_as(List[Invoice], response.json())
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SporeStackTooManyRequestsError(SporeStackError):
|
||||||
|
"""HTTP 429, retry again later"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SporeStackServerError(SporeStackError):
|
class SporeStackServerError(SporeStackError):
|
||||||
"""HTTP 5XX"""
|
"""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
|
class Flavor(BaseModel):
|
||||||
|
# Unique string to identify the flavor that's sort of human readable.
|
||||||
from pydantic import BaseModel
|
slug: str
|
||||||
|
# Number of vCPU cores the server is given.
|
||||||
|
cores: int
|
||||||
class NetworkInterface(BaseModel):
|
# Memory in Megabytes
|
||||||
|
memory: int
|
||||||
|
# Disk in Gigabytes
|
||||||
|
disk: int
|
||||||
|
# USD cents per day
|
||||||
|
price: int
|
||||||
|
# IPv4 connectivity: "/32"
|
||||||
ipv4: str
|
ipv4: str
|
||||||
|
# IPv6 connectivity: "/128"
|
||||||
ipv6: str
|
ipv6: str
|
||||||
|
"""Gigabytes of bandwidth per day."""
|
||||||
|
bandwidth_per_month: float
|
||||||
|
"""Gigabytes of bandwidth per month."""
|
||||||
|
|
||||||
|
|
||||||
class Payment(BaseModel):
|
class OperatingSystem(BaseModel):
|
||||||
txid: Optional[str]
|
slug: str
|
||||||
uri: Optional[str]
|
"""Unique string to identify the operating system."""
|
||||||
usd: str
|
minimum_disk: int
|
||||||
paid: bool
|
"""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:
|
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)}"
|
to_hash = f"ss_m_{secrets.token_hex(11)}"
|
||||||
return f"{to_hash}_{checksum(to_hash)}"
|
return f"{to_hash}_{checksum(to_hash)}"
|
||||||
|
@ -22,6 +22,7 @@ def random_machine_id() -> str:
|
||||||
|
|
||||||
def random_token() -> 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
|
import pytest
|
||||||
from pydantic import ValidationError
|
import respx
|
||||||
|
from sporestack import api_client, exceptions
|
||||||
|
|
||||||
from sporestack import api_client
|
# respx seems to ignore the uri://domain if you don't specify it.
|
||||||
|
|
||||||
|
|
||||||
def test__is_onion_url() -> None:
|
def test__is_onion_url() -> None:
|
||||||
|
@ -20,113 +20,140 @@ def test__is_onion_url() -> None:
|
||||||
assert api_client._is_onion_url("http://me.me/file.onion") is False
|
assert api_client._is_onion_url("http://me.me/file.onion") is False
|
||||||
|
|
||||||
|
|
||||||
@patch("sporestack.api_client._api_request")
|
def test_get_response_error_text() -> None:
|
||||||
def test_launch(mock_api_request: MagicMock) -> None:
|
assert (
|
||||||
with pytest.raises(ValidationError):
|
api_client._get_response_error_text(
|
||||||
api_client.launch(
|
httpx.Response(status_code=422, text="just text")
|
||||||
"dummymachineid",
|
|
||||||
currency="xmr",
|
|
||||||
days=1,
|
|
||||||
operating_system="freebsd-12",
|
|
||||||
ssh_key="id-rsa...",
|
|
||||||
flavor="aflavor",
|
|
||||||
)
|
)
|
||||||
json_params = {
|
== "just text"
|
||||||
"machine_id": "dummymachineid",
|
)
|
||||||
"days": 1,
|
|
||||||
"currency": "xmr",
|
assert (
|
||||||
"flavor": "aflavor",
|
api_client._get_response_error_text(
|
||||||
"ssh_key": "id-rsa...",
|
httpx.Response(status_code=422, json={"detail": "detail text"})
|
||||||
"operating_system": "freebsd-12",
|
)
|
||||||
"region": None,
|
== "detail text"
|
||||||
"organization": None,
|
)
|
||||||
"settlement_token": None,
|
|
||||||
"affiliate_amount": None,
|
# This may not be the best behavior overall.
|
||||||
"affiliate_token": None,
|
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(
|
route_response = httpx.Response(200, json=response_json)
|
||||||
url="https://api.sporestack.com/server/dummymachineid/launch",
|
route = respx_mock.get(f"/token/{dummy_token}/info").mock(
|
||||||
json_params=json_params,
|
return_value=route_response
|
||||||
retry=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
client = api_client.APIClient()
|
||||||
|
info_response = client.token_info(dummy_token)
|
||||||
|
assert info_response.balance_cents == 0
|
||||||
|
assert info_response.balance_usd == "$0.00"
|
||||||
|
assert info_response.burn_rate_cents == 0
|
||||||
|
assert info_response.burn_rate_usd == "$0.00"
|
||||||
|
assert info_response.servers == 0
|
||||||
|
assert info_response.days_remaining == 0
|
||||||
|
|
||||||
@patch("sporestack.api_client._api_request")
|
assert route.called
|
||||||
def test_topup(mock_api_request: MagicMock) -> None:
|
|
||||||
with pytest.raises(ValidationError):
|
|
||||||
api_client.topup("dummymachineid", currency="xmr", days=1)
|
def test_server_info(respx_mock: respx.MockRouter) -> None:
|
||||||
json_params = {
|
dummy_machine_id = "dummyinvalidmachineid"
|
||||||
"machine_id": "dummymachineid",
|
flavor = {
|
||||||
"days": 1,
|
"slug": "a flavor slug",
|
||||||
"currency": "xmr",
|
"cores": 1,
|
||||||
"settlement_token": None,
|
"memory": 1024,
|
||||||
"affiliate_amount": None,
|
"disk": 25,
|
||||||
"affiliate_token": None,
|
"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",
|
response_json = {
|
||||||
json_params=json_params,
|
"machine_id": dummy_machine_id,
|
||||||
retry=False,
|
"hostname": "a hostname",
|
||||||
|
"flavor": flavor,
|
||||||
|
"region": "a region",
|
||||||
|
"token": "a token",
|
||||||
|
"running": True,
|
||||||
|
"created_at": 1,
|
||||||
|
"expiration": 2,
|
||||||
|
"autorenew": False,
|
||||||
|
"ipv4": "0.0.0.0",
|
||||||
|
"ipv6": "::0",
|
||||||
|
"deleted": False,
|
||||||
|
"deleted_at": 0,
|
||||||
|
"deleted_by": None,
|
||||||
|
"forgotten_at": None,
|
||||||
|
"suspended_at": None,
|
||||||
|
"operating_system": "debian-11",
|
||||||
|
}
|
||||||
|
route_response = httpx.Response(200, json=response_json)
|
||||||
|
route = respx_mock.get(f"/server/{dummy_machine_id}/info").mock(
|
||||||
|
return_value=route_response
|
||||||
)
|
)
|
||||||
|
|
||||||
|
client = api_client.APIClient()
|
||||||
|
info_response = client.server_info(dummy_machine_id)
|
||||||
|
# These aren't exhaustive, but there's a number here.
|
||||||
|
assert info_response.machine_id == dummy_machine_id
|
||||||
|
assert info_response.hostname == response_json["hostname"]
|
||||||
|
assert info_response.flavor.dict() == response_json["flavor"]
|
||||||
|
assert info_response.token == response_json["token"]
|
||||||
|
assert info_response.running == response_json["running"]
|
||||||
|
assert info_response.created_at == response_json["created_at"]
|
||||||
|
assert info_response.expiration == response_json["expiration"]
|
||||||
|
assert info_response.autorenew == response_json["autorenew"]
|
||||||
|
assert info_response.forgotten_at == response_json["forgotten_at"]
|
||||||
|
# Not sure why mypy dislikes this. It passes pytest.
|
||||||
|
assert info_response.flavor.slug == response_json["flavor"]["slug"] # type: ignore
|
||||||
|
|
||||||
@patch("sporestack.api_client._api_request")
|
assert route.called
|
||||||
def test_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,
|
|
||||||
)
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
|
import pytest
|
||||||
|
import typer
|
||||||
from _pytest.monkeypatch import MonkeyPatch
|
from _pytest.monkeypatch import MonkeyPatch
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
from sporestack import cli
|
from sporestack import cli
|
||||||
from sporestack.api_client import TOR_ENDPOINT
|
from sporestack.api_client import TOR_ENDPOINT
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
|
@ -35,10 +36,22 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
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_")
|
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:
|
def test_hash() -> None:
|
||||||
assert utils.checksum("ss_m_1deadbeefcafedeadbeef1") == "0892"
|
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