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.
@@ -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
@@ -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
@@ -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"