kc-sdk-python 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kvindo
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.
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: kc-sdk-python
3
+ Version: 0.1.0
4
+ Summary: Python SDK / client for the Kvindo Cloud API
5
+ Author: Kvindo
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Kvindo/kc-sdk-python
8
+ Project-URL: Repository, https://github.com/Kvindo/kc-sdk-python
9
+ Keywords: kvindo,cloud,sdk,client,api
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Intended Audience :: Developers
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: requests
17
+ Requires-Dist: marshmallow-dataclass
18
+ Requires-Dist: py-ulid
19
+ Dynamic: license-file
20
+
21
+ # kc-sdk-python
22
+
23
+ Python SDK / client for the **Kvindo Cloud API**.
24
+
25
+ A thin, typed client over the REST API: one resource client per resource type
26
+ (VMs, volumes, load balancers, kubernetes, S3, VPCs, …), all sharing the same
27
+ create / read / update / delete / list contract.
28
+
29
+ ## Install
30
+
31
+ ```sh
32
+ pip install kc-sdk-python
33
+ ```
34
+
35
+ Dependencies: `requests`, `marshmallow-dataclass`, `py-ulid`.
36
+
37
+ ## Usage
38
+
39
+ ```python
40
+ from kc_api import KcClient
41
+
42
+ client = KcClient("YOUR_API_TOKEN") # api_url defaults to https://cloud-api.kvindo.ru
43
+
44
+ # List (label-filtered, paginated)
45
+ resp = client.vms.get_by_labels({"env": "prod"}, max_page_size=50)
46
+ for vm in resp.resources:
47
+ print(vm["metadata"]["name"])
48
+
49
+ # Read one
50
+ vm = client.vms.read("01H...")
51
+ print(vm.resource)
52
+
53
+ # Create / update (async) then wait for it to reconcile
54
+ created = client.vms.create_or_update({
55
+ "metadata": {"name": "my-vm", "folderId": "01H..."},
56
+ "spec": {"offerId": "g3-1c2-100", "state": "running", ...},
57
+ })
58
+ status = client.vms.wait_request_satisfied(created.requestId, timeout_seconds=300)
59
+ assert status.succeeded
60
+
61
+ # Delete (optionally block until reconciled)
62
+ client.vms.delete("01H...", wait=True)
63
+ ```
64
+
65
+ Create / update / delete are **asynchronous**: they return a `requestId`; poll
66
+ `read_request(requestId)` or use `wait_request_satisfied(...)`. Every response
67
+ object carries `errorMessage` / `errorCode` (a typed `KcApi*ErrorCode`) which are
68
+ `None` on success.
69
+
70
+ ### Available resources
71
+
72
+ `KcClient` exposes one `KcResourceClient` per type, e.g. `client.vms`,
73
+ `client.volumes`, `client.s3_buckets`, `client.kubernetes`,
74
+ `client.load_balancers`, `client.vpcs`, `client.postgresql_standalones`,
75
+ `client.folders`, `client.transactions`, … (the surface mirrors the official
76
+ Kvindo Cloud API).
77
+
78
+ ## License
79
+
80
+ MIT
@@ -0,0 +1,60 @@
1
+ # kc-sdk-python
2
+
3
+ Python SDK / client for the **Kvindo Cloud API**.
4
+
5
+ A thin, typed client over the REST API: one resource client per resource type
6
+ (VMs, volumes, load balancers, kubernetes, S3, VPCs, …), all sharing the same
7
+ create / read / update / delete / list contract.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pip install kc-sdk-python
13
+ ```
14
+
15
+ Dependencies: `requests`, `marshmallow-dataclass`, `py-ulid`.
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from kc_api import KcClient
21
+
22
+ client = KcClient("YOUR_API_TOKEN") # api_url defaults to https://cloud-api.kvindo.ru
23
+
24
+ # List (label-filtered, paginated)
25
+ resp = client.vms.get_by_labels({"env": "prod"}, max_page_size=50)
26
+ for vm in resp.resources:
27
+ print(vm["metadata"]["name"])
28
+
29
+ # Read one
30
+ vm = client.vms.read("01H...")
31
+ print(vm.resource)
32
+
33
+ # Create / update (async) then wait for it to reconcile
34
+ created = client.vms.create_or_update({
35
+ "metadata": {"name": "my-vm", "folderId": "01H..."},
36
+ "spec": {"offerId": "g3-1c2-100", "state": "running", ...},
37
+ })
38
+ status = client.vms.wait_request_satisfied(created.requestId, timeout_seconds=300)
39
+ assert status.succeeded
40
+
41
+ # Delete (optionally block until reconciled)
42
+ client.vms.delete("01H...", wait=True)
43
+ ```
44
+
45
+ Create / update / delete are **asynchronous**: they return a `requestId`; poll
46
+ `read_request(requestId)` or use `wait_request_satisfied(...)`. Every response
47
+ object carries `errorMessage` / `errorCode` (a typed `KcApi*ErrorCode`) which are
48
+ `None` on success.
49
+
50
+ ### Available resources
51
+
52
+ `KcClient` exposes one `KcResourceClient` per type, e.g. `client.vms`,
53
+ `client.volumes`, `client.s3_buckets`, `client.kubernetes`,
54
+ `client.load_balancers`, `client.vpcs`, `client.postgresql_standalones`,
55
+ `client.folders`, `client.transactions`, … (the surface mirrors the official
56
+ Kvindo Cloud API).
57
+
58
+ ## License
59
+
60
+ MIT
@@ -0,0 +1,774 @@
1
+ """Python client (SDK) for the Kvindo Cloud API.
2
+
3
+ `KcClient` exposes one `KcResourceClient` per resource type (vms, volumes,
4
+ load balancers, kubernetes, s3, …). Every resource speaks the same REST
5
+ contract under `/api/v1/<resource-type>`:
6
+
7
+ PUT /api/v1/<type> create or update (idempotent on metadata.id)
8
+ GET /api/v1/<type>/<id> read one
9
+ DELETE /api/v1/<type>/<id> delete one
10
+ GET /api/v1/<type>/get-by-labels list (label-filtered, paginated)
11
+ GET /api/v1/<type>/request/<reqId> poll an async change-request's status
12
+
13
+ Create/update/delete are **asynchronous**: they return a `requestId`
14
+ immediately; the actual provisioning is done by server-side reconcilers. Poll
15
+ `read_request(requestId)` (or use `wait_request_satisfied`) until it succeeds.
16
+
17
+ The resource surface mirrors the maintained C# client
18
+ `KvindoCloud.Api/KvindoCloudClient.cs`, which is the source of truth.
19
+
20
+ Dependencies: requests, marshmallow-dataclass, py-ulid.
21
+ """
22
+
23
+ import time
24
+ import logging
25
+ import requests
26
+ from requests.adapters import HTTPAdapter
27
+ from urllib3.util.retry import Retry
28
+ from urllib.parse import urlencode
29
+ from enum import Enum
30
+
31
+ # https://stackoverflow.com/questions/15476983/deserialize-a-json-string-to-an-object-in-python
32
+ from marshmallow_dataclass import dataclass
33
+ from typing import List, Optional
34
+ from dataclasses import field
35
+ from ulid import ULID
36
+
37
+ # Why a module logger here instead of importing one: this file is published as a
38
+ # standalone SDK, so it must not depend on the internal kc_common module (which
39
+ # pulls in python-json-logger and a pre-configured logger). Callers configure
40
+ # logging as they see fit; by default this logger is silent.
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ # Vendored from kc_common (create_http_client_with_retries / create_url_with_query_params)
45
+ # so the SDK carries no local-module dependency — its only third-party deps are
46
+ # requests, marshmallow-dataclass and py-ulid.
47
+ def create_http_client_with_retries(
48
+ retry_statuses=[500, 502, 503, 504, 520, 521],
49
+ verify_ssl: bool = False,
50
+ ) -> requests.Session:
51
+ """Build a `requests.Session` that retries idempotent failures with backoff.
52
+
53
+ Args:
54
+ retry_statuses: HTTP status codes that should be retried. 5xx are
55
+ server-side; 520 = web server returned an unknown error;
56
+ 521 = origin down (Cloudflare-specific).
57
+ verify_ssl: enable/disable TLS certificate verification. Defaults to
58
+ False because internal/dev endpoints use self-signed certs; pass
59
+ True against public prod.
60
+
61
+ Returns:
62
+ A configured `requests.Session` (reuse it for connection pooling).
63
+ """
64
+ session = requests.Session()
65
+ session.verify = verify_ssl
66
+ # total=5 attempts; backoff_factor=1 -> delays 0.5, 1, 2, 4, 8 ... seconds.
67
+ retries = Retry(total=5, backoff_factor=1, status_forcelist=retry_statuses)
68
+ adapter = HTTPAdapter(max_retries=retries)
69
+ session.mount("http://", adapter)
70
+ session.mount("https://", adapter)
71
+ return session
72
+
73
+
74
+ def create_url_with_query_params(base_url: str, query_params: dict) -> str:
75
+ """Append a query string to `base_url`, URL-encoding keys and values.
76
+
77
+ A dict-valued param is flattened to repeated `key[subkey]=value` pairs
78
+ (used for the `labels` filter). `None` values are dropped entirely.
79
+
80
+ Args:
81
+ base_url: URL without a query string.
82
+ query_params: flat dict; values may be scalars or a one-level dict.
83
+
84
+ Returns:
85
+ The full URL including the `?...` query string.
86
+ """
87
+ # Drop params whose value is None (e.g. an unset enumeratorId).
88
+ params = {k: v for k, v in query_params.items() if v is not None}
89
+
90
+ url = base_url + "?"
91
+
92
+ # TODO: Add recursive traverse
93
+ for p in params:
94
+ if isinstance(params[p], dict):
95
+ # Flatten one nesting level: labels={"env":"dev"} -> labels[env]=dev
96
+ for p2 in params[p]:
97
+ url = f"{url}{'' if url.endswith('?') else '&'}{urlencode({f'{p}[{p2}]': params[p][p2]})}"
98
+ else:
99
+ url = f"{url}{'' if url.endswith('?') else '&'}{urlencode({p: params[p]})}"
100
+
101
+ return url
102
+
103
+
104
+ # ── Error codes ───────────────────────────────────────────────────────────────
105
+ # Error-code enums mirror the C# enums in KvindoCloud.Api/Models/*ErrorCode.cs.
106
+ # The API serializes them as the PascalCase member NAME (each C# enum carries
107
+ # [JsonConverter(typeof(JsonStringEnumConverter))]), and marshmallow loads Enum
108
+ # fields by name, so the member names below must match the C# names exactly.
109
+ # Why each value is the name string (not the HTTP status code it used to be):
110
+ # duplicate values make Python collapse members into aliases of the first one
111
+ # with that value (e.g. every 422 became an alias of NotFound), so "MissingIdField"
112
+ # on the wire silently loaded as NotFound. Unique values keep every code distinct.
113
+ #
114
+ # Note: the """docstrings""" after each member/field are attribute docstrings —
115
+ # editors (Pylance/PyCharm) surface them on hover, unlike `#` comments.
116
+
117
+
118
+ class KcApiGenericErrorCode(Enum):
119
+ """Generic top-level errors not specific to one operation."""
120
+
121
+ Unauthorized = "Unauthorized"
122
+ """Token missing/invalid or lacks the required permission."""
123
+ BadData = "BadData"
124
+ """Request was malformed (bad JSON, wrong types, etc.)."""
125
+
126
+
127
+ class KcApiModificationErrorCode(Enum):
128
+ """Errors returned by create / update / delete (PUT and DELETE)."""
129
+
130
+ NotFound = "NotFound"
131
+ """No resource with the given id."""
132
+ Unauthorized = "Unauthorized"
133
+ """Token lacks permission for this resource/action."""
134
+ MissingIdField = "MissingIdField"
135
+ """Body had no id (and none could be derived)."""
136
+ ResourceIsScheduling = "ResourceIsScheduling"
137
+ """A previous change request is still in flight; retry later."""
138
+ MissingNameField = "MissingNameField"
139
+ """Body had no name."""
140
+ Unknown = "Unknown"
141
+ """Unhandled server-side error (treat as a 5xx)."""
142
+ ResourceIsDeleteProtected = "ResourceIsDeleteProtected"
143
+ """deleteProtection=true; clear it before deleting."""
144
+ BadData = "BadData"
145
+ """Request was malformed."""
146
+
147
+
148
+ @dataclass
149
+ class KcResourceDeleteResponse(object):
150
+ """Response of `KcResourceClient.delete`."""
151
+
152
+ requestId: str = None
153
+ """Id of the async change request to poll; None on error."""
154
+ resourceId: str = None
155
+ """Id of the resource being deleted; None on error."""
156
+ errorMessage: str = None
157
+ """Human-readable error; None on success."""
158
+ errorCode: KcApiModificationErrorCode = None
159
+ """Machine-readable error; None on success."""
160
+
161
+
162
+ @dataclass
163
+ class KcResourceCreateResponse(object):
164
+ """Response of `KcResourceClient.create` / `create_or_update`."""
165
+
166
+ requestId: str = None
167
+ """Id of the async change request to poll; None on error."""
168
+ resourceId: str = None
169
+ """Id of the created/updated resource; None on error."""
170
+ errorMessage: str = None
171
+ """Human-readable error; None on success."""
172
+ errorCode: KcApiModificationErrorCode = None
173
+ """Machine-readable error; None on success."""
174
+
175
+
176
+ @dataclass
177
+ class KcResourceUpdateResponse(object):
178
+ """Response of `KcResourceClient.update`. Shape-identical to the create response."""
179
+
180
+ requestId: str = None
181
+ """Id of the async change request to poll; None on error."""
182
+ resourceId: str = None
183
+ """Id of the updated resource; None on error."""
184
+ errorMessage: str = None
185
+ """Human-readable error; None on success."""
186
+ errorCode: KcApiModificationErrorCode = None
187
+ """Machine-readable error; None on success."""
188
+
189
+
190
+ class KcApiReadErrorCode(Enum):
191
+ """Errors returned by a single-resource read (`GET /api/v1/<type>/<id>`)."""
192
+
193
+ Unauthorized = "Unauthorized"
194
+ """Token lacks read permission."""
195
+ NotFound = "NotFound"
196
+ """No resource with the given id."""
197
+ ResourceIsScheduling = "ResourceIsScheduling"
198
+ """Resource exists but its first change request hasn't completed."""
199
+ Unknown = "Unknown"
200
+ """Unhandled server-side error."""
201
+ BadData = "BadData"
202
+ """Malformed request (e.g. invalid id)."""
203
+
204
+
205
+ @dataclass
206
+ class KcResourceReadResponse(object):
207
+ """Response of `KcResourceClient.read`."""
208
+
209
+ resource: dict = None
210
+ """The resource as a raw dict; None if errorMessage is set."""
211
+ errorMessage: str = None
212
+ """Human-readable error; None on success."""
213
+ errorCode: KcApiReadErrorCode = None
214
+ """Machine-readable error; set iff errorMessage is set."""
215
+
216
+
217
+ class KcApiReadRequestErrorCode(Enum):
218
+ """Errors returned when polling an async change-request's status."""
219
+
220
+ Unauthorized = "Unauthorized"
221
+ """Token lacks permission."""
222
+ NotFound = "NotFound"
223
+ """No change request with the given requestId."""
224
+ Unknown = "Unknown"
225
+ """Unhandled server-side error."""
226
+ BadData = "BadData"
227
+ """Malformed request."""
228
+ UnableToReconcile = "UnableToReconcile"
229
+ """The reconciler failed to apply the change (terminal failure)."""
230
+
231
+
232
+ @dataclass
233
+ class KcResourceReadRequestResponse(object):
234
+ """Status of an async create/update/delete request (`GET .../request/<id>`)."""
235
+
236
+ succeeded: bool
237
+ """True once the reconciler has finished applying the change."""
238
+ scheduledResourceId: str
239
+ """Id of the resource the request targets."""
240
+ errorMessage: str = None
241
+ """Set if the request failed; None while pending or on success."""
242
+ errorCode: KcApiReadRequestErrorCode = None
243
+ """Machine-readable failure code; None while pending or on success."""
244
+
245
+
246
+ class KcApiGetByLabelsErrorCode(Enum):
247
+ """Errors returned by label-filtered list (`GET .../get-by-labels`)."""
248
+
249
+ Unauthorized = "Unauthorized"
250
+ """Token lacks list permission."""
251
+ PageSizeTooBig = "PageSizeTooBig"
252
+ """maxPageSize exceeded the server limit (max 100)."""
253
+ EnumeratorNotFound = "EnumeratorNotFound"
254
+ """The pagination enumeratorId expired or is unknown."""
255
+ Unknown = "Unknown"
256
+ """Unhandled server-side error."""
257
+ BadData = "BadData"
258
+ """Malformed request."""
259
+
260
+
261
+ @dataclass
262
+ class KcResourceGetByLabelsPagination(object):
263
+ """Pagination cursor returned by get-by-labels; pass it back to fetch the next page."""
264
+
265
+ enumeratorId: str = None
266
+ """Opaque cursor; feed to the next get_by_labels call as enumerator_id."""
267
+
268
+
269
+ @dataclass
270
+ class KcResourceGetByLabelsResponse(object):
271
+ """Response of `KcResourceClient.get_by_labels` (one page of results)."""
272
+
273
+ # pagination and resources are null on error responses (errorMessage set), so
274
+ # they must be Optional or marshmallow rejects the payload before the caller
275
+ # can read errorMessage.
276
+ pagination: Optional[KcResourceGetByLabelsPagination] = None
277
+ """Cursor for the next page; None on error."""
278
+ resources: Optional[List[dict]] = field(default_factory=list)
279
+ """This page's resources as raw dicts; None on error."""
280
+ errorMessage: str = None
281
+ """Human-readable error; None on success."""
282
+ errorCode: KcApiGetByLabelsErrorCode = None
283
+ """Machine-readable error; None on success."""
284
+
285
+
286
+ # HTTP statuses the API uses to carry a structured (deserializable) body. Anything
287
+ # outside this set is an unexpected transport/server failure and is raised instead.
288
+ _HANDLED_STATUS_CODES = [200, 400, 401, 403, 422]
289
+
290
+
291
+ class KcResourceClient:
292
+ """Client for a single resource type of the Kvindo Cloud API.
293
+
294
+ One instance is bound to one resource type (e.g. "vm", "s3-bucket") and
295
+ reused for all calls against it. Obtain instances via `KcClient`, e.g.
296
+ `KcClient(token).vms`.
297
+
298
+ See https://cloud-api.kvindo.ru/swagger/index.html for the full contract.
299
+ """
300
+
301
+ def __init__(
302
+ self,
303
+ resource_type: str,
304
+ token: str,
305
+ api_url: str = "https://cloud-api.kvindo.ru",
306
+ log_extra: dict = None,
307
+ ):
308
+ """
309
+ Args:
310
+ resource_type: the kebab-case API path segment (e.g. "vm", "s3-bucket").
311
+ token: the bearer token; a leading "Bearer " prefix is stripped if present.
312
+ api_url: base URL of the Cloud API (no trailing slash).
313
+ log_extra: optional dict merged into every debug log record's `extra`.
314
+ """
315
+ self.__token = token.replace("Bearer ", "")
316
+ self.__resource_type = resource_type
317
+ self.__api_url = api_url
318
+ self.__log_extra = log_extra if log_extra is not None else {}
319
+
320
+ def __headers(self) -> dict:
321
+ """Standard auth + content-type headers for every request."""
322
+ return {
323
+ "accept": "*/*",
324
+ "Authorization": f"Bearer {self.__token}",
325
+ "Content-Type": "application/json-patch+json",
326
+ }
327
+
328
+ def delete(self, id: str, wait=False) -> KcResourceDeleteResponse:
329
+ """Delete a resource by id (asynchronous).
330
+
331
+ Args:
332
+ id: id of the resource to delete.
333
+ wait: if True, block (up to 300s) until the delete reconciles via
334
+ `wait_request_satisfied` before returning.
335
+
336
+ Returns:
337
+ KcResourceDeleteResponse with `requestId` to poll, or `errorMessage`/
338
+ `errorCode` set on a handled error.
339
+
340
+ Raises:
341
+ Exception: on an unexpected HTTP status (outside 200/400/401/403/422).
342
+ """
343
+ url = f"{self.__api_url}/api/v1/{self.__resource_type}/{id}"
344
+
345
+ response = create_http_client_with_retries().delete(url, headers=self.__headers())
346
+
347
+ logger.debug(
348
+ f"Got {response.status_code} status code while making request DELETE {url}\nResponse body: {response.text}",
349
+ extra=self.__log_extra,
350
+ )
351
+
352
+ if response.status_code in _HANDLED_STATUS_CODES:
353
+ result: KcResourceDeleteResponse = KcResourceDeleteResponse.Schema().load(
354
+ response.json()
355
+ )
356
+ if wait:
357
+ self.wait_request_satisfied(result.requestId, 300)
358
+ return result
359
+ else:
360
+ raise Exception(
361
+ f"Got {response.status_code} status code while making request DELETE {url}\nResponse body: {response.text}"
362
+ )
363
+
364
+ def read(self, id: str) -> KcResourceReadResponse:
365
+ """Read a single resource by id.
366
+
367
+ Args:
368
+ id: id of the resource to read.
369
+
370
+ Returns:
371
+ KcResourceReadResponse with `resource` (a raw dict) on success, or
372
+ `errorMessage`/`errorCode` set on a handled error.
373
+
374
+ Raises:
375
+ Exception: on an unexpected HTTP status.
376
+ """
377
+ url = f"{self.__api_url}/api/v1/{self.__resource_type}/{id}"
378
+
379
+ response = create_http_client_with_retries().get(url, headers=self.__headers())
380
+
381
+ logger.debug(
382
+ f"Got {response.status_code} status code while making request GET {url}\nResponse body: {response.text}",
383
+ extra=self.__log_extra,
384
+ )
385
+
386
+ if response.status_code in _HANDLED_STATUS_CODES:
387
+ return KcResourceReadResponse.Schema().load(response.json())
388
+ else:
389
+ raise Exception(
390
+ f"Got {response.status_code} status code while making request GET {url}\nResponse body: {response.text}"
391
+ )
392
+
393
+ def get_by_labels(
394
+ self, labels: dict = None, enumerator_id: str = None, max_page_size: int = 10
395
+ ) -> KcResourceGetByLabelsResponse:
396
+ """List resources of this type, filtered by labels and paginated.
397
+
398
+ Args:
399
+ labels: label filter as a dict; values may use `*` wildcards. Empty/None
400
+ matches all. Defaults to None (treated as no filter).
401
+ enumerator_id: pagination cursor from a previous call's
402
+ `pagination.enumeratorId`; None starts at the first page.
403
+ max_page_size: max resources per page. **Must not exceed 100** or the
404
+ API returns `PageSizeTooBig`.
405
+
406
+ Returns:
407
+ KcResourceGetByLabelsResponse: one page in `resources`, the next-page
408
+ cursor in `pagination`, or `errorMessage`/`errorCode` on a handled error.
409
+
410
+ Raises:
411
+ Exception: on an unexpected HTTP status.
412
+ """
413
+ url = f"{self.__api_url}/api/v1/{self.__resource_type}/get-by-labels"
414
+ params = {
415
+ "labels": labels if labels is not None else {},
416
+ "maxPageSize": max_page_size,
417
+ "enumeratorId": enumerator_id,
418
+ }
419
+
420
+ url = create_url_with_query_params(url, params)
421
+ response = create_http_client_with_retries().get(url, headers=self.__headers())
422
+
423
+ logger.debug(
424
+ f"Got {response.status_code} status code while making request GET {url}\nResponse body: {response.text}",
425
+ extra=self.__log_extra,
426
+ )
427
+
428
+ if response.status_code in _HANDLED_STATUS_CODES:
429
+ return KcResourceGetByLabelsResponse.Schema().load(response.json())
430
+ else:
431
+ raise Exception(
432
+ f"Got {response.status_code} status code while making request GET {url}\nResponse body: {response.text}"
433
+ )
434
+
435
+ def read_request(self, request_id: str) -> KcResourceReadRequestResponse:
436
+ """Read the current status of an async change request (one poll).
437
+
438
+ Args:
439
+ request_id: the `requestId` returned by create/update/delete.
440
+
441
+ Returns:
442
+ KcResourceReadRequestResponse: `succeeded` True once applied,
443
+ `errorMessage`/`errorCode` set if it failed, otherwise still pending.
444
+
445
+ Raises:
446
+ Exception: on an unexpected HTTP status.
447
+ """
448
+ url = f"{self.__api_url}/api/v1/{self.__resource_type}/request/{request_id}"
449
+
450
+ response = create_http_client_with_retries().get(url, headers=self.__headers())
451
+
452
+ logger.debug(
453
+ f"Got {response.status_code} status code while making request GET {url}\nResponse body: {response.text}",
454
+ extra=self.__log_extra,
455
+ )
456
+
457
+ if response.status_code in _HANDLED_STATUS_CODES:
458
+ return KcResourceReadRequestResponse.Schema().load(response.json())
459
+ else:
460
+ raise Exception(
461
+ f"Got {response.status_code} status code while making request GET {url}\nResponse body: {response.text}"
462
+ )
463
+
464
+ def wait_request_satisfied(
465
+ self, request_id: str, timeout_seconds: int
466
+ ) -> KcResourceReadRequestResponse:
467
+ """Poll a change request once per second until it finishes or times out.
468
+
469
+ Returns as soon as the request succeeds (`succeeded == True`) or fails
470
+ (both `errorMessage` and `errorCode` set). On timeout it returns the last
471
+ (still-pending) status rather than raising — inspect `succeeded` to tell
472
+ the cases apart.
473
+
474
+ Args:
475
+ request_id: the `requestId` to poll.
476
+ timeout_seconds: max number of 1-second polls before giving up.
477
+
478
+ Returns:
479
+ The final (or last-seen) KcResourceReadRequestResponse.
480
+ """
481
+ result = self.read_request(request_id)
482
+
483
+ i = 0
484
+ while result.succeeded == False and result.errorMessage == None:
485
+ i = i + 1
486
+ if i > timeout_seconds:
487
+ return result # timed out while still pending
488
+
489
+ time.sleep(1)
490
+ result = self.read_request(request_id)
491
+
492
+ if result.succeeded == True:
493
+ return result # applied successfully
494
+ if result.errorMessage != None and result.errorCode != None:
495
+ return result # failed terminally
496
+
497
+ return result
498
+
499
+ def create_or_update(self, data: dict) -> KcResourceCreateResponse:
500
+ """Create a resource, or update it if one with the same id already exists.
501
+
502
+ Idempotent on the resource id: if no id is present in `data` (neither
503
+ `data["metadata"]["id"]` for the envelope shape nor top-level `data["id"]`
504
+ for the flat shape), a fresh ULID is generated and used, so re-sending the
505
+ same `data` object updates the same resource.
506
+
507
+ Args:
508
+ data: the resource body, either the kubectl-style envelope
509
+ ({"metadata": {...}, "spec": {...}}) or the flat shape. **Mutated
510
+ in place** to inject the generated id when absent.
511
+
512
+ Returns:
513
+ KcResourceCreateResponse with `requestId`/`resourceId`, or
514
+ `errorMessage`/`errorCode` on a handled error.
515
+
516
+ Raises:
517
+ Exception: on an unexpected HTTP status.
518
+ """
519
+ logger.debug(f"create_or_update({data})")
520
+
521
+ # Inject a ULID id when absent so the call is idempotent and the caller can
522
+ # poll the returned requestId. Two body shapes are supported:
523
+ if "metadata" in data:
524
+ # Envelope shape: id lives under metadata.
525
+ if "id" not in data["metadata"] or not data["metadata"]["id"]:
526
+ data["metadata"]["id"] = str(ULID())
527
+ elif "id" not in data or not data["id"]:
528
+ # Flat shape: id at the top level.
529
+ data["id"] = str(ULID())
530
+
531
+ url = f"{self.__api_url}/api/v1/{self.__resource_type}"
532
+
533
+ response = create_http_client_with_retries().put(url, json=data, headers=self.__headers())
534
+
535
+ logger.debug(
536
+ f"Got {response.status_code} status code while making request PUT {url}\nRequest body: {data}\nResponse body: {response.text}",
537
+ extra=self.__log_extra,
538
+ )
539
+
540
+ if response.status_code in _HANDLED_STATUS_CODES:
541
+ return KcResourceCreateResponse.Schema().load(response.json())
542
+ else:
543
+ raise Exception(
544
+ f"Got {response.status_code} status code while making request PUT {url}\nRequest body: {data}\nResponse body: {response.text}"
545
+ )
546
+
547
+ def create(self, data: dict) -> KcResourceCreateResponse:
548
+ """Left for compatibility! Use create_or_update instead.
549
+
550
+ Args:
551
+ data: see `create_or_update`.
552
+ """
553
+ return self.create_or_update(data)
554
+
555
+ def update(self, data: dict) -> KcResourceUpdateResponse:
556
+ """Left for compatibility! Use create_or_update instead.
557
+
558
+ Args:
559
+ data: see `create_or_update`.
560
+ """
561
+ return self.create_or_update(data)
562
+
563
+
564
+ class KcClient:
565
+ """Top-level Kvindo Cloud API client.
566
+
567
+ Construct once with a token, then access a per-type `KcResourceClient` via the
568
+ attributes below (e.g. `KcClient(token).vms.get_by_labels(...)`).
569
+
570
+ See https://cloud-api.kvindo.ru/swagger/index.html. The resource surface
571
+ mirrors the maintained C# client KvindoCloud.Api/KvindoCloudClient.cs.
572
+ """
573
+
574
+ # Compute
575
+ vms: KcResourceClient
576
+ vm_on_off_maintenance_actions: KcResourceClient
577
+ vm_recurrent_command_maintenance_actions: KcResourceClient
578
+ volumes: KcResourceClient
579
+ volume_attachments: KcResourceClient
580
+ images: KcResourceClient
581
+ image_schedules: KcResourceClient
582
+ ssh_keys: KcResourceClient
583
+ ssh_private_keys: KcResourceClient
584
+ certificates: KcResourceClient
585
+
586
+ # Networking
587
+ floating_ips: KcResourceClient
588
+ vpcs: KcResourceClient
589
+ vpc_subnets: KcResourceClient
590
+ vpc_peerings: KcResourceClient
591
+ vpc_peering_peers: KcResourceClient
592
+ vpc_peering_external_peers: KcResourceClient
593
+ route_tables: KcResourceClient
594
+ route_table_attachments: KcResourceClient
595
+ route_table_routes: KcResourceClient
596
+ security_groups: KcResourceClient
597
+ nat_gateways: KcResourceClient
598
+
599
+ # Load balancer
600
+ load_balancers: KcResourceClient
601
+ load_balancer_http_listeners: KcResourceClient
602
+ load_balancer_http_listener_rules: KcResourceClient
603
+ load_balancer_https_listeners: KcResourceClient
604
+ load_balancer_https_listener_rules: KcResourceClient
605
+ load_balancer_tcp_listeners: KcResourceClient
606
+ load_balancer_tcp_listener_rules: KcResourceClient
607
+ load_balancer_udp_listeners: KcResourceClient
608
+ load_balancer_udp_listener_rules: KcResourceClient
609
+ load_balancer_tls_listeners: KcResourceClient
610
+ load_balancer_tls_listener_rules: KcResourceClient
611
+ load_balancer_target_groups: KcResourceClient
612
+ load_balancer_target_group_service_discovery_targets: KcResourceClient
613
+ load_balancer_target_group_static_targets: KcResourceClient
614
+
615
+ # S3
616
+ s3_buckets: KcResourceClient
617
+ s3_users: KcResourceClient
618
+ s3_user_access_policies: KcResourceClient
619
+
620
+ # Managed services
621
+ kubernetes: KcResourceClient
622
+ kubernetes_node_groups: KcResourceClient
623
+ kubernetes_users: KcResourceClient
624
+ kubernetes_user_roles: KcResourceClient
625
+ postgresqls: KcResourceClient
626
+ postgresql_standalones: KcResourceClient
627
+ postgresql_node_groups: KcResourceClient
628
+ postgresql_parameters_sets: KcResourceClient
629
+ etcd: KcResourceClient
630
+ etcd_node_group: KcResourceClient
631
+ open_vpns: KcResourceClient
632
+ open_vpn_users: KcResourceClient
633
+ open_vpn_user_settings: KcResourceClient
634
+ gitlabs: KcResourceClient
635
+ gitlab_runners: KcResourceClient
636
+ grafanas: KcResourceClient
637
+ victoria_metrics: KcResourceClient
638
+ ollamas: KcResourceClient
639
+
640
+ # IaM / org
641
+ folders: KcResourceClient
642
+ hosting_providers: KcResourceClient
643
+ access_policies: KcResourceClient
644
+ users: KcResourceClient
645
+ user_tokens: KcResourceClient
646
+ billing_accounts: KcResourceClient
647
+ quotas: KcResourceClient
648
+ quota_change_requests: KcResourceClient
649
+ support_plans: KcResourceClient
650
+ support_tickets: KcResourceClient
651
+ support_ticket_comments: KcResourceClient
652
+ support_ticket_comment_attachments: KcResourceClient
653
+ transactions: KcResourceClient
654
+
655
+ def __init__(
656
+ self,
657
+ token: str,
658
+ api_url: str = "https://cloud-api.kvindo.ru",
659
+ log_extra: dict = None,
660
+ ):
661
+ """
662
+ Args:
663
+ token: the bearer token; a leading "Bearer " prefix is stripped if present.
664
+ api_url: base URL of the Cloud API (no trailing slash).
665
+ log_extra: optional dict merged into every debug log record's `extra`;
666
+ propagated to every per-resource client.
667
+ """
668
+ self.__log_extra = log_extra if log_extra is not None else {}
669
+ self.__token = token.replace("Bearer ", "")
670
+ self.__api_url = api_url
671
+ # Cached response of get_transaction_collection_keys (lazy, fetched once).
672
+ self._transaction_collection_keys = None
673
+
674
+ def _r(resource_type: str) -> KcResourceClient:
675
+ """Build a per-type client sharing this client's token/url/log_extra."""
676
+ return KcResourceClient(resource_type, token, api_url, log_extra)
677
+
678
+ # Compute
679
+ self.vms = _r("vm")
680
+ self.vm_on_off_maintenance_actions = _r("vm-on-off-maintenance-action")
681
+ self.vm_recurrent_command_maintenance_actions = _r("vm-recurrent-command-maintenance-action")
682
+ self.volumes = _r("volume")
683
+ self.volume_attachments = _r("volume-attachment")
684
+ self.images = _r("image")
685
+ self.image_schedules = _r("image-schedule")
686
+ self.ssh_keys = _r("ssh-key")
687
+ self.ssh_private_keys = _r("ssh-private-key")
688
+ self.certificates = _r("certificate")
689
+
690
+ # Networking
691
+ self.floating_ips = _r("floating-ip")
692
+ self.vpcs = _r("vpc")
693
+ self.vpc_subnets = _r("vpc-subnet")
694
+ self.vpc_peerings = _r("vpc-peering")
695
+ self.vpc_peering_peers = _r("vpc-peering-peer")
696
+ self.vpc_peering_external_peers = _r("vpc-peering-external-peer")
697
+ self.route_tables = _r("route-table")
698
+ self.route_table_attachments = _r("route-table-attachment")
699
+ self.route_table_routes = _r("route-table-route")
700
+ self.security_groups = _r("security-group")
701
+ self.nat_gateways = _r("nat-gateway")
702
+
703
+ # Load balancer
704
+ self.load_balancers = _r("loadbalancer")
705
+ self.load_balancer_http_listeners = _r("loadbalancer-http-listener")
706
+ self.load_balancer_http_listener_rules = _r("loadbalancer-http-listener-rule")
707
+ self.load_balancer_https_listeners = _r("loadbalancer-https-listener")
708
+ self.load_balancer_https_listener_rules = _r("loadbalancer-https-listener-rule")
709
+ self.load_balancer_tcp_listeners = _r("loadbalancer-tcp-listener")
710
+ self.load_balancer_tcp_listener_rules = _r("loadbalancer-tcp-listener-rule")
711
+ self.load_balancer_udp_listeners = _r("loadbalancer-udp-listener")
712
+ self.load_balancer_udp_listener_rules = _r("loadbalancer-udp-listener-rule")
713
+ self.load_balancer_tls_listeners = _r("loadbalancer-tls-listener")
714
+ self.load_balancer_tls_listener_rules = _r("loadbalancer-tls-listener-rule")
715
+ self.load_balancer_target_groups = _r("loadbalancer-target-group")
716
+ self.load_balancer_target_group_service_discovery_targets = _r("loadbalancer-target-group-service-discovery-target")
717
+ self.load_balancer_target_group_static_targets = _r("loadbalancer-target-group-static-target")
718
+
719
+ # S3
720
+ self.s3_buckets = _r("s3-bucket")
721
+ self.s3_users = _r("s3-user")
722
+ self.s3_user_access_policies = _r("s3-user-access-policy")
723
+
724
+ # Managed services
725
+ self.kubernetes = _r("kubernetes")
726
+ self.kubernetes_node_groups = _r("kubernetes-node-group")
727
+ self.kubernetes_users = _r("kubernetes-user")
728
+ self.kubernetes_user_roles = _r("kubernetes-user-role")
729
+ self.postgresqls = _r("postgresql")
730
+ self.postgresql_standalones = _r("postgresql-standalone")
731
+ self.postgresql_node_groups = _r("postgresql-node-group")
732
+ self.postgresql_parameters_sets = _r("postgresql-parameters-set")
733
+ self.etcd = _r("etcd")
734
+ self.etcd_node_group = _r("etcd-node-group")
735
+ self.open_vpns = _r("open-vpn")
736
+ self.open_vpn_users = _r("open-vpn-user")
737
+ self.open_vpn_user_settings = _r("open-vpn-user-settings")
738
+ self.gitlabs = _r("gitlab")
739
+ self.gitlab_runners = _r("gitlab-runner")
740
+ self.grafanas = _r("grafana")
741
+ self.victoria_metrics = _r("victoria-metrics")
742
+ self.ollamas = _r("ollama")
743
+
744
+ # IaM / org
745
+ self.folders = _r("folder")
746
+ self.hosting_providers = _r("hosting-provider")
747
+ self.access_policies = _r("access-policy")
748
+ self.users = _r("user")
749
+ self.user_tokens = _r("user-token")
750
+ self.billing_accounts = _r("billing-account")
751
+ self.quotas = _r("quota")
752
+ self.quota_change_requests = _r("quota-change-request")
753
+ self.support_plans = _r("support-plan")
754
+ self.support_tickets = _r("support-ticket")
755
+ self.support_ticket_comments = _r("support-ticket-comment")
756
+ self.support_ticket_comment_attachments = _r("support-ticket-comment-attachment")
757
+ self.transactions = _r("transaction")
758
+
759
+ def get_transaction_collection_keys(self) -> list:
760
+ """Return the transaction-spec collection keys (the child-resource
761
+ collection names accepted inside an OrganizationTransaction).
762
+
763
+ Fetched once from `/api/v1/internal/transaction-spec` and cached on the
764
+ instance for subsequent calls.
765
+
766
+ Returns:
767
+ The raw list returned by the transaction-spec endpoint.
768
+ """
769
+ if self._transaction_collection_keys is None:
770
+ url = f"{self.__api_url}/api/v1/internal/transaction-spec"
771
+ headers = {"Authorization": f"Bearer {self.__token}"}
772
+ response = create_http_client_with_retries().get(url, headers=headers)
773
+ self._transaction_collection_keys = response.json()
774
+ return self._transaction_collection_keys
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: kc-sdk-python
3
+ Version: 0.1.0
4
+ Summary: Python SDK / client for the Kvindo Cloud API
5
+ Author: Kvindo
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Kvindo/kc-sdk-python
8
+ Project-URL: Repository, https://github.com/Kvindo/kc-sdk-python
9
+ Keywords: kvindo,cloud,sdk,client,api
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Intended Audience :: Developers
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: requests
17
+ Requires-Dist: marshmallow-dataclass
18
+ Requires-Dist: py-ulid
19
+ Dynamic: license-file
20
+
21
+ # kc-sdk-python
22
+
23
+ Python SDK / client for the **Kvindo Cloud API**.
24
+
25
+ A thin, typed client over the REST API: one resource client per resource type
26
+ (VMs, volumes, load balancers, kubernetes, S3, VPCs, …), all sharing the same
27
+ create / read / update / delete / list contract.
28
+
29
+ ## Install
30
+
31
+ ```sh
32
+ pip install kc-sdk-python
33
+ ```
34
+
35
+ Dependencies: `requests`, `marshmallow-dataclass`, `py-ulid`.
36
+
37
+ ## Usage
38
+
39
+ ```python
40
+ from kc_api import KcClient
41
+
42
+ client = KcClient("YOUR_API_TOKEN") # api_url defaults to https://cloud-api.kvindo.ru
43
+
44
+ # List (label-filtered, paginated)
45
+ resp = client.vms.get_by_labels({"env": "prod"}, max_page_size=50)
46
+ for vm in resp.resources:
47
+ print(vm["metadata"]["name"])
48
+
49
+ # Read one
50
+ vm = client.vms.read("01H...")
51
+ print(vm.resource)
52
+
53
+ # Create / update (async) then wait for it to reconcile
54
+ created = client.vms.create_or_update({
55
+ "metadata": {"name": "my-vm", "folderId": "01H..."},
56
+ "spec": {"offerId": "g3-1c2-100", "state": "running", ...},
57
+ })
58
+ status = client.vms.wait_request_satisfied(created.requestId, timeout_seconds=300)
59
+ assert status.succeeded
60
+
61
+ # Delete (optionally block until reconciled)
62
+ client.vms.delete("01H...", wait=True)
63
+ ```
64
+
65
+ Create / update / delete are **asynchronous**: they return a `requestId`; poll
66
+ `read_request(requestId)` or use `wait_request_satisfied(...)`. Every response
67
+ object carries `errorMessage` / `errorCode` (a typed `KcApi*ErrorCode`) which are
68
+ `None` on success.
69
+
70
+ ### Available resources
71
+
72
+ `KcClient` exposes one `KcResourceClient` per type, e.g. `client.vms`,
73
+ `client.volumes`, `client.s3_buckets`, `client.kubernetes`,
74
+ `client.load_balancers`, `client.vpcs`, `client.postgresql_standalones`,
75
+ `client.folders`, `client.transactions`, … (the surface mirrors the official
76
+ Kvindo Cloud API).
77
+
78
+ ## License
79
+
80
+ MIT
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ kc_api.py
4
+ pyproject.toml
5
+ kc_sdk_python.egg-info/PKG-INFO
6
+ kc_sdk_python.egg-info/SOURCES.txt
7
+ kc_sdk_python.egg-info/dependency_links.txt
8
+ kc_sdk_python.egg-info/requires.txt
9
+ kc_sdk_python.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ requests
2
+ marshmallow-dataclass
3
+ py-ulid
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kc-sdk-python"
7
+ version = "0.1.0"
8
+ description = "Python SDK / client for the Kvindo Cloud API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Kvindo" }]
14
+ keywords = ["kvindo", "cloud", "sdk", "client", "api"]
15
+ dependencies = [
16
+ "requests",
17
+ "marshmallow-dataclass",
18
+ "py-ulid",
19
+ ]
20
+ classifiers = [
21
+ "Programming Language :: Python :: 3",
22
+ "Operating System :: OS Independent",
23
+ "Intended Audience :: Developers",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/Kvindo/kc-sdk-python"
28
+ Repository = "https://github.com/Kvindo/kc-sdk-python"
29
+
30
+ [tool.setuptools]
31
+ py-modules = ["kc_api"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+