wayscloud 0.3.0__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 (31) hide show
  1. {wayscloud-0.3.0 → wayscloud-0.4.0}/PKG-INFO +67 -1
  2. wayscloud-0.4.0/README.md +157 -0
  3. {wayscloud-0.3.0 → wayscloud-0.4.0}/pyproject.toml +1 -1
  4. {wayscloud-0.3.0 → wayscloud-0.4.0}/setup.cfg +1 -1
  5. wayscloud-0.4.0/tests/test_kubernetes_service.py +117 -0
  6. {wayscloud-0.3.0 → wayscloud-0.4.0}/tests/test_smoke.py +20 -17
  7. wayscloud-0.4.0/wayscloud/_version.py +1 -0
  8. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/client.py +32 -8
  9. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/apps.py +39 -0
  10. wayscloud-0.4.0/wayscloud/services/impact.py +76 -0
  11. wayscloud-0.4.0/wayscloud/services/kubernetes.py +159 -0
  12. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud.egg-info/PKG-INFO +67 -1
  13. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud.egg-info/SOURCES.txt +3 -0
  14. wayscloud-0.3.0/README.md +0 -91
  15. wayscloud-0.3.0/wayscloud/_version.py +0 -1
  16. {wayscloud-0.3.0 → wayscloud-0.4.0}/LICENSE +0 -0
  17. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/__init__.py +0 -0
  18. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/exceptions.py +0 -0
  19. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/py.typed +0 -0
  20. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/__init__.py +0 -0
  21. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/account.py +0 -0
  22. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/database.py +0 -0
  23. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/dns.py +0 -0
  24. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/iot.py +0 -0
  25. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/redis.py +0 -0
  26. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/sms.py +0 -0
  27. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/storage.py +0 -0
  28. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/vps.py +0 -0
  29. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud.egg-info/dependency_links.txt +0 -0
  30. {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud.egg-info/requires.txt +0 -0
  31. {wayscloud-0.3.0 → 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.3.0
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.3.0"
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.3.0
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
+ )
@@ -0,0 +1,76 @@
1
+ """Impact service — customer-funded reforestation contributions.
2
+
3
+ Maps 1:1 to /v1/impact/* endpoints. Mock provider only at MVP — every item
4
+ where `provider == 'mock'` carries `demo_data: True` from the server.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid as _uuid
10
+ from typing import Optional
11
+
12
+
13
+ class ImpactService:
14
+ """Forests, tree commitments, items (seeds/sprouts/trees), and feed."""
15
+
16
+ def __init__(self, client):
17
+ self._client = client
18
+
19
+ # ── Forests ───────────────────────────────────────────────────
20
+
21
+ def create_forest(self, name: str) -> dict:
22
+ return self._client.post("/v1/impact/forests", json={"name": name})
23
+
24
+ def list_forests(self) -> list[dict]:
25
+ data = self._client.get("/v1/impact/forests")
26
+ return data.get("forests", data) if isinstance(data, dict) else data
27
+
28
+ def forest_status(self, forest: str) -> dict:
29
+ """Counts by stage + commitment buckets. `forest` may be UUID or name."""
30
+ return self._client.get(f"/v1/impact/forests/{forest}")
31
+
32
+ def forest_feed(self, forest: str, limit: int = 20) -> list[dict]:
33
+ data = self._client.get(
34
+ f"/v1/impact/forests/{forest}/feed", params={"limit": limit}
35
+ )
36
+ return data.get("feed", data) if isinstance(data, dict) else data
37
+
38
+ # ── Tree commitments ──────────────────────────────────────────
39
+
40
+ def commit_trees(
41
+ self,
42
+ forest: str,
43
+ quantity: int,
44
+ idempotency_key: Optional[str] = None,
45
+ ) -> dict:
46
+ """Commit N trees to a forest. Server returns the commitment + items.
47
+
48
+ Idempotency: same idempotency_key returns the same commitment. If
49
+ omitted, the SDK generates a UUID4 client-side so retries on transient
50
+ network failures don't double-charge.
51
+ """
52
+ body = {
53
+ "forest_id": forest,
54
+ "quantity": quantity,
55
+ "idempotency_key": idempotency_key or f"sdk_{_uuid.uuid4().hex}",
56
+ }
57
+ return self._client.post("/v1/impact/tree-commitments", json=body)
58
+
59
+ def list_commitments(self, limit: int = 50) -> list[dict]:
60
+ data = self._client.get(
61
+ "/v1/impact/tree-commitments", params={"limit": limit}
62
+ )
63
+ return data.get("commitments", data) if isinstance(data, dict) else data
64
+
65
+ def get_commitment(self, commitment_id: str) -> dict:
66
+ return self._client.get(f"/v1/impact/tree-commitments/{commitment_id}")
67
+
68
+ # ── Items (seeds / sprouts / trees) ───────────────────────────
69
+
70
+ def get_tree(self, item_id: str) -> dict:
71
+ """`item_id` may be a UUID or an external short id like 'seed_81ab1f2c93'."""
72
+ return self._client.get(f"/v1/impact/trees/{item_id}")
73
+
74
+ def water_tree(self, item_id: str) -> dict:
75
+ """Cosmetic watering. Rate-limited server-side to once per 24h."""
76
+ return self._client.post(f"/v1/impact/trees/{item_id}/water", json={})
@@ -0,0 +1,159 @@
1
+ """Kubernetes service -- Managed Kubernetes clusters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Optional
7
+
8
+
9
+ class KubernetesService:
10
+ """Manage Kubernetes clusters.
11
+
12
+ All methods use the public API at /v1/kubernetes (PAT with the
13
+ ``kubernetes:read`` / ``kubernetes:write`` scopes, or API key auth).
14
+ Create and delete are asynchronous: the API answers 202 with the cluster
15
+ in status ``provisioning`` / ``deleting``; use :meth:`wait` to block.
16
+ """
17
+
18
+ TERMINAL = {"running", "error", "deleted"}
19
+
20
+ def __init__(self, client):
21
+ self._client = client
22
+
23
+ # -- catalogue -----------------------------------------------------------
24
+ def plans(self, region: str = "no", kind: Optional[str] = None) -> list[dict]:
25
+ """List cluster and node plans (``kind``: controlplane, node, ip, lb, storage, backup)."""
26
+ params = {"region": region}
27
+ if kind:
28
+ params["kind"] = kind
29
+ data = self._client.get("/v1/kubernetes/plans", params=params)
30
+ return data.get("plans", []) if isinstance(data, dict) else data
31
+
32
+ def regions(self) -> list[dict]:
33
+ data = self._client.get("/v1/kubernetes/regions")
34
+ return data.get("regions", []) if isinstance(data, dict) else data
35
+
36
+ def versions(self) -> dict:
37
+ return self._client.get("/v1/kubernetes/versions")
38
+
39
+ def estimate(self, plan_code: str, node_pools: list[dict], currency: str = "NOK", region: str = "no") -> dict:
40
+ return self._client.post("/v1/kubernetes/estimate",
41
+ json={"plan_code": plan_code, "node_pools": node_pools, "currency": currency, "region": region})
42
+
43
+ # -- clusters ------------------------------------------------------------
44
+ def list(self) -> list[dict]:
45
+ data = self._client.get("/v1/kubernetes/clusters")
46
+ return data.get("clusters", []) if isinstance(data, dict) else data
47
+
48
+ def get(self, cluster_id: str) -> dict:
49
+ return self._client.get(f"/v1/kubernetes/clusters/{cluster_id}")
50
+
51
+ def create(
52
+ self,
53
+ name: str,
54
+ node_pools: list[dict],
55
+ plan_code: str = "k8s-cluster-dev",
56
+ region: str = "no",
57
+ version: str = "1.34",
58
+ api_ip_filter: Optional[list[str]] = None,
59
+ ssh_key_ids: Optional[list[str]] = None,
60
+ ) -> dict:
61
+ """Create a cluster. ``node_pools``: ``[{"name": "default", "plan_code": "k8s-node-2c4g", "count": 2}]``."""
62
+ return self._client.post("/v1/kubernetes/clusters", json={
63
+ "name": name, "region": region, "plan_code": plan_code, "version": version,
64
+ "node_pools": node_pools, "api_ip_filter": api_ip_filter or [], "ssh_key_ids": ssh_key_ids or [],
65
+ })
66
+
67
+ def delete(self, cluster_id: str, confirm_name: str) -> dict:
68
+ """Delete a cluster. ``confirm_name`` must equal the cluster's name."""
69
+ return self._client.delete(f"/v1/kubernetes/clusters/{cluster_id}", json={"confirm_name": confirm_name})
70
+
71
+ def wait(self, cluster_id: str, timeout: int = 1800, interval: int = 15) -> dict:
72
+ """Poll until the cluster reaches running/error (or is gone after a delete)."""
73
+ from ..exceptions import NotFoundError
74
+
75
+ deadline = time.time() + timeout
76
+ while True:
77
+ try:
78
+ c = self.get(cluster_id)
79
+ except NotFoundError: # gone after a delete: terminal
80
+ return {"id": cluster_id, "status": "deleted"}
81
+ if c.get("status") in self.TERMINAL:
82
+ return c
83
+ if time.time() > deadline:
84
+ raise TimeoutError(f"cluster {cluster_id} still {c.get('status')} after {timeout}s")
85
+ time.sleep(interval)
86
+
87
+ def history(self, cluster_id: str) -> list[dict]:
88
+ data = self._client.get(f"/v1/kubernetes/clusters/{cluster_id}/history")
89
+ return data.get("events", []) if isinstance(data, dict) else data
90
+
91
+ # -- node pools ----------------------------------------------------------
92
+ def add_node_pool(self, cluster_id: str, name: str, plan_code: str, count: int,
93
+ labels: Optional[dict] = None, taints: Optional[list[dict]] = None,
94
+ ssh_key_ids: Optional[list[str]] = None) -> dict:
95
+ """Add a node pool. ``count`` must be 1-16; ``taints`` entries are
96
+ ``{"key": ..., "value": ..., "effect": ...}``. ``ssh_key_ids`` must be
97
+ SSH key ids on the account (ownership is validated by the API)."""
98
+ return self._client.post(f"/v1/kubernetes/clusters/{cluster_id}/node-pools",
99
+ json={"name": name, "plan_code": plan_code, "count": count, "labels": labels or {},
100
+ "taints": taints or [], "ssh_key_ids": ssh_key_ids or []})
101
+
102
+ def scale_node_pool(self, cluster_id: str, pool_name: str, count: int) -> dict:
103
+ """Scale a node pool. ``count`` must be between 1 and 16; scale-to-zero is not supported."""
104
+ return self._client.post(f"/v1/kubernetes/clusters/{cluster_id}/node-pools/{pool_name}/scale", json={"count": count})
105
+
106
+ def delete_node_pool(self, cluster_id: str, pool_name: str) -> dict:
107
+ return self._client.delete(f"/v1/kubernetes/clusters/{cluster_id}/node-pools/{pool_name}")
108
+
109
+ # -- access --------------------------------------------------------------
110
+ def kubeconfig(self, cluster_id: str) -> str:
111
+ """Admin kubeconfig as YAML text. Treat as a secret."""
112
+ return self._client.get(f"/v1/kubernetes/clusters/{cluster_id}/kubeconfig", raw=True)
113
+
114
+ def set_api_access(self, cluster_id: str, cidrs: list[str]) -> dict:
115
+ """Set the CIDRs allowed to reach the Kubernetes API. An empty list means the API is
116
+ only reachable from WAYSCloud infrastructure; add the client CIDR for external
117
+ kubectl/API access."""
118
+ return self._client.put(f"/v1/kubernetes/clusters/{cluster_id}/api-access", json={"cidrs": cidrs})
119
+
120
+ # -- public IPs ----------------------------------------------------------
121
+ def allocate_ip(self, cluster_id: str) -> dict:
122
+ return self._client.post(f"/v1/kubernetes/clusters/{cluster_id}/ips")
123
+
124
+ def release_ip(self, cluster_id: str, address: str) -> dict:
125
+ return self._client.delete(f"/v1/kubernetes/clusters/{cluster_id}/ips/{address}")
126
+
127
+ def set_ptr(self, cluster_id: str, address: str, ptr_record: str) -> dict:
128
+ """Set reverse DNS. The name must already resolve to the address."""
129
+ return self._client.put(f"/v1/kubernetes/clusters/{cluster_id}/ips/{address}/ptr", json={"ptr_record": ptr_record})
130
+
131
+ # -- backups -------------------------------------------------------------
132
+ def backup_policy(self, cluster_id: str) -> dict:
133
+ return self._client.get(f"/v1/kubernetes/clusters/{cluster_id}/backup-policy")
134
+
135
+ def set_backup_policy(self, cluster_id: str, *, enabled: bool = True, schedule_cron: str = "0 3 * * *",
136
+ retention_days: int = 14, include_volumes: bool = True, namespaces: Optional[list[str]] = None) -> dict:
137
+ return self._client.put(f"/v1/kubernetes/clusters/{cluster_id}/backup-policy", json={
138
+ "enabled": enabled, "schedule_cron": schedule_cron, "retention_days": retention_days,
139
+ "include_volumes": include_volumes, "namespaces": namespaces})
140
+
141
+ def backups(self, cluster_id: str) -> list[dict]:
142
+ data = self._client.get(f"/v1/kubernetes/clusters/{cluster_id}/backups")
143
+ return data.get("backups", []) if isinstance(data, dict) else data
144
+
145
+ def backup_now(self, cluster_id: str, namespaces: Optional[list[str]] = None) -> dict:
146
+ return self._client.post(f"/v1/kubernetes/clusters/{cluster_id}/backups", json={"namespaces": namespaces})
147
+
148
+ def restore(self, cluster_id: str, backup_id: str, namespaces: Optional[list[str]] = None) -> dict:
149
+ return self._client.post(f"/v1/kubernetes/clusters/{cluster_id}/backups/{backup_id}/restore", json={"namespaces": namespaces})
150
+
151
+ # -- upgrades ------------------------------------------------------------
152
+ def available_upgrades(self, cluster_id: str) -> list[str]:
153
+ data = self._client.get(f"/v1/kubernetes/clusters/{cluster_id}/upgrade")
154
+ return data.get("versions", []) if isinstance(data, dict) else data
155
+
156
+ def upgrade(self, cluster_id: str, version: str) -> dict:
157
+ """Upgrade the control plane to `version`. No backup is taken automatically; create
158
+ one first if the workload needs a restore point (the portal shows the same warning)."""
159
+ return self._client.post(f"/v1/kubernetes/clusters/{cluster_id}/upgrade", json={"version": version})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wayscloud
3
- Version: 0.3.0
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).
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  setup.cfg
5
+ tests/test_kubernetes_service.py
5
6
  tests/test_smoke.py
