wayscloud 0.2.3__tar.gz → 0.4.0__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.
Files changed (32) hide show
  1. {wayscloud-0.2.3 → wayscloud-0.4.0}/PKG-INFO +67 -1
  2. wayscloud-0.4.0/README.md +157 -0
  3. {wayscloud-0.2.3 → wayscloud-0.4.0}/pyproject.toml +1 -1
  4. {wayscloud-0.2.3 → wayscloud-0.4.0}/setup.cfg +1 -1
  5. wayscloud-0.4.0/tests/test_kubernetes_service.py +117 -0
  6. {wayscloud-0.2.3 → wayscloud-0.4.0}/tests/test_smoke.py +20 -17
  7. wayscloud-0.4.0/wayscloud/_version.py +1 -0
  8. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/client.py +32 -8
  9. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/apps.py +39 -0
  10. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/database.py +19 -6
  11. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/dns.py +6 -0
  12. wayscloud-0.4.0/wayscloud/services/impact.py +76 -0
  13. wayscloud-0.4.0/wayscloud/services/iot.py +376 -0
  14. wayscloud-0.4.0/wayscloud/services/kubernetes.py +159 -0
  15. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/redis.py +9 -3
  16. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/storage.py +15 -3
  17. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/vps.py +30 -0
  18. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud.egg-info/PKG-INFO +67 -1
  19. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud.egg-info/SOURCES.txt +3 -0
  20. wayscloud-0.2.3/README.md +0 -91
  21. wayscloud-0.2.3/wayscloud/_version.py +0 -1
  22. wayscloud-0.2.3/wayscloud/services/iot.py +0 -107
  23. {wayscloud-0.2.3 → wayscloud-0.4.0}/LICENSE +0 -0
  24. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/__init__.py +0 -0
  25. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/exceptions.py +0 -0
  26. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/py.typed +0 -0
  27. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/__init__.py +0 -0
  28. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/account.py +0 -0
  29. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/sms.py +0 -0
  30. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud.egg-info/dependency_links.txt +0 -0
  31. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud.egg-info/requires.txt +0 -0
  32. {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wayscloud
3
- Version: 0.2.3
3
+ Version: 0.4.0
4
4
  Summary: Official Python SDK for the WAYSCloud API
5
5
  Home-page: https://wayscloud.services
6
6
  License: MIT
@@ -76,6 +76,68 @@ client.storage.create_bucket("my-bucket")
76
76
  client.sms.send(to="+4712345678", message="Hello from WAYSCloud")
77
77
  ```
78
78
 
79
+ ## Kubernetes
80
+
81
+ `client.kubernetes` manages Managed Kubernetes clusters (PAT scopes
82
+ `kubernetes:read` / `kubernetes:write`). Create and delete are asynchronous:
83
+ the API answers `202` with status `provisioning` / `deleting`, and `wait()`
84
+ blocks until the cluster is `running`, `error`, or gone.
85
+
86
+ ```python
87
+ # Catalogue
88
+ client.kubernetes.plans(region="no", kind="node")
89
+ client.kubernetes.regions()
90
+ client.kubernetes.versions()
91
+ client.kubernetes.estimate(
92
+ plan_code="k8s-cluster-dev",
93
+ node_pools=[{"name": "default", "plan_code": "k8s-node-2c4g", "count": 2}],
94
+ )
95
+
96
+ # Clusters
97
+ cluster = client.kubernetes.create(
98
+ "shop",
99
+ node_pools=[{"name": "default", "plan_code": "k8s-node-2c4g", "count": 2}],
100
+ ssh_key_ids=["<ssh-key-id>"],
101
+ )
102
+ cluster = client.kubernetes.wait(cluster["id"])
103
+
104
+ client.kubernetes.list()
105
+ client.kubernetes.get(cluster["id"])
106
+ client.kubernetes.history(cluster["id"])
107
+ client.kubernetes.delete(cluster["id"], confirm_name="shop")
108
+
109
+ # Node pools (labels, taints, per-pool SSH keys)
110
+ client.kubernetes.add_node_pool(
111
+ cluster["id"], "workers", "k8s-node-2c4g", 3,
112
+ labels={"role": "worker"},
113
+ taints=[{"key": "dedicated", "value": "gpu", "effect": "NoSchedule"}],
114
+ ssh_key_ids=["<ssh-key-id>"],
115
+ )
116
+ client.kubernetes.scale_node_pool(cluster["id"], "workers", 5)
117
+ client.kubernetes.delete_node_pool(cluster["id"], "workers")
118
+
119
+ # Access — the kubeconfig is a secret; restrict the API to your CIDRs
120
+ client.kubernetes.kubeconfig(cluster["id"]) # YAML text
121
+ client.kubernetes.set_api_access(cluster["id"], ["203.0.113.7/32"])
122
+
123
+ # Public IPs and reverse DNS (allocate/release return the updated cluster)
124
+ cluster = client.kubernetes.allocate_ip(cluster["id"])
125
+ address = cluster["public_ips"][-1]["address"]
126
+ client.kubernetes.set_ptr(cluster["id"], address, "shop.example.com")
127
+ client.kubernetes.release_ip(cluster["id"], address)
128
+
129
+ # Backups
130
+ client.kubernetes.backup_policy(cluster["id"])
131
+ client.kubernetes.set_backup_policy(cluster["id"], schedule_cron="0 3 * * *", retention_days=14)
132
+ client.kubernetes.backups(cluster["id"])
133
+ client.kubernetes.backup_now(cluster["id"])
134
+ client.kubernetes.restore(cluster["id"], "<backup-id>")
135
+
136
+ # Upgrades — no backup is taken automatically, create one first
137
+ client.kubernetes.available_upgrades(cluster["id"])
138
+ client.kubernetes.upgrade(cluster["id"], "1.34")
139
+ ```
140
+
79
141
  ## Error handling
80
142
 
81
143
  ```python
@@ -109,6 +171,10 @@ Automatic retries on 429, 502, 503, 504 with exponential backoff. Respects `Retr
109
171
  - Python 3.10+
110
172
  - httpx
111
173
 
174
+ ## Changelog
175
+
176
+ See [CHANGELOG.md](https://github.com/wayscloudas/wayscloud-python-sdk/blob/main/CHANGELOG.md).
177
+
112
178
  ## License
113
179
 
114
180
  MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,157 @@
1
+ # WAYSCloud Python SDK
2
+
3
+ Official Python SDK for the [WAYSCloud](https://wayscloud.services) API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install wayscloud
9
+ ```
10
+
11
+ ## Authentication
12
+
13
+ ```python
14
+ from wayscloud import WaysCloudClient
15
+
16
+ # Personal Access Token
17
+ client = WaysCloudClient(token="wayscloud_pat_...")
18
+
19
+ # API key
20
+ client = WaysCloudClient(api_key="wayscloud_api_...")
21
+
22
+ # Environment variables (WAYSCLOUD_TOKEN or WAYSCLOUD_API_KEY)
23
+ client = WaysCloudClient()
24
+ ```
25
+
26
+ Priority: explicit arguments > environment variables.
27
+
28
+ ## Usage
29
+
30
+ ```python
31
+ # VPS
32
+ for vm in client.vps.list():
33
+ print(vm["hostname"], vm["status"])
34
+
35
+ # DNS
36
+ client.dns.create_record(
37
+ "example.com",
38
+ record_type="A",
39
+ name="www",
40
+ value="192.0.2.1",
41
+ )
42
+
43
+ # Database
44
+ db = client.database.create(name="prod", db_type="postgresql")
45
+
46
+ # Apps
47
+ app = client.apps.create(name="my-app", region="eu")
48
+
49
+ # Storage
50
+ client.storage.create_bucket("my-bucket")
51
+
52
+ # SMS
53
+ client.sms.send(to="+4712345678", message="Hello from WAYSCloud")
54
+ ```
55
+
56
+ ## Kubernetes
57
+
58
+ `client.kubernetes` manages Managed Kubernetes clusters (PAT scopes
59
+ `kubernetes:read` / `kubernetes:write`). Create and delete are asynchronous:
60
+ the API answers `202` with status `provisioning` / `deleting`, and `wait()`
61
+ blocks until the cluster is `running`, `error`, or gone.
62
+
63
+ ```python
64
+ # Catalogue
65
+ client.kubernetes.plans(region="no", kind="node")
66
+ client.kubernetes.regions()
67
+ client.kubernetes.versions()
68
+ client.kubernetes.estimate(
69
+ plan_code="k8s-cluster-dev",
70
+ node_pools=[{"name": "default", "plan_code": "k8s-node-2c4g", "count": 2}],
71
+ )
72
+
73
+ # Clusters
74
+ cluster = client.kubernetes.create(
75
+ "shop",
76
+ node_pools=[{"name": "default", "plan_code": "k8s-node-2c4g", "count": 2}],
77
+ ssh_key_ids=["<ssh-key-id>"],
78
+ )
79
+ cluster = client.kubernetes.wait(cluster["id"])
80
+
81
+ client.kubernetes.list()
82
+ client.kubernetes.get(cluster["id"])
83
+ client.kubernetes.history(cluster["id"])
84
+ client.kubernetes.delete(cluster["id"], confirm_name="shop")
85
+
86
+ # Node pools (labels, taints, per-pool SSH keys)
87
+ client.kubernetes.add_node_pool(
88
+ cluster["id"], "workers", "k8s-node-2c4g", 3,
89
+ labels={"role": "worker"},
90
+ taints=[{"key": "dedicated", "value": "gpu", "effect": "NoSchedule"}],
91
+ ssh_key_ids=["<ssh-key-id>"],
92
+ )
93
+ client.kubernetes.scale_node_pool(cluster["id"], "workers", 5)
94
+ client.kubernetes.delete_node_pool(cluster["id"], "workers")
95
+
96
+ # Access — the kubeconfig is a secret; restrict the API to your CIDRs
97
+ client.kubernetes.kubeconfig(cluster["id"]) # YAML text
98
+ client.kubernetes.set_api_access(cluster["id"], ["203.0.113.7/32"])
99
+
100
+ # Public IPs and reverse DNS (allocate/release return the updated cluster)
101
+ cluster = client.kubernetes.allocate_ip(cluster["id"])
102
+ address = cluster["public_ips"][-1]["address"]
103
+ client.kubernetes.set_ptr(cluster["id"], address, "shop.example.com")
104
+ client.kubernetes.release_ip(cluster["id"], address)
105
+
106
+ # Backups
107
+ client.kubernetes.backup_policy(cluster["id"])
108
+ client.kubernetes.set_backup_policy(cluster["id"], schedule_cron="0 3 * * *", retention_days=14)
109
+ client.kubernetes.backups(cluster["id"])
110
+ client.kubernetes.backup_now(cluster["id"])
111
+ client.kubernetes.restore(cluster["id"], "<backup-id>")
112
+
113
+ # Upgrades — no backup is taken automatically, create one first
114
+ client.kubernetes.available_upgrades(cluster["id"])
115
+ client.kubernetes.upgrade(cluster["id"], "1.34")
116
+ ```
117
+
118
+ ## Error handling
119
+
120
+ ```python
121
+ from wayscloud import NotFoundError, AuthenticationError
122
+
123
+ try:
124
+ client.vps.get("id")
125
+ except NotFoundError:
126
+ print("Not found")
127
+ except AuthenticationError:
128
+ print("Invalid credentials")
129
+ ```
130
+
131
+ All exceptions inherit from `WaysCloudError`.
132
+
133
+ ## Configuration
134
+
135
+ | Parameter | Environment variable | Default |
136
+ |-----------|---------------------|---------|
137
+ | `token` | `WAYSCLOUD_TOKEN` | — |
138
+ | `api_key` | `WAYSCLOUD_API_KEY` | — |
139
+ | `base_url` | `WAYSCLOUD_API_URL` | `https://api.wayscloud.services` |
140
+ | `timeout` | — | `30.0` |
141
+
142
+ ## Retries
143
+
144
+ Automatic retries on 429, 502, 503, 504 with exponential backoff. Respects `Retry-After` headers.
145
+
146
+ ## Requirements
147
+
148
+ - Python 3.10+
149
+ - httpx
150
+
151
+ ## Changelog
152
+
153
+ See [CHANGELOG.md](https://github.com/wayscloudas/wayscloud-python-sdk/blob/main/CHANGELOG.md).
154
+
155
+ ## License
156
+
157
+ MIT — see [LICENSE](LICENSE).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "wayscloud"
3
- version = "0.2.3"
3
+ version = "0.4.0"
4
4
  description = "Official Python SDK for the WAYSCloud API"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = wayscloud
3
- version = 0.2.3
3
+ version = 0.4.0
4
4
  description = Official Python SDK for the WAYSCloud API
5
5
  long_description = file: README.md
6
6
  long_description_content_type = text/markdown
@@ -0,0 +1,117 @@
1
+ """KubernetesService request shapes against a fake client (no HTTP)."""
2
+ import importlib.util
3
+ import os
4
+ import sys
5
+ import types
6
+ import unittest
7
+
8
+ HERE = os.path.dirname(__file__)
9
+ SDK = os.path.join(HERE, "..")
10
+
11
+ try:
12
+ # Normal case (repo root on sys.path or package installed): use the real
13
+ # package so later test modules can still import `wayscloud`.
14
+ from wayscloud.exceptions import NotFoundError
15
+ from wayscloud.services.kubernetes import KubernetesService
16
+ except ImportError:
17
+ # Standalone fallback: load the service module without importing the
18
+ # package __init__ (which needs httpx).
19
+ pkg = types.ModuleType("wayscloud"); pkg.__path__ = [os.path.join(SDK, "wayscloud")]
20
+ sys.modules.setdefault("wayscloud", pkg)
21
+ exc_mod = types.ModuleType("wayscloud.exceptions")
22
+
23
+
24
+ class WaysCloudError(Exception):
25
+ pass
26
+
27
+
28
+ class NotFoundError(WaysCloudError):
29
+ pass
30
+
31
+
32
+ exc_mod.WaysCloudError, exc_mod.NotFoundError = WaysCloudError, NotFoundError
33
+ sys.modules["wayscloud.exceptions"] = exc_mod
34
+ spec = importlib.util.spec_from_file_location("wayscloud.services.kubernetes", os.path.join(SDK, "wayscloud", "services", "kubernetes.py"))
35
+ mod = importlib.util.module_from_spec(spec); sys.modules[spec.name] = mod; spec.loader.exec_module(mod)
36
+ KubernetesService = mod.KubernetesService
37
+
38
+
39
+ class FakeClient:
40
+ def __init__(self, responses=None):
41
+ self.calls, self.responses = [], list(responses or [])
42
+
43
+ def _rec(self, method, path, **kw):
44
+ self.calls.append((method, path, kw))
45
+ return self.responses.pop(0) if self.responses else {}
46
+
47
+ def get(self, path, params=None, raw=False):
48
+ return self._rec("GET", path, params=params, raw=raw)
49
+
50
+ def post(self, path, json=None):
51
+ return self._rec("POST", path, json=json)
52
+
53
+ def put(self, path, json=None):
54
+ return self._rec("PUT", path, json=json)
55
+
56
+ def delete(self, path, json=None):
57
+ return self._rec("DELETE", path, json=json)
58
+
59
+
60
+ class ServiceTests(unittest.TestCase):
61
+ def test_create_payload(self):
62
+ c = FakeClient([{"id": "c1", "status": "provisioning"}])
63
+ out = KubernetesService(c).create("shop", [{"name": "default", "plan_code": "k8s-node-2c4g", "count": 2}], api_ip_filter=["1.2.3.0/24"])
64
+ self.assertEqual(out["id"], "c1")
65
+ m, p, kw = c.calls[0]
66
+ self.assertEqual((m, p), ("POST", "/v1/kubernetes/clusters"))
67
+ self.assertEqual(kw["json"]["node_pools"][0]["count"], 2)
68
+ self.assertEqual(kw["json"]["plan_code"], "k8s-cluster-dev")
69
+ self.assertEqual(kw["json"]["ssh_key_ids"], [])
70
+
71
+ def test_add_node_pool_sends_labels_taints_and_ssh_keys(self):
72
+ c = FakeClient([{"id": "c1"}])
73
+ KubernetesService(c).add_node_pool(
74
+ "c1", "workers", "k8s-node-2c4g", 2,
75
+ labels={"role": "worker"},
76
+ taints=[{"key": "dedicated", "value": "gpu", "effect": "NoSchedule"}],
77
+ ssh_key_ids=["11111111-1111-1111-1111-111111111111"])
78
+ m, p, kw = c.calls[0]
79
+ self.assertEqual((m, p), ("POST", "/v1/kubernetes/clusters/c1/node-pools"))
80
+ self.assertEqual(kw["json"]["labels"], {"role": "worker"})
81
+ self.assertEqual(kw["json"]["taints"], [{"key": "dedicated", "value": "gpu", "effect": "NoSchedule"}])
82
+ self.assertEqual(kw["json"]["ssh_key_ids"], ["11111111-1111-1111-1111-111111111111"])
83
+ self.assertEqual(kw["json"]["count"], 2)
84
+
85
+ def test_add_node_pool_defaults_are_empty(self):
86
+ c = FakeClient([{"id": "c1"}])
87
+ KubernetesService(c).add_node_pool("c1", "workers", "k8s-node-2c4g", 1)
88
+ self.assertEqual(c.calls[0][2]["json"], {"name": "workers", "plan_code": "k8s-node-2c4g", "count": 1,
89
+ "labels": {}, "taints": [], "ssh_key_ids": []})
90
+
91
+ def test_delete_sends_confirmation_body(self):
92
+ c = FakeClient([{"status": "deleting"}])
93
+ KubernetesService(c).delete("c1", "shop")
94
+ self.assertEqual(c.calls[0], ("DELETE", "/v1/kubernetes/clusters/c1", {"json": {"confirm_name": "shop"}}))
95
+
96
+ def test_kubeconfig_is_raw_text(self):
97
+ c = FakeClient(["apiVersion: v1"])
98
+ self.assertEqual(KubernetesService(c).kubeconfig("c1"), "apiVersion: v1")
99
+ self.assertTrue(c.calls[0][2]["raw"])
100
+
101
+ def test_list_unwraps_envelope(self):
102
+ c = FakeClient([{"clusters": [{"id": "a"}], "total": 1}])
103
+ self.assertEqual(KubernetesService(c).list(), [{"id": "a"}])
104
+
105
+ def test_wait_returns_on_running_and_on_404(self):
106
+ c = FakeClient([{"status": "provisioning"}, {"status": "running", "id": "c1"}])
107
+ svc = KubernetesService(c)
108
+ self.assertEqual(svc.wait("c1", timeout=5, interval=0)["status"], "running")
109
+
110
+ class Gone(FakeClient):
111
+ def get(self, *a, **k):
112
+ raise NotFoundError("404")
113
+ self.assertEqual(KubernetesService(Gone()).wait("c1", timeout=5, interval=0)["status"], "deleted")
114
+
115
+
116
+ if __name__ == "__main__":
117
+ unittest.main()
@@ -1,5 +1,6 @@
1
1
  """SDK smoke tests — verify public API surface, auth, lifecycle, and error handling."""
2
2
 
3
+ import json
3
4
  import os
4
5
 
5
6
  import httpx
@@ -115,29 +116,31 @@ def test_all_services_instantiate():
115
116
 
116
117
  @respx.mock
117
118
  def test_get_returns_json():
118
- respx.get("https://api.wayscloud.services/api/v1/dashboard/vps").mock(
119
- return_value=httpx.Response(200, json={"instances": []})
119
+ respx.get("https://api.wayscloud.services/v1/vps/").mock(
120
+ return_value=httpx.Response(200, json={"total": 1, "vps_instances": [{"id": "vps-1", "hostname": "web01"}]})
120
121
  )
121
122
  with WaysCloudClient(token="t") as c:
122
123
  result = c.vps.list()
123
- assert result == []
124
+ assert result == [{"id": "vps-1", "hostname": "web01"}]
124
125
 
125
126
 
126
127
  @respx.mock
127
128
  def test_post_sends_json_body():
128
- route = respx.post("https://api.wayscloud.services/api/v1/dashboard/dns/zones").mock(
129
- return_value=httpx.Response(201, json={"id": "z1", "name": "example.com"})
129
+ route = respx.post("https://api.wayscloud.services/v1/dns/zones").mock(
130
+ return_value=httpx.Response(201, json={"zone_id": "z1", "zone_name": "example.com"})
130
131
  )
131
132
  with WaysCloudClient(token="t") as c:
132
133
  result = c.dns.create_zone("example.com")
133
- assert result["name"] == "example.com"
134
- assert route.calls[0].request.headers["content-type"] == "application/json"
134
+ assert result["zone_name"] == "example.com"
135
+ request = route.calls[0].request
136
+ assert request.headers["content-type"] == "application/json"
137
+ assert json.loads(request.content) == {"zone_name": "example.com", "zone_type": "master"}
135
138
 
136
139
 
137
140
  @respx.mock
138
141
  def test_get_does_not_send_content_type():
139
- route = respx.get("https://api.wayscloud.services/api/v1/dashboard/vps").mock(
140
- return_value=httpx.Response(200, json={"instances": []})
142
+ route = respx.get("https://api.wayscloud.services/v1/vps/").mock(
143
+ return_value=httpx.Response(200, json={"total": 0, "vps_instances": []})
141
144
  )
142
145
  with WaysCloudClient(token="t") as c:
143
146
  c.vps.list()
@@ -146,7 +149,7 @@ def test_get_does_not_send_content_type():
146
149
 
147
150
  @respx.mock
148
151
  def test_204_returns_ok():
149
- respx.delete("https://api.wayscloud.services/api/v1/dashboard/vps/abc").mock(
152
+ respx.delete("https://api.wayscloud.services/v1/vps/abc").mock(
150
153
  return_value=httpx.Response(204)
151
154
  )
152
155
  with WaysCloudClient(token="t") as c:
@@ -158,7 +161,7 @@ def test_204_returns_ok():
158
161
 
159
162
  @respx.mock
160
163
  def test_401_raises_authentication_error():
161
- respx.get("https://api.wayscloud.services/api/v1/dashboard/vps").mock(
164
+ respx.get("https://api.wayscloud.services/v1/vps/").mock(
162
165
  return_value=httpx.Response(401, json={"detail": "Invalid token"})
163
166
  )
164
167
  with WaysCloudClient(token="t") as c:
@@ -169,7 +172,7 @@ def test_401_raises_authentication_error():
169
172
 
170
173
  @respx.mock
171
174
  def test_404_raises_not_found():
172
- respx.get("https://api.wayscloud.services/api/v1/dashboard/vps/bad").mock(
175
+ respx.get("https://api.wayscloud.services/v1/vps/bad").mock(
173
176
  return_value=httpx.Response(404, json={"detail": "Not found"})
174
177
  )
175
178
  with WaysCloudClient(token="t") as c:
@@ -179,7 +182,7 @@ def test_404_raises_not_found():
179
182
 
180
183
  @respx.mock
181
184
  def test_422_raises_validation_error():
182
- respx.post("https://api.wayscloud.services/api/v1/dashboard/dns/zones").mock(
185
+ respx.post("https://api.wayscloud.services/v1/dns/zones").mock(
183
186
  return_value=httpx.Response(422, json={"detail": "Invalid zone name"})
184
187
  )
185
188
  with WaysCloudClient(token="t") as c:
@@ -189,7 +192,7 @@ def test_422_raises_validation_error():
189
192
 
190
193
  @respx.mock
191
194
  def test_500_raises_server_error():
192
- respx.get("https://api.wayscloud.services/api/v1/dashboard/vps").mock(
195
+ respx.get("https://api.wayscloud.services/v1/vps/").mock(
193
196
  return_value=httpx.Response(500, json={"detail": "Internal error"})
194
197
  )
195
198
  with WaysCloudClient(token="t") as c:
@@ -201,16 +204,16 @@ def test_500_raises_server_error():
201
204
 
202
205
  @respx.mock
203
206
  def test_retries_on_429_then_succeeds():
204
- route = respx.get("https://api.wayscloud.services/api/v1/dashboard/vps").mock(
207
+ route = respx.get("https://api.wayscloud.services/v1/vps/").mock(
205
208
  side_effect=[
206
209
  httpx.Response(429),
207
- httpx.Response(200, json={"instances": [{"hostname": "ok"}]}),
210
+ httpx.Response(200, json={"total": 1, "vps_instances": [{"hostname": "ok"}]}),
208
211
  ]
209
212
  )
210
213
  with WaysCloudClient(token="t") as c:
211
214
  c.BACKOFF_FACTOR = 0.01 # Speed up test
212
215
  result = c.vps.list()
213
- assert len(result) == 1
216
+ assert result == [{"hostname": "ok"}]
214
217
  assert route.call_count == 2
215
218
 
216
219
 
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -86,10 +86,12 @@ class WaysCloudClient:
86
86
  self._storage: Optional[Any] = None
87
87
  self._database: Optional[Any] = None
88
88
  self._redis: Optional[Any] = None
89
+ self._kubernetes: Optional[Any] = None
89
90
  self._apps: Optional[Any] = None
90
91
  self._iot: Optional[Any] = None
91
92
  self._sms: Optional[Any] = None
92
93
  self._account: Optional[Any] = None
94
+ self._impact: Optional[Any] = None
93
95
 
94
96
  # ── Lifecycle ─────────────────────────────────────────────────
95
97
 
@@ -111,9 +113,13 @@ class WaysCloudClient:
111
113
  path: str,
112
114
  json: Any = None,
113
115
  params: Optional[dict] = None,
116
+ raw: bool = False,
114
117
  ) -> Any:
115
118
  """Execute HTTP request with retry and error mapping.
116
119
 
120
+ ``raw=True`` returns the response body as text (e.g. a kubeconfig YAML)
121
+ instead of decoding JSON.
122
+
117
123
  Retries up to MAX_RETRIES times on 429/502/503/504 with exponential
118
124
  backoff. Maps HTTP error codes to typed exceptions.
119
125
  """
@@ -134,8 +140,10 @@ class WaysCloudClient:
134
140
  params=params,
135
141
  )
136
142
 
137
- # Success
138
- if response.status_code in (200, 201):
143
+ # Success (202 = accepted, asynchronous operations such as cluster create/delete)
144
+ if response.status_code in (200, 201, 202):
145
+ if raw:
146
+ return response.text
139
147
  try:
140
148
  return response.json()
141
149
  except Exception:
@@ -217,9 +225,9 @@ class WaysCloudClient:
217
225
  else:
218
226
  raise WaysCloudError(message=message, status_code=status, detail=detail)
219
227
 
220
- def get(self, path: str, params: Optional[dict] = None) -> Any:
221
- """Execute GET request."""
222
- return self._request("GET", path, params=params)
228
+ def get(self, path: str, params: Optional[dict] = None, raw: bool = False) -> Any:
229
+ """Execute GET request. ``raw=True`` returns text instead of JSON."""
230
+ return self._request("GET", path, params=params, raw=raw)
223
231
 
224
232
  def post(self, path: str, json: Any = None) -> Any:
225
233
  """Execute POST request."""
@@ -233,9 +241,9 @@ class WaysCloudClient:
233
241
  """Execute PATCH request."""
234
242
  return self._request("PATCH", path, json=json)
235
243
 
236
- def delete(self, path: str) -> Any:
237
- """Execute DELETE request."""
238
- return self._request("DELETE", path)
244
+ def delete(self, path: str, json: Any = None) -> Any:
245
+ """Execute DELETE request (optionally with a JSON body, e.g. a confirmation)."""
246
+ return self._request("DELETE", path, json=json)
239
247
 
240
248
  # ── Lazy service properties ───────────────────────────────────
241
249
 
@@ -279,6 +287,14 @@ class WaysCloudClient:
279
287
  self._redis = RedisService(self)
280
288
  return self._redis
281
289
 
290
+ @property
291
+ def kubernetes(self):
292
+ """Managed Kubernetes clusters (/v1/kubernetes)."""
293
+ if self._kubernetes is None:
294
+ from .services.kubernetes import KubernetesService
295
+ self._kubernetes = KubernetesService(self)
296
+ return self._kubernetes
297
+
282
298
  @property
283
299
  def apps(self):
284
300
  """App Platform service."""
@@ -310,3 +326,11 @@ class WaysCloudClient:
310
326
  from .services.account import AccountService
311
327
  self._account = AccountService(self)
312
328
  return self._account
329
+
330
+ @property
331
+ def impact(self):
332
+ """Impact Trees service — customer-funded reforestation contributions."""
333
+ if self._impact is None:
334
+ from .services.impact import ImpactService
335
+ self._impact = ImpactService(self)
336
+ return self._impact
@@ -102,3 +102,42 @@ class AppService:
102
102
  # domains(), add_domain(), remove_domain() removed.
103
103
  # No public /v1/apps/{id}/domains endpoint exists (404 verified).
104
104
  # Domain management is dashboard-only.
105
+
106
+ # ── GitHub auto-deploy ────────────────────────────────────────
107
+ # Dashboard-only endpoints. PAT auth is accepted by the
108
+ # get_authenticated_customer_or_internal dependency, so SDK/CLI
109
+ # callers can reach /v1/dashboard/apps/.../auto-deploy/github
110
+ # with the same PAT they already use for /v1/apps.
111
+
112
+ def auto_deploy_get(self, app_id: str) -> dict:
113
+ """Return GitHub auto-deploy config for the app (no secret)."""
114
+ return self._client.get(
115
+ f"/v1/dashboard/apps/{app_id}/auto-deploy/github"
116
+ )
117
+
118
+ def auto_deploy_configure(
119
+ self,
120
+ app_id: str,
121
+ repo_url: str,
122
+ branch: str = "main",
123
+ auto_deploy_enabled: bool = True,
124
+ ) -> dict:
125
+ """Configure/update GitHub auto-deploy.
126
+
127
+ The first call for an app returns the generated webhook_secret
128
+ in the response — surface it to the user exactly once.
129
+ """
130
+ return self._client.put(
131
+ f"/v1/dashboard/apps/{app_id}/auto-deploy/github",
132
+ json={
133
+ "repo_url": repo_url,
134
+ "branch": branch,
135
+ "auto_deploy_enabled": auto_deploy_enabled,
136
+ },
137
+ )
138
+
139
+ def auto_deploy_rotate_secret(self, app_id: str) -> dict:
140
+ """Rotate the per-app webhook secret. Returns the new one once."""
141
+ return self._client.post(
142
+ f"/v1/dashboard/apps/{app_id}/auto-deploy/github/rotate-secret"
143
+ )
@@ -59,17 +59,30 @@ class DatabaseService:
59
59
  data = self._client.get(f"/v1/databases/{db_type}/{name}/firewall")
60
60
  return data if isinstance(data, list) else data.get("rules", [])
61
61
 
62
- def add_firewall_rule(self, db_type: str, name: str, source: str) -> dict:
63
- """Add a firewall rule to allow access from a CIDR."""
62
+ def add_firewall_rule(self, db_type: str, name: str, ip_address: str, description: str = "") -> dict:
63
+ """Add a firewall rule to whitelist an IP address.
64
+
65
+ Args:
66
+ db_type: Database type (postgresql or mariadb).
67
+ name: Database name.
68
+ ip_address: IPv4 address to whitelist (e.g., '203.0.113.50').
69
+ description: Optional description.
70
+ """
71
+ body: dict = {"ip_address": ip_address}
72
+ if description:
73
+ body["description"] = description
64
74
  return self._client.post(
65
75
  f"/v1/databases/{db_type}/{name}/firewall",
66
- json={"source": source},
76
+ json=body,
67
77
  )
68
78
 
69
79
  def remove_firewall_rule(self, db_type: str, name: str, rule_id: str) -> dict:
70
80
  """Remove a firewall rule."""
71
81
  return self._client.delete(f"/v1/databases/{db_type}/{name}/firewall/{rule_id}")
72
82
 
73
- # credentials() removed — no public /v1/databases/.../credentials endpoint.
74
- # Connection credentials are returned in create() response.
75
- # tiers() removed — no public endpoint. Dashboard-only discovery.
83
+ # ── Tiers ──────────────────────────────────────────────────────
84
+
85
+ def tiers(self) -> list[dict]:
86
+ """List available database tiers (standard, encrypted)."""
87
+ data = self._client.get("/v1/databases/tiers")
88
+ return data if isinstance(data, list) else data.get("tiers", [])