sws-sdk 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sws_sdk-0.1.1/.github/workflows/publish.yml +39 -0
- sws_sdk-0.1.1/.gitignore +30 -0
- sws_sdk-0.1.1/LICENSE +21 -0
- sws_sdk-0.1.1/PKG-INFO +123 -0
- sws_sdk-0.1.1/README.md +91 -0
- sws_sdk-0.1.1/pyproject.toml +60 -0
- sws_sdk-0.1.1/sws/__init__.py +41 -0
- sws_sdk-0.1.1/sws/_version.py +1 -0
- sws_sdk-0.1.1/sws/client.py +416 -0
- sws_sdk-0.1.1/sws/exceptions.py +39 -0
- sws_sdk-0.1.1/sws/models.py +208 -0
- sws_sdk-0.1.1/tests/__init__.py +0 -0
- sws_sdk-0.1.1/tests/test_client.py +163 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
id-token: write # required for PyPI trusted publishing
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
matrix:
|
|
16
|
+
python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python }}
|
|
22
|
+
- run: pip install -e ".[dev]"
|
|
23
|
+
- run: pytest -v
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
needs: test
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
environment: pypi
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@v4
|
|
31
|
+
- uses: actions/setup-python@v5
|
|
32
|
+
with:
|
|
33
|
+
python-version: "3.12"
|
|
34
|
+
- name: Build wheel + sdist
|
|
35
|
+
run: |
|
|
36
|
+
pip install build
|
|
37
|
+
python -m build
|
|
38
|
+
- name: Publish to PyPI
|
|
39
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
sws_sdk-0.1.1/.gitignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Build artifacts
|
|
2
|
+
build/
|
|
3
|
+
dist/
|
|
4
|
+
*.egg-info/
|
|
5
|
+
*.egg
|
|
6
|
+
|
|
7
|
+
# Bytecode
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
*$py.class
|
|
11
|
+
|
|
12
|
+
# Test/coverage
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
.coverage.*
|
|
16
|
+
htmlcov/
|
|
17
|
+
.tox/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
|
|
21
|
+
# Virtualenvs
|
|
22
|
+
.venv/
|
|
23
|
+
venv/
|
|
24
|
+
env/
|
|
25
|
+
|
|
26
|
+
# IDEs
|
|
27
|
+
.vscode/
|
|
28
|
+
.idea/
|
|
29
|
+
*.swp
|
|
30
|
+
.DS_Store
|
sws_sdk-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Savannaa Cloud
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sws_sdk-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sws-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Official Python SDK for the SWS cloud platform
|
|
5
|
+
Project-URL: Homepage, https://savannaa.com
|
|
6
|
+
Project-URL: Documentation, https://savannaa.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/savannaacloud/sws-sdk
|
|
8
|
+
Project-URL: Issues, https://github.com/savannaacloud/sws-sdk/issues
|
|
9
|
+
Author: Savannaa Cloud
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cloud,infrastructure,savannaa,sws
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: System :: Systems Administration
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: httpx>=0.25
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
29
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# sws-sdk
|
|
34
|
+
|
|
35
|
+
Official Python SDK for the **SWS** cloud platform.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Once published to PyPI:
|
|
41
|
+
pip install sws-sdk
|
|
42
|
+
|
|
43
|
+
# Available now (installs the tagged release straight from GitHub):
|
|
44
|
+
pip install "git+https://github.com/savannaacloud/sws-sdk@v0.1.0"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from sws import Client
|
|
51
|
+
|
|
52
|
+
client = Client(api_key="ctk_...", region="ng-lagos-1")
|
|
53
|
+
|
|
54
|
+
# List virtual machines
|
|
55
|
+
for vm in client.compute.list_instances():
|
|
56
|
+
print(vm.name, vm.status)
|
|
57
|
+
|
|
58
|
+
# Launch an instance
|
|
59
|
+
instance = client.compute.create_instance(
|
|
60
|
+
name="web-01",
|
|
61
|
+
image="ubuntu-22.04",
|
|
62
|
+
plan="m1.medium",
|
|
63
|
+
network_id="net-uuid",
|
|
64
|
+
key_name="my-key",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Create + attach a volume
|
|
68
|
+
vol = client.storage.create_volume(name="data", size=100, type="ssd")
|
|
69
|
+
client.storage.attach_volume(vol.id, instance_id=instance.id)
|
|
70
|
+
|
|
71
|
+
# Open SSH (port 22) from anywhere
|
|
72
|
+
sg = client.network.create_security_group("web", description="Allow SSH")
|
|
73
|
+
client.network.add_security_group_rule(
|
|
74
|
+
sg.id, protocol="tcp", port_range_min=22, port_range_max=22,
|
|
75
|
+
remote_ip_prefix="0.0.0.0/0",
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Configure via constructor or environment variables:
|
|
80
|
+
|
|
81
|
+
| Argument | Env var | Default |
|
|
82
|
+
| ----------- | --------------- | -------------------------- |
|
|
83
|
+
| `api_key` | `SWS_API_KEY` | _(required)_ |
|
|
84
|
+
| `region` | `SWS_REGION` | `ng-lagos-1` |
|
|
85
|
+
| `base_url` | `SWS_BASE_URL` | `https://savannaa.com` |
|
|
86
|
+
|
|
87
|
+
## Resources
|
|
88
|
+
|
|
89
|
+
| Namespace | Operations |
|
|
90
|
+
| ------------------ | -------------------------------------------------------------------------------------------------- |
|
|
91
|
+
| `client.compute` | instances (CRUD + start/stop/reboot/resize), plans, images, keypairs |
|
|
92
|
+
| `client.network` | networks, subnets, security groups + rules, public IPs (allocate/assign/release) |
|
|
93
|
+
| `client.storage` | volumes (create, delete, attach, detach) |
|
|
94
|
+
| `client.database` | managed database instances (mysql, postgresql, …) |
|
|
95
|
+
|
|
96
|
+
## Error handling
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from sws import Client, QuotaExceededError, NotFoundError
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
client.compute.create_instance(...)
|
|
103
|
+
except QuotaExceededError:
|
|
104
|
+
print("Out of instance quota — request a bump in the console.")
|
|
105
|
+
except NotFoundError:
|
|
106
|
+
print("Image or plan does not exist in this region.")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
All exceptions inherit from `sws.SWSError`. Subclasses: `AuthenticationError`,
|
|
110
|
+
`ValidationError`, `NotFoundError`, `QuotaExceededError`, `APIError`.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pip install -e ".[dev]"
|
|
116
|
+
pytest # unit tests use respx, no live API required
|
|
117
|
+
ruff check sws tests
|
|
118
|
+
mypy sws
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
sws_sdk-0.1.1/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# sws-sdk
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the **SWS** cloud platform.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Once published to PyPI:
|
|
9
|
+
pip install sws-sdk
|
|
10
|
+
|
|
11
|
+
# Available now (installs the tagged release straight from GitHub):
|
|
12
|
+
pip install "git+https://github.com/savannaacloud/sws-sdk@v0.1.0"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from sws import Client
|
|
19
|
+
|
|
20
|
+
client = Client(api_key="ctk_...", region="ng-lagos-1")
|
|
21
|
+
|
|
22
|
+
# List virtual machines
|
|
23
|
+
for vm in client.compute.list_instances():
|
|
24
|
+
print(vm.name, vm.status)
|
|
25
|
+
|
|
26
|
+
# Launch an instance
|
|
27
|
+
instance = client.compute.create_instance(
|
|
28
|
+
name="web-01",
|
|
29
|
+
image="ubuntu-22.04",
|
|
30
|
+
plan="m1.medium",
|
|
31
|
+
network_id="net-uuid",
|
|
32
|
+
key_name="my-key",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Create + attach a volume
|
|
36
|
+
vol = client.storage.create_volume(name="data", size=100, type="ssd")
|
|
37
|
+
client.storage.attach_volume(vol.id, instance_id=instance.id)
|
|
38
|
+
|
|
39
|
+
# Open SSH (port 22) from anywhere
|
|
40
|
+
sg = client.network.create_security_group("web", description="Allow SSH")
|
|
41
|
+
client.network.add_security_group_rule(
|
|
42
|
+
sg.id, protocol="tcp", port_range_min=22, port_range_max=22,
|
|
43
|
+
remote_ip_prefix="0.0.0.0/0",
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Configure via constructor or environment variables:
|
|
48
|
+
|
|
49
|
+
| Argument | Env var | Default |
|
|
50
|
+
| ----------- | --------------- | -------------------------- |
|
|
51
|
+
| `api_key` | `SWS_API_KEY` | _(required)_ |
|
|
52
|
+
| `region` | `SWS_REGION` | `ng-lagos-1` |
|
|
53
|
+
| `base_url` | `SWS_BASE_URL` | `https://savannaa.com` |
|
|
54
|
+
|
|
55
|
+
## Resources
|
|
56
|
+
|
|
57
|
+
| Namespace | Operations |
|
|
58
|
+
| ------------------ | -------------------------------------------------------------------------------------------------- |
|
|
59
|
+
| `client.compute` | instances (CRUD + start/stop/reboot/resize), plans, images, keypairs |
|
|
60
|
+
| `client.network` | networks, subnets, security groups + rules, public IPs (allocate/assign/release) |
|
|
61
|
+
| `client.storage` | volumes (create, delete, attach, detach) |
|
|
62
|
+
| `client.database` | managed database instances (mysql, postgresql, …) |
|
|
63
|
+
|
|
64
|
+
## Error handling
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from sws import Client, QuotaExceededError, NotFoundError
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
client.compute.create_instance(...)
|
|
71
|
+
except QuotaExceededError:
|
|
72
|
+
print("Out of instance quota — request a bump in the console.")
|
|
73
|
+
except NotFoundError:
|
|
74
|
+
print("Image or plan does not exist in this region.")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
All exceptions inherit from `sws.SWSError`. Subclasses: `AuthenticationError`,
|
|
78
|
+
`ValidationError`, `NotFoundError`, `QuotaExceededError`, `APIError`.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install -e ".[dev]"
|
|
84
|
+
pytest # unit tests use respx, no live API required
|
|
85
|
+
ruff check sws tests
|
|
86
|
+
mypy sws
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sws-sdk"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python SDK for the SWS cloud platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Savannaa Cloud" }]
|
|
13
|
+
keywords = ["cloud", "infrastructure", "sws", "savannaa"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: System :: Systems Administration",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"httpx>=0.25",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=7",
|
|
33
|
+
"pytest-cov",
|
|
34
|
+
"respx>=0.21",
|
|
35
|
+
"ruff",
|
|
36
|
+
"mypy",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://savannaa.com"
|
|
41
|
+
Documentation = "https://savannaa.com/docs"
|
|
42
|
+
Repository = "https://github.com/savannaacloud/sws-sdk"
|
|
43
|
+
Issues = "https://github.com/savannaacloud/sws-sdk/issues"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.version]
|
|
46
|
+
path = "sws/_version.py"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["sws"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 100
|
|
53
|
+
target-version = "py39"
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "W", "I", "B", "UP"]
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
testpaths = ["tests"]
|
|
60
|
+
addopts = "-ra --strict-markers"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Official Python SDK for the SWS cloud platform."""
|
|
2
|
+
|
|
3
|
+
from sws._version import __version__
|
|
4
|
+
from sws.client import Client
|
|
5
|
+
from sws.exceptions import (
|
|
6
|
+
APIError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
QuotaExceededError,
|
|
10
|
+
SWSError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
from sws.models import (
|
|
14
|
+
Instance,
|
|
15
|
+
Keypair,
|
|
16
|
+
Network,
|
|
17
|
+
Plan,
|
|
18
|
+
PublicIP,
|
|
19
|
+
SecurityGroup,
|
|
20
|
+
Subnet,
|
|
21
|
+
Volume,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Client",
|
|
26
|
+
"SWSError",
|
|
27
|
+
"APIError",
|
|
28
|
+
"AuthenticationError",
|
|
29
|
+
"NotFoundError",
|
|
30
|
+
"QuotaExceededError",
|
|
31
|
+
"ValidationError",
|
|
32
|
+
"Instance",
|
|
33
|
+
"Keypair",
|
|
34
|
+
"Network",
|
|
35
|
+
"Subnet",
|
|
36
|
+
"SecurityGroup",
|
|
37
|
+
"PublicIP",
|
|
38
|
+
"Volume",
|
|
39
|
+
"Plan",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Top-level :class:`Client` and resource handlers.
|
|
2
|
+
|
|
3
|
+
Resource handlers live as inner classes so users get attribute access
|
|
4
|
+
(``client.compute.list_instances()``) without us having to maintain a
|
|
5
|
+
service-locator dict.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from sws._version import __version__
|
|
16
|
+
from sws.exceptions import (
|
|
17
|
+
APIError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
QuotaExceededError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
)
|
|
23
|
+
from sws.models import (
|
|
24
|
+
Database,
|
|
25
|
+
Instance,
|
|
26
|
+
Keypair,
|
|
27
|
+
Network,
|
|
28
|
+
Plan,
|
|
29
|
+
PublicIP,
|
|
30
|
+
SecurityGroup,
|
|
31
|
+
Subnet,
|
|
32
|
+
Volume,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
DEFAULT_BASE_URL = "https://savannaa.com"
|
|
36
|
+
DEFAULT_REGION = "ng-lagos-1"
|
|
37
|
+
DEFAULT_TIMEOUT = 30.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _raise_for_status(r: httpx.Response) -> None:
|
|
41
|
+
"""Translate non-2xx responses into the SDK exception hierarchy.
|
|
42
|
+
|
|
43
|
+
Quota errors arrive as 403 with a body containing "Quota" — they get
|
|
44
|
+
their own subclass so callers can retry with smaller requests.
|
|
45
|
+
"""
|
|
46
|
+
if r.is_success:
|
|
47
|
+
return
|
|
48
|
+
try:
|
|
49
|
+
body: Any = r.json()
|
|
50
|
+
msg = body.get("detail") or body.get("error") or body.get("message") or r.text
|
|
51
|
+
except Exception:
|
|
52
|
+
body = r.text
|
|
53
|
+
msg = r.text or r.reason_phrase
|
|
54
|
+
|
|
55
|
+
if r.status_code in (401, 403):
|
|
56
|
+
if isinstance(msg, str) and "quota" in msg.lower():
|
|
57
|
+
raise QuotaExceededError(r.status_code, msg, body)
|
|
58
|
+
raise AuthenticationError(r.status_code, msg, body)
|
|
59
|
+
if r.status_code == 404:
|
|
60
|
+
raise NotFoundError(r.status_code, msg, body)
|
|
61
|
+
if r.status_code in (400, 422):
|
|
62
|
+
raise ValidationError(r.status_code, msg, body)
|
|
63
|
+
raise APIError(r.status_code, msg, body)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Client:
|
|
67
|
+
"""SWS API client.
|
|
68
|
+
|
|
69
|
+
Example::
|
|
70
|
+
|
|
71
|
+
from sws import Client
|
|
72
|
+
|
|
73
|
+
client = Client(api_key="ctk_...", region="ng-lagos-1")
|
|
74
|
+
for vm in client.compute.list_instances():
|
|
75
|
+
print(vm.name, vm.status)
|
|
76
|
+
|
|
77
|
+
Auth resolution order: ``api_key`` argument → ``SWS_API_KEY`` env var.
|
|
78
|
+
Region: ``region`` argument → ``SWS_REGION`` env var → ``ng-lagos-1``.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
api_key: str | None = None,
|
|
84
|
+
*,
|
|
85
|
+
region: str | None = None,
|
|
86
|
+
base_url: str | None = None,
|
|
87
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
88
|
+
verify_tls: bool = True,
|
|
89
|
+
) -> None:
|
|
90
|
+
api_key = api_key or os.environ.get("SWS_API_KEY")
|
|
91
|
+
if not api_key:
|
|
92
|
+
raise AuthenticationError(
|
|
93
|
+
401,
|
|
94
|
+
"missing api_key (pass to Client(api_key=...) or set SWS_API_KEY env var)",
|
|
95
|
+
)
|
|
96
|
+
region = region or os.environ.get("SWS_REGION") or DEFAULT_REGION
|
|
97
|
+
base_url = base_url or os.environ.get("SWS_BASE_URL") or DEFAULT_BASE_URL
|
|
98
|
+
|
|
99
|
+
self._http = httpx.Client(
|
|
100
|
+
base_url=base_url,
|
|
101
|
+
headers={
|
|
102
|
+
"Authorization": f"Bearer {api_key}",
|
|
103
|
+
"x-region": region,
|
|
104
|
+
"User-Agent": f"sws-sdk-python/{__version__}",
|
|
105
|
+
"Accept": "application/json",
|
|
106
|
+
},
|
|
107
|
+
timeout=timeout,
|
|
108
|
+
verify=verify_tls,
|
|
109
|
+
)
|
|
110
|
+
self.region = region
|
|
111
|
+
self.compute = Compute(self._http)
|
|
112
|
+
self.network = NetworkResource(self._http)
|
|
113
|
+
self.storage = Storage(self._http)
|
|
114
|
+
self.database = DatabaseResource(self._http)
|
|
115
|
+
|
|
116
|
+
def close(self) -> None:
|
|
117
|
+
self._http.close()
|
|
118
|
+
|
|
119
|
+
def __enter__(self) -> Client:
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
123
|
+
self.close()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class _Resource:
|
|
127
|
+
def __init__(self, http: httpx.Client) -> None:
|
|
128
|
+
self._http = http
|
|
129
|
+
|
|
130
|
+
def _get(self, path: str, **kwargs: Any) -> Any:
|
|
131
|
+
r = self._http.get(path, **kwargs)
|
|
132
|
+
_raise_for_status(r)
|
|
133
|
+
return r.json() if r.content else None
|
|
134
|
+
|
|
135
|
+
def _post(self, path: str, json: Any = None, **kwargs: Any) -> Any:
|
|
136
|
+
r = self._http.post(path, json=json, **kwargs)
|
|
137
|
+
_raise_for_status(r)
|
|
138
|
+
return r.json() if r.content else None
|
|
139
|
+
|
|
140
|
+
def _delete(self, path: str, **kwargs: Any) -> None:
|
|
141
|
+
r = self._http.delete(path, **kwargs)
|
|
142
|
+
_raise_for_status(r)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Compute(_Resource):
|
|
146
|
+
"""Virtual machines, plans, images, keypairs."""
|
|
147
|
+
|
|
148
|
+
# ── instances ──────────────────────────────────────────────────────
|
|
149
|
+
def list_instances(self) -> list[Instance]:
|
|
150
|
+
data = self._get("/api/compute/servers") or []
|
|
151
|
+
return [Instance.from_api(d) for d in data]
|
|
152
|
+
|
|
153
|
+
def get_instance(self, instance_id: str) -> Instance:
|
|
154
|
+
return Instance.from_api(self._get(f"/api/compute/servers/{instance_id}"))
|
|
155
|
+
|
|
156
|
+
def create_instance(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
name: str,
|
|
160
|
+
image: str,
|
|
161
|
+
plan: str,
|
|
162
|
+
network_id: str | None = None,
|
|
163
|
+
key_name: str | None = None,
|
|
164
|
+
security_groups: list[str] | None = None,
|
|
165
|
+
user_data: str | None = None,
|
|
166
|
+
) -> Instance:
|
|
167
|
+
# The backend still takes flavor_id over the wire — translate the
|
|
168
|
+
# SDK's "plan" surface to it here so callers never see the legacy
|
|
169
|
+
# term.
|
|
170
|
+
payload: dict[str, Any] = {
|
|
171
|
+
"name": name,
|
|
172
|
+
"image_id": image,
|
|
173
|
+
"flavor_id": plan,
|
|
174
|
+
}
|
|
175
|
+
if network_id:
|
|
176
|
+
payload["network_id"] = network_id
|
|
177
|
+
if key_name:
|
|
178
|
+
payload["key_name"] = key_name
|
|
179
|
+
if security_groups:
|
|
180
|
+
payload["security_groups"] = security_groups
|
|
181
|
+
if user_data is not None:
|
|
182
|
+
payload["user_data"] = user_data
|
|
183
|
+
return Instance.from_api(self._post("/api/compute/servers", json=payload))
|
|
184
|
+
|
|
185
|
+
def delete_instance(self, instance_id: str) -> None:
|
|
186
|
+
self._delete(f"/api/compute/servers/{instance_id}")
|
|
187
|
+
|
|
188
|
+
def start_instance(self, instance_id: str) -> None:
|
|
189
|
+
self._post(f"/api/compute/servers/{instance_id}/start")
|
|
190
|
+
|
|
191
|
+
def stop_instance(self, instance_id: str) -> None:
|
|
192
|
+
self._post(f"/api/compute/servers/{instance_id}/stop")
|
|
193
|
+
|
|
194
|
+
def reboot_instance(self, instance_id: str, *, hard: bool = False) -> None:
|
|
195
|
+
self._post(
|
|
196
|
+
f"/api/compute/servers/{instance_id}/reboot",
|
|
197
|
+
json={"type": "HARD" if hard else "SOFT"},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def resize_instance(self, instance_id: str, *, plan: str) -> None:
|
|
201
|
+
self._post(
|
|
202
|
+
f"/api/compute/servers/{instance_id}/resize",
|
|
203
|
+
json={"flavor_id": plan},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# ── plans / images / keypairs ─────────────────────────────────────
|
|
207
|
+
def list_plans(self) -> list[Plan]:
|
|
208
|
+
data = self._get("/api/compute/plans") or []
|
|
209
|
+
return [Plan.from_api(d) for d in data]
|
|
210
|
+
|
|
211
|
+
def list_images(self) -> list[dict]:
|
|
212
|
+
data = self._get("/api/images") or []
|
|
213
|
+
return list(data)
|
|
214
|
+
|
|
215
|
+
def list_keypairs(self) -> list[Keypair]:
|
|
216
|
+
data = self._get("/api/compute/keypairs") or []
|
|
217
|
+
return [Keypair.from_api(d) for d in data]
|
|
218
|
+
|
|
219
|
+
def create_keypair(self, name: str, *, public_key: str | None = None) -> Keypair:
|
|
220
|
+
payload: dict[str, Any] = {"name": name}
|
|
221
|
+
if public_key:
|
|
222
|
+
payload["public_key"] = public_key
|
|
223
|
+
return Keypair.from_api(self._post("/api/compute/keypairs", json=payload))
|
|
224
|
+
|
|
225
|
+
def delete_keypair(self, name: str) -> None:
|
|
226
|
+
self._delete(f"/api/compute/keypairs/{name}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class NetworkResource(_Resource):
|
|
230
|
+
"""Networks, subnets, security groups, public IPs."""
|
|
231
|
+
|
|
232
|
+
# ── networks ──────────────────────────────────────────────────────
|
|
233
|
+
def list_networks(self) -> list[Network]:
|
|
234
|
+
data = self._get("/api/network/networks") or []
|
|
235
|
+
return [Network.from_api(d) for d in data]
|
|
236
|
+
|
|
237
|
+
def create_network(self, name: str, *, description: str | None = None) -> Network:
|
|
238
|
+
payload: dict[str, Any] = {"name": name}
|
|
239
|
+
if description is not None:
|
|
240
|
+
payload["description"] = description
|
|
241
|
+
return Network.from_api(self._post("/api/network/networks", json=payload))
|
|
242
|
+
|
|
243
|
+
def delete_network(self, network_id: str) -> None:
|
|
244
|
+
self._delete(f"/api/network/networks/{network_id}")
|
|
245
|
+
|
|
246
|
+
# ── subnets ───────────────────────────────────────────────────────
|
|
247
|
+
def list_subnets(self) -> list[Subnet]:
|
|
248
|
+
data = self._get("/api/network/subnets") or []
|
|
249
|
+
return [Subnet.from_api(d) for d in data]
|
|
250
|
+
|
|
251
|
+
def create_subnet(
|
|
252
|
+
self,
|
|
253
|
+
*,
|
|
254
|
+
name: str,
|
|
255
|
+
network_id: str,
|
|
256
|
+
cidr: str,
|
|
257
|
+
ip_version: int = 4,
|
|
258
|
+
enable_dhcp: bool = True,
|
|
259
|
+
dns_nameservers: list[str] | None = None,
|
|
260
|
+
) -> Subnet:
|
|
261
|
+
payload: dict[str, Any] = {
|
|
262
|
+
"name": name,
|
|
263
|
+
"network_id": network_id,
|
|
264
|
+
"cidr": cidr,
|
|
265
|
+
"ip_version": ip_version,
|
|
266
|
+
"enable_dhcp": enable_dhcp,
|
|
267
|
+
}
|
|
268
|
+
if dns_nameservers:
|
|
269
|
+
payload["dns_nameservers"] = dns_nameservers
|
|
270
|
+
return Subnet.from_api(self._post("/api/network/subnets", json=payload))
|
|
271
|
+
|
|
272
|
+
def delete_subnet(self, subnet_id: str) -> None:
|
|
273
|
+
self._delete(f"/api/network/subnets/{subnet_id}")
|
|
274
|
+
|
|
275
|
+
# ── security groups ───────────────────────────────────────────────
|
|
276
|
+
def list_security_groups(self) -> list[SecurityGroup]:
|
|
277
|
+
data = self._get("/api/network/security-groups") or []
|
|
278
|
+
return [SecurityGroup.from_api(d) for d in data]
|
|
279
|
+
|
|
280
|
+
def create_security_group(self, name: str, *, description: str = "") -> SecurityGroup:
|
|
281
|
+
return SecurityGroup.from_api(
|
|
282
|
+
self._post(
|
|
283
|
+
"/api/network/security-groups",
|
|
284
|
+
json={"name": name, "description": description},
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def delete_security_group(self, group_id: str) -> None:
|
|
289
|
+
self._delete(f"/api/network/security-groups/{group_id}")
|
|
290
|
+
|
|
291
|
+
def add_security_group_rule(
|
|
292
|
+
self,
|
|
293
|
+
group_id: str,
|
|
294
|
+
*,
|
|
295
|
+
direction: str = "ingress",
|
|
296
|
+
protocol: str = "tcp",
|
|
297
|
+
port_range_min: int,
|
|
298
|
+
port_range_max: int,
|
|
299
|
+
remote_ip_prefix: str = "0.0.0.0/0",
|
|
300
|
+
ethertype: str = "IPv4",
|
|
301
|
+
) -> dict:
|
|
302
|
+
return self._post(
|
|
303
|
+
"/api/network/security-group-rules",
|
|
304
|
+
json={
|
|
305
|
+
"security_group_id": group_id,
|
|
306
|
+
"direction": direction,
|
|
307
|
+
"protocol": protocol,
|
|
308
|
+
"port_range_min": port_range_min,
|
|
309
|
+
"port_range_max": port_range_max,
|
|
310
|
+
"remote_ip_prefix": remote_ip_prefix,
|
|
311
|
+
"ethertype": ethertype,
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def remove_security_group_rule(self, rule_id: str) -> None:
|
|
316
|
+
self._delete(f"/api/network/security-group-rules/{rule_id}")
|
|
317
|
+
|
|
318
|
+
# ── public IPs ────────────────────────────────────────────────────
|
|
319
|
+
def list_public_ips(self) -> list[PublicIP]:
|
|
320
|
+
data = self._get("/api/network/public-ips") or []
|
|
321
|
+
return [PublicIP.from_api(d) for d in data]
|
|
322
|
+
|
|
323
|
+
def allocate_public_ip(self, *, floating_network_id: str | None = None) -> PublicIP:
|
|
324
|
+
payload: dict[str, Any] = {}
|
|
325
|
+
if floating_network_id:
|
|
326
|
+
payload["floating_network_id"] = floating_network_id
|
|
327
|
+
return PublicIP.from_api(self._post("/api/network/public-ips", json=payload))
|
|
328
|
+
|
|
329
|
+
def assign_public_ip(self, ip_id: str, *, instance_id: str) -> None:
|
|
330
|
+
self._post(
|
|
331
|
+
f"/api/network/public-ips/{ip_id}/associate",
|
|
332
|
+
json={"instance_id": instance_id},
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def unassign_public_ip(self, ip_id: str) -> None:
|
|
336
|
+
self._post(f"/api/network/public-ips/{ip_id}/disassociate")
|
|
337
|
+
|
|
338
|
+
def release_public_ip(self, ip_id: str) -> None:
|
|
339
|
+
self._delete(f"/api/network/public-ips/{ip_id}")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class Storage(_Resource):
|
|
343
|
+
"""Block storage volumes."""
|
|
344
|
+
|
|
345
|
+
def list_volumes(self) -> list[Volume]:
|
|
346
|
+
data = self._get("/api/block-storage/volumes") or []
|
|
347
|
+
return [Volume.from_api(d) for d in data]
|
|
348
|
+
|
|
349
|
+
def get_volume(self, volume_id: str) -> Volume:
|
|
350
|
+
return Volume.from_api(self._get(f"/api/block-storage/volumes/{volume_id}"))
|
|
351
|
+
|
|
352
|
+
def create_volume(
|
|
353
|
+
self,
|
|
354
|
+
*,
|
|
355
|
+
name: str,
|
|
356
|
+
size: int,
|
|
357
|
+
type: str | None = None,
|
|
358
|
+
description: str | None = None,
|
|
359
|
+
) -> Volume:
|
|
360
|
+
payload: dict[str, Any] = {"name": name, "size": size}
|
|
361
|
+
if type is not None:
|
|
362
|
+
payload["volume_type"] = type
|
|
363
|
+
if description is not None:
|
|
364
|
+
payload["description"] = description
|
|
365
|
+
return Volume.from_api(self._post("/api/block-storage/volumes", json=payload))
|
|
366
|
+
|
|
367
|
+
def delete_volume(self, volume_id: str) -> None:
|
|
368
|
+
self._delete(f"/api/block-storage/volumes/{volume_id}")
|
|
369
|
+
|
|
370
|
+
def attach_volume(self, volume_id: str, *, instance_id: str) -> None:
|
|
371
|
+
self._post(
|
|
372
|
+
f"/api/block-storage/volumes/{volume_id}/attach",
|
|
373
|
+
json={"instance_id": instance_id},
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def detach_volume(self, volume_id: str) -> None:
|
|
377
|
+
self._post(f"/api/block-storage/volumes/{volume_id}/detach")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class DatabaseResource(_Resource):
|
|
381
|
+
"""Managed database instances (mysql, postgresql, etc.)."""
|
|
382
|
+
|
|
383
|
+
def list_instances(self) -> list[Database]:
|
|
384
|
+
data = self._get("/api/database/instances") or []
|
|
385
|
+
return [Database.from_api(d) for d in data]
|
|
386
|
+
|
|
387
|
+
def get_instance(self, db_id: str) -> Database:
|
|
388
|
+
return Database.from_api(self._get(f"/api/database/instances/{db_id}"))
|
|
389
|
+
|
|
390
|
+
def create_instance(
|
|
391
|
+
self,
|
|
392
|
+
*,
|
|
393
|
+
name: str,
|
|
394
|
+
datastore: str,
|
|
395
|
+
version: str,
|
|
396
|
+
plan: str,
|
|
397
|
+
size: int,
|
|
398
|
+
admin_user: str = "admin",
|
|
399
|
+
admin_password: str,
|
|
400
|
+
network_id: str | None = None,
|
|
401
|
+
) -> Database:
|
|
402
|
+
payload: dict[str, Any] = {
|
|
403
|
+
"name": name,
|
|
404
|
+
"datastore_type": datastore,
|
|
405
|
+
"datastore_version": version,
|
|
406
|
+
"flavor": plan,
|
|
407
|
+
"size": size,
|
|
408
|
+
"admin_user": admin_user,
|
|
409
|
+
"admin_password": admin_password,
|
|
410
|
+
}
|
|
411
|
+
if network_id:
|
|
412
|
+
payload["network_id"] = network_id
|
|
413
|
+
return Database.from_api(self._post("/api/database/instances", json=payload))
|
|
414
|
+
|
|
415
|
+
def delete_instance(self, db_id: str) -> None:
|
|
416
|
+
self._delete(f"/api/database/instances/{db_id}")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Exception hierarchy for the SWS SDK.
|
|
2
|
+
|
|
3
|
+
All errors derive from SWSError so callers can catch broadly with
|
|
4
|
+
`except SWSError:` or precisely with the subclasses below.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SWSError(Exception):
|
|
13
|
+
"""Base class for all SDK errors."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class APIError(SWSError):
|
|
17
|
+
"""Raised when the API returns a non-2xx response that is not a
|
|
18
|
+
more specific error class below."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, status_code: int, message: str, body: Any = None) -> None:
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
self.body = body
|
|
23
|
+
super().__init__(f"HTTP {status_code}: {message}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuthenticationError(APIError):
|
|
27
|
+
"""API key is missing, invalid, or expired (401/403)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NotFoundError(APIError):
|
|
31
|
+
"""Resource does not exist (404)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ValidationError(APIError):
|
|
35
|
+
"""Request payload was rejected by the server (400/422)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class QuotaExceededError(APIError):
|
|
39
|
+
"""The tenant has hit a per-resource quota (403 with quota detail)."""
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Typed dataclass models for SWS API resources.
|
|
2
|
+
|
|
3
|
+
Models accept the raw API response via :meth:`from_api` and translate
|
|
4
|
+
internal/legacy field names into the SDK's stable surface (e.g. the
|
|
5
|
+
backend still returns ``flavor`` for what the SDK exposes as ``plan``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _coerce_int(v: Any) -> int | None:
|
|
15
|
+
if v is None or v == "":
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
return int(v)
|
|
19
|
+
except (TypeError, ValueError):
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Plan:
|
|
25
|
+
"""A compute plan (size/SKU) — what the underlying platform calls a flavor."""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
name: str
|
|
29
|
+
vcpus: int | None = None
|
|
30
|
+
ram: int | None = None # MB
|
|
31
|
+
disk: int | None = None # GB
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_api(cls, data: dict) -> Plan:
|
|
35
|
+
return cls(
|
|
36
|
+
id=str(data.get("id", "")),
|
|
37
|
+
name=str(data.get("name", "")),
|
|
38
|
+
vcpus=_coerce_int(data.get("vcpus")),
|
|
39
|
+
ram=_coerce_int(data.get("ram")),
|
|
40
|
+
disk=_coerce_int(data.get("disk")),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Instance:
|
|
46
|
+
id: str
|
|
47
|
+
name: str
|
|
48
|
+
status: str
|
|
49
|
+
plan: dict | None = None
|
|
50
|
+
image: dict | None = None
|
|
51
|
+
addresses: dict[str, Any] | None = None
|
|
52
|
+
key_name: str | None = None
|
|
53
|
+
created_at: str | None = None
|
|
54
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_api(cls, data: dict) -> Instance:
|
|
58
|
+
return cls(
|
|
59
|
+
id=str(data.get("id", "")),
|
|
60
|
+
name=str(data.get("name", "")),
|
|
61
|
+
status=str(data.get("status", "")),
|
|
62
|
+
plan=data.get("flavor") or data.get("plan"),
|
|
63
|
+
image=data.get("image"),
|
|
64
|
+
addresses=data.get("addresses"),
|
|
65
|
+
key_name=data.get("key_name"),
|
|
66
|
+
created_at=data.get("created") or data.get("created_at"),
|
|
67
|
+
raw=dict(data),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Keypair:
|
|
73
|
+
name: str
|
|
74
|
+
fingerprint: str | None = None
|
|
75
|
+
public_key: str | None = None
|
|
76
|
+
private_key: str | None = None # only present on create
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_api(cls, data: dict) -> Keypair:
|
|
80
|
+
return cls(
|
|
81
|
+
name=str(data.get("name", "")),
|
|
82
|
+
fingerprint=data.get("fingerprint"),
|
|
83
|
+
public_key=data.get("public_key"),
|
|
84
|
+
private_key=data.get("private_key"),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Network:
|
|
90
|
+
id: str
|
|
91
|
+
name: str
|
|
92
|
+
status: str | None = None
|
|
93
|
+
subnets: list[str] | None = None
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_api(cls, data: dict) -> Network:
|
|
97
|
+
return cls(
|
|
98
|
+
id=str(data.get("id", "")),
|
|
99
|
+
name=str(data.get("name", "")),
|
|
100
|
+
status=data.get("status"),
|
|
101
|
+
subnets=data.get("subnets"),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class Subnet:
|
|
107
|
+
id: str
|
|
108
|
+
name: str
|
|
109
|
+
network_id: str
|
|
110
|
+
cidr: str
|
|
111
|
+
ip_version: int = 4
|
|
112
|
+
enable_dhcp: bool = True
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_api(cls, data: dict) -> Subnet:
|
|
116
|
+
return cls(
|
|
117
|
+
id=str(data.get("id", "")),
|
|
118
|
+
name=str(data.get("name", "")),
|
|
119
|
+
network_id=str(data.get("network_id", "")),
|
|
120
|
+
cidr=str(data.get("cidr", "")),
|
|
121
|
+
ip_version=int(data.get("ip_version", 4)),
|
|
122
|
+
enable_dhcp=bool(data.get("enable_dhcp", True)),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class SecurityGroup:
|
|
128
|
+
id: str
|
|
129
|
+
name: str
|
|
130
|
+
description: str | None = None
|
|
131
|
+
rules: list[dict] = field(default_factory=list)
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_api(cls, data: dict) -> SecurityGroup:
|
|
135
|
+
return cls(
|
|
136
|
+
id=str(data.get("id", "")),
|
|
137
|
+
name=str(data.get("name", "")),
|
|
138
|
+
description=data.get("description"),
|
|
139
|
+
rules=list(data.get("security_group_rules") or data.get("rules") or []),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class PublicIP:
|
|
145
|
+
"""A public (floating) IP address."""
|
|
146
|
+
|
|
147
|
+
id: str
|
|
148
|
+
address: str
|
|
149
|
+
instance_id: str | None = None
|
|
150
|
+
status: str | None = None
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def from_api(cls, data: dict) -> PublicIP:
|
|
154
|
+
return cls(
|
|
155
|
+
id=str(data.get("id", "")),
|
|
156
|
+
address=str(data.get("floating_ip_address") or data.get("address", "")),
|
|
157
|
+
instance_id=data.get("port_id") or data.get("instance_id"),
|
|
158
|
+
status=data.get("status"),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class Volume:
|
|
164
|
+
id: str
|
|
165
|
+
name: str
|
|
166
|
+
size: int # GB
|
|
167
|
+
status: str | None = None
|
|
168
|
+
type: str | None = None
|
|
169
|
+
attached_to: str | None = None
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def from_api(cls, data: dict) -> Volume:
|
|
173
|
+
attachments = data.get("attachments") or []
|
|
174
|
+
attached = (
|
|
175
|
+
attachments[0].get("server_id") if attachments and isinstance(attachments[0], dict) else None
|
|
176
|
+
)
|
|
177
|
+
return cls(
|
|
178
|
+
id=str(data.get("id", "")),
|
|
179
|
+
name=str(data.get("name", "")),
|
|
180
|
+
size=int(data.get("size", 0) or 0),
|
|
181
|
+
status=data.get("status"),
|
|
182
|
+
type=data.get("volume_type") or data.get("type"),
|
|
183
|
+
attached_to=attached,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class Database:
|
|
189
|
+
id: str
|
|
190
|
+
name: str
|
|
191
|
+
datastore: str
|
|
192
|
+
status: str | None = None
|
|
193
|
+
plan: dict | None = None
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def from_api(cls, data: dict) -> Database:
|
|
197
|
+
ds = data.get("datastore")
|
|
198
|
+
if isinstance(ds, dict):
|
|
199
|
+
ds_type = str(ds.get("type", ""))
|
|
200
|
+
else:
|
|
201
|
+
ds_type = str(ds or data.get("datastore_type", ""))
|
|
202
|
+
return cls(
|
|
203
|
+
id=str(data.get("id", "")),
|
|
204
|
+
name=str(data.get("name", "")),
|
|
205
|
+
datastore=ds_type,
|
|
206
|
+
status=data.get("status"),
|
|
207
|
+
plan=data.get("flavor") or data.get("plan"),
|
|
208
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Mock-based tests using respx — no live API required."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
import respx
|
|
8
|
+
|
|
9
|
+
from sws import (
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
Client,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
QuotaExceededError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def client() -> Client:
|
|
20
|
+
return Client(api_key="sws_test", region="ng-lagos-1", base_url="https://api.example")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@respx.mock
|
|
24
|
+
def test_auth_header_and_region_sent(client: Client) -> None:
|
|
25
|
+
route = respx.get("https://api.example/api/compute/servers").mock(
|
|
26
|
+
return_value=httpx.Response(200, json=[]),
|
|
27
|
+
)
|
|
28
|
+
client.compute.list_instances()
|
|
29
|
+
req = route.calls.last.request
|
|
30
|
+
assert req.headers["authorization"] == "Bearer sws_test"
|
|
31
|
+
assert req.headers["x-region"] == "ng-lagos-1"
|
|
32
|
+
assert req.headers["user-agent"].startswith("sws-sdk-python/")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@respx.mock
|
|
36
|
+
def test_list_instances_parses_flavor_as_plan(client: Client) -> None:
|
|
37
|
+
respx.get("https://api.example/api/compute/servers").mock(
|
|
38
|
+
return_value=httpx.Response(
|
|
39
|
+
200,
|
|
40
|
+
json=[
|
|
41
|
+
{
|
|
42
|
+
"id": "i-1",
|
|
43
|
+
"name": "web-1",
|
|
44
|
+
"status": "ACTIVE",
|
|
45
|
+
"flavor": {"id": "m1.small", "vcpus": 1, "ram": 2048},
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
instances = client.compute.list_instances()
|
|
51
|
+
assert len(instances) == 1
|
|
52
|
+
assert instances[0].id == "i-1"
|
|
53
|
+
assert instances[0].plan == {"id": "m1.small", "vcpus": 1, "ram": 2048}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@respx.mock
|
|
57
|
+
def test_create_instance_translates_plan_to_flavor_id(client: Client) -> None:
|
|
58
|
+
"""SDK takes ``plan=`` from the caller but sends ``flavor_id`` over
|
|
59
|
+
the wire — the backend hasn't been renamed yet."""
|
|
60
|
+
route = respx.post("https://api.example/api/compute/servers").mock(
|
|
61
|
+
return_value=httpx.Response(
|
|
62
|
+
201,
|
|
63
|
+
json={"id": "i-2", "name": "web-2", "status": "BUILD"},
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
inst = client.compute.create_instance(
|
|
67
|
+
name="web-2",
|
|
68
|
+
image="ubuntu-22.04",
|
|
69
|
+
plan="m1.medium",
|
|
70
|
+
network_id="net-1",
|
|
71
|
+
key_name="my-key",
|
|
72
|
+
)
|
|
73
|
+
assert inst.id == "i-2"
|
|
74
|
+
body = route.calls.last.request.content.decode()
|
|
75
|
+
assert '"flavor_id":"m1.medium"' in body.replace(" ", "")
|
|
76
|
+
assert "plan" not in body # SDK keyword shouldn't leak into the wire payload
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@respx.mock
|
|
80
|
+
def test_404_raises_not_found(client: Client) -> None:
|
|
81
|
+
respx.get("https://api.example/api/compute/servers/missing").mock(
|
|
82
|
+
return_value=httpx.Response(404, json={"detail": "Not found"})
|
|
83
|
+
)
|
|
84
|
+
with pytest.raises(NotFoundError) as exc:
|
|
85
|
+
client.compute.get_instance("missing")
|
|
86
|
+
assert exc.value.status_code == 404
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@respx.mock
|
|
90
|
+
def test_403_quota_message_raises_quota_exceeded(client: Client) -> None:
|
|
91
|
+
respx.post("https://api.example/api/compute/servers").mock(
|
|
92
|
+
return_value=httpx.Response(
|
|
93
|
+
403, json={"detail": "Quota exceeded for instances: 10/10"}
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
with pytest.raises(QuotaExceededError):
|
|
97
|
+
client.compute.create_instance(name="x", image="i", plan="m1.tiny")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@respx.mock
|
|
101
|
+
def test_401_raises_auth_error(client: Client) -> None:
|
|
102
|
+
respx.get("https://api.example/api/compute/servers").mock(
|
|
103
|
+
return_value=httpx.Response(401, json={"detail": "bad token"})
|
|
104
|
+
)
|
|
105
|
+
with pytest.raises(AuthenticationError):
|
|
106
|
+
client.compute.list_instances()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@respx.mock
|
|
110
|
+
def test_422_raises_validation_error(client: Client) -> None:
|
|
111
|
+
respx.post("https://api.example/api/network/subnets").mock(
|
|
112
|
+
return_value=httpx.Response(422, json={"detail": "cidr required"})
|
|
113
|
+
)
|
|
114
|
+
with pytest.raises(ValidationError):
|
|
115
|
+
client.network.create_subnet(name="s", network_id="n", cidr="")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@respx.mock
|
|
119
|
+
def test_security_group_rule_payload(client: Client) -> None:
|
|
120
|
+
route = respx.post("https://api.example/api/network/security-group-rules").mock(
|
|
121
|
+
return_value=httpx.Response(201, json={"id": "r-1"})
|
|
122
|
+
)
|
|
123
|
+
client.network.add_security_group_rule(
|
|
124
|
+
"sg-1",
|
|
125
|
+
protocol="tcp",
|
|
126
|
+
port_range_min=22,
|
|
127
|
+
port_range_max=22,
|
|
128
|
+
remote_ip_prefix="0.0.0.0/0",
|
|
129
|
+
)
|
|
130
|
+
body = route.calls.last.request.content.decode()
|
|
131
|
+
assert '"security_group_id":"sg-1"' in body.replace(" ", "")
|
|
132
|
+
assert '"direction":"ingress"' in body.replace(" ", "")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@respx.mock
|
|
136
|
+
def test_volume_attach_uses_instance_id(client: Client) -> None:
|
|
137
|
+
route = respx.post("https://api.example/api/block-storage/volumes/v-1/attach").mock(
|
|
138
|
+
return_value=httpx.Response(202)
|
|
139
|
+
)
|
|
140
|
+
client.storage.attach_volume("v-1", instance_id="i-9")
|
|
141
|
+
body = route.calls.last.request.content.decode()
|
|
142
|
+
assert '"instance_id":"i-9"' in body.replace(" ", "")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_missing_api_key_raises() -> None:
|
|
146
|
+
import os
|
|
147
|
+
|
|
148
|
+
old = os.environ.pop("SWS_API_KEY", None)
|
|
149
|
+
try:
|
|
150
|
+
with pytest.raises(AuthenticationError):
|
|
151
|
+
Client()
|
|
152
|
+
finally:
|
|
153
|
+
if old:
|
|
154
|
+
os.environ["SWS_API_KEY"] = old
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_env_var_resolution(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
158
|
+
monkeypatch.setenv("SWS_API_KEY", "sws_from_env")
|
|
159
|
+
monkeypatch.setenv("SWS_REGION", "ng-abuja-1")
|
|
160
|
+
c = Client(base_url="https://api.example")
|
|
161
|
+
assert c.region == "ng-abuja-1"
|
|
162
|
+
assert c._http.headers["authorization"] == "Bearer sws_from_env"
|
|
163
|
+
assert c._http.headers["x-region"] == "ng-abuja-1"
|