6
7
  wayscloud/__init__.py
7
8
  wayscloud/_version.py
@@ -18,7 +19,9 @@ wayscloud/services/account.py
18
19
  wayscloud/services/apps.py
19
20
  wayscloud/services/database.py
20
21
  wayscloud/services/dns.py
22
+ wayscloud/services/impact.py
21
23
  wayscloud/services/iot.py
24
+ wayscloud/services/kubernetes.py
22
25
  wayscloud/services/redis.py
23
26
  wayscloud/services/sms.py
24
27
  wayscloud/services/storage.py
wayscloud-0.3.0/README.md DELETED
@@ -1,91 +0,0 @@
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
- ## Error handling
57
-
58
- ```python
59
- from wayscloud import NotFoundError, AuthenticationError
60
-
61
- try:
62
- client.vps.get("id")
63
- except NotFoundError:
64
- print("Not found")
65
- except AuthenticationError:
66
- print("Invalid credentials")
67
- ```
68
-
69
- All exceptions inherit from `WaysCloudError`.
70
-
71
- ## Configuration
72
-
73
- | Parameter | Environment variable | Default |
74
- |-----------|---------------------|---------|
75
- | `token` | `WAYSCLOUD_TOKEN` | — |
76
- | `api_key` | `WAYSCLOUD_API_KEY` | — |
77
- | `base_url` | `WAYSCLOUD_API_URL` | `https://api.wayscloud.services` |
78
- | `timeout` | — | `30.0` |
79
-
80
- ## Retries
81
-
82
- Automatic retries on 429, 502, 503, 504 with exponential backoff. Respects `Retry-After` headers.
83
-
84
- ## Requirements
85
-
86
- - Python 3.10+
87
- - httpx
88
-
89
- ## License
90
-
91
- MIT — see [LICENSE](LICENSE).
@@ -1 +0,0 @@
1
- __version__ = "0.3.0"
File without changes
File without changes