kaitencloud 1.0.0__py3-none-any.whl

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 (69) hide show
  1. kaitencloud/__init__.py +95 -0
  2. kaitencloud/_async/__init__.py +4 -0
  3. kaitencloud/_async/_http.py +327 -0
  4. kaitencloud/_async/client.py +310 -0
  5. kaitencloud/_async/resources/__init__.py +34 -0
  6. kaitencloud/_async/resources/_base.py +20 -0
  7. kaitencloud/_async/resources/components.py +76 -0
  8. kaitencloud/_async/resources/connectors.py +92 -0
  9. kaitencloud/_async/resources/customers.py +144 -0
  10. kaitencloud/_async/resources/deployment_zones.py +95 -0
  11. kaitencloud/_async/resources/entitlement_groups.py +89 -0
  12. kaitencloud/_async/resources/entitlements.py +202 -0
  13. kaitencloud/_async/resources/feature_flags.py +164 -0
  14. kaitencloud/_async/resources/instances.py +349 -0
  15. kaitencloud/_async/resources/integrations.py +143 -0
  16. kaitencloud/_async/resources/license_families.py +61 -0
  17. kaitencloud/_async/resources/licenses.py +247 -0
  18. kaitencloud/_async/resources/metadata_fields.py +115 -0
  19. kaitencloud/_async/resources/platform/__init__.py +14 -0
  20. kaitencloud/_async/resources/platform/connectors.py +53 -0
  21. kaitencloud/_async/resources/platform/organizations.py +68 -0
  22. kaitencloud/_async/resources/platform/tokens.py +92 -0
  23. kaitencloud/_async/resources/platform/users.py +26 -0
  24. kaitencloud/_async/resources/releases.py +59 -0
  25. kaitencloud/_async/resources/service_accounts.py +93 -0
  26. kaitencloud/_constants.py +32 -0
  27. kaitencloud/_exceptions.py +254 -0
  28. kaitencloud/_models.py +39 -0
  29. kaitencloud/_sync/__init__.py +6 -0
  30. kaitencloud/_sync/_http.py +329 -0
  31. kaitencloud/_sync/client.py +312 -0
  32. kaitencloud/_sync/resources/__init__.py +36 -0
  33. kaitencloud/_sync/resources/_base.py +22 -0
  34. kaitencloud/_sync/resources/components.py +76 -0
  35. kaitencloud/_sync/resources/connectors.py +92 -0
  36. kaitencloud/_sync/resources/customers.py +144 -0
  37. kaitencloud/_sync/resources/deployment_zones.py +93 -0
  38. kaitencloud/_sync/resources/entitlement_groups.py +91 -0
  39. kaitencloud/_sync/resources/entitlements.py +204 -0
  40. kaitencloud/_sync/resources/feature_flags.py +166 -0
  41. kaitencloud/_sync/resources/instances.py +349 -0
  42. kaitencloud/_sync/resources/integrations.py +145 -0
  43. kaitencloud/_sync/resources/license_families.py +63 -0
  44. kaitencloud/_sync/resources/licenses.py +245 -0
  45. kaitencloud/_sync/resources/metadata_fields.py +117 -0
  46. kaitencloud/_sync/resources/platform/__init__.py +16 -0
  47. kaitencloud/_sync/resources/platform/connectors.py +53 -0
  48. kaitencloud/_sync/resources/platform/organizations.py +70 -0
  49. kaitencloud/_sync/resources/platform/tokens.py +94 -0
  50. kaitencloud/_sync/resources/platform/users.py +28 -0
  51. kaitencloud/_sync/resources/releases.py +61 -0
  52. kaitencloud/_sync/resources/service_accounts.py +93 -0
  53. kaitencloud/_types.py +56 -0
  54. kaitencloud/_utils.py +230 -0
  55. kaitencloud/_version.py +4 -0
  56. kaitencloud/py.typed +0 -0
  57. kaitencloud/targeting.py +108 -0
  58. kaitencloud/types/__init__.py +197 -0
  59. kaitencloud/types/_core.py +1582 -0
  60. kaitencloud/types/_params.py +126 -0
  61. kaitencloud/types/_platform.py +62 -0
  62. kaitencloud/types/_webhooks.py +1043 -0
  63. kaitencloud/usage.py +94 -0
  64. kaitencloud/webhooks.py +189 -0
  65. kaitencloud-1.0.0.dist-info/METADATA +845 -0
  66. kaitencloud-1.0.0.dist-info/RECORD +69 -0
  67. kaitencloud-1.0.0.dist-info/WHEEL +4 -0
  68. kaitencloud-1.0.0.dist-info/licenses/LICENSE +202 -0
  69. kaitencloud-1.0.0.dist-info/licenses/NOTICE +3 -0
@@ -0,0 +1,95 @@
1
+ # Copyright 2026 KAITEN INC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """The official Python SDK for Kaiten.
5
+
6
+ ::
7
+
8
+ from kaitencloud import KaitenClient
9
+
10
+ client = KaitenClient(token="ksh_...")
11
+ for instance in client.instances.list():
12
+ print(instance.slug, instance.status)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from . import targeting, types, usage
21
+ from ._async.client import AsyncKaitenClient, AsyncKaitenPlatformClient
22
+ from ._constants import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT
23
+ from ._exceptions import (
24
+ APIConnectionError,
25
+ APIResponseValidationError,
26
+ APIStatusError,
27
+ APITimeoutError,
28
+ AuthenticationError,
29
+ BadRequestError,
30
+ ConflictError,
31
+ CredentialError,
32
+ InternalServerError,
33
+ KaitenError,
34
+ NotFoundError,
35
+ PaginationError,
36
+ PermissionDeniedError,
37
+ RateLimitError,
38
+ ServiceUnavailableError,
39
+ ThresholdExceededError,
40
+ UnprocessableEntityError,
41
+ WebhookPayloadError,
42
+ WebhookVerificationError,
43
+ )
44
+ from ._models import KaitenModel
45
+ from ._sync.client import KaitenClient, KaitenPlatformClient
46
+ from ._types import NOT_GIVEN, NotGiven
47
+ from ._version import __version__
48
+ from .usage import UNLIMITED
49
+
50
+ if TYPE_CHECKING:
51
+ from . import webhooks
52
+
53
+ __all__ = [
54
+ "DEFAULT_MAX_RETRIES",
55
+ "DEFAULT_TIMEOUT",
56
+ "NOT_GIVEN",
57
+ "UNLIMITED",
58
+ "APIConnectionError",
59
+ "APIResponseValidationError",
60
+ "APIStatusError",
61
+ "APITimeoutError",
62
+ "AsyncKaitenClient",
63
+ "AsyncKaitenPlatformClient",
64
+ "AuthenticationError",
65
+ "BadRequestError",
66
+ "ConflictError",
67
+ "CredentialError",
68
+ "InternalServerError",
69
+ "KaitenClient",
70
+ "KaitenError",
71
+ "KaitenModel",
72
+ "KaitenPlatformClient",
73
+ "NotFoundError",
74
+ "NotGiven",
75
+ "PaginationError",
76
+ "PermissionDeniedError",
77
+ "RateLimitError",
78
+ "ServiceUnavailableError",
79
+ "ThresholdExceededError",
80
+ "UnprocessableEntityError",
81
+ "WebhookPayloadError",
82
+ "WebhookVerificationError",
83
+ "__version__",
84
+ "targeting",
85
+ "types",
86
+ "usage",
87
+ "webhooks",
88
+ ]
89
+
90
+
91
+ def __getattr__(name: str) -> Any:
92
+ # The webhook event models are only loaded by the code that receives webhooks.
93
+ if name == "webhooks":
94
+ return importlib.import_module(f"{__name__}.webhooks")
95
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,4 @@
1
+ # Copyright 2026 KAITEN INC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """The Kaiten clients and their resources."""
@@ -0,0 +1,327 @@
1
+ # Copyright 2026 KAITEN INC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """The transport both clients share: authentication, retries, errors and pagination."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from collections.abc import AsyncIterator, Mapping
10
+ from typing import Any, TypeVar
11
+
12
+ import httpx
13
+ from pydantic import BaseModel, ValidationError
14
+
15
+ from .._constants import DEFAULT_TIMEOUT, MAX_PAGE_SIZE
16
+ from .._exceptions import (
17
+ APIConnectionError,
18
+ APIResponseValidationError,
19
+ APIStatusError,
20
+ APITimeoutError,
21
+ PaginationError,
22
+ make_status_error,
23
+ )
24
+ from .._types import NOT_GIVEN, AsyncCredential, NotGiven, TimeoutTypes
25
+ from .._utils import USER_AGENT, Surface, async_resolve_token, async_sleep, check_token, retry_delay
26
+
27
+ __all__ = ["AsyncAPIClient"]
28
+
29
+ ModelT = TypeVar("ModelT", bound=BaseModel)
30
+
31
+ log = logging.getLogger("kaitencloud")
32
+
33
+ # Methods a retry cannot turn into a second effect. POST and PATCH are not among them: a usage
34
+ # report retried after a lost response is usage counted twice, and usage is what is billed.
35
+ IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
36
+
37
+ # Failures that happen before a request is sent, and so are safe to retry whatever its method.
38
+ NEVER_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
39
+
40
+
41
+ class AsyncAPIClient:
42
+ """Sends a client's requests: authenticates, retries what is safe, maps errors, walks pages."""
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ base_url: str,
48
+ credential: AsyncCredential,
49
+ surface: Surface,
50
+ max_retries: int,
51
+ timeout: TimeoutTypes | NotGiven = NOT_GIVEN,
52
+ default_headers: Mapping[str, str] | None = None,
53
+ http_client: httpx.AsyncClient | None = None,
54
+ ) -> None:
55
+ if max_retries < 0:
56
+ raise ValueError(f"max_retries must be zero or more, got {max_retries}")
57
+ self.base_url = base_url
58
+ self.credential = credential
59
+ self.surface = surface
60
+ self.max_retries = max_retries
61
+ self.timeout = timeout
62
+ self.default_headers = dict(default_headers or {})
63
+ self.owns_http_client = http_client is None
64
+ self.http_client = http_client or httpx.AsyncClient(
65
+ timeout=DEFAULT_TIMEOUT if isinstance(timeout, NotGiven) else timeout
66
+ )
67
+
68
+ def copy(
69
+ self,
70
+ *,
71
+ timeout: TimeoutTypes | NotGiven = NOT_GIVEN,
72
+ max_retries: int | NotGiven = NOT_GIVEN,
73
+ default_headers: Mapping[str, str] | NotGiven = NOT_GIVEN,
74
+ ) -> AsyncAPIClient:
75
+ """A transport with some options changed, sharing this one's connection pool."""
76
+ return AsyncAPIClient(
77
+ base_url=self.base_url,
78
+ credential=self.credential,
79
+ surface=self.surface,
80
+ max_retries=self.max_retries if isinstance(max_retries, NotGiven) else max_retries,
81
+ timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
82
+ default_headers=(
83
+ self.default_headers if isinstance(default_headers, NotGiven) else default_headers
84
+ ),
85
+ http_client=self.http_client,
86
+ )
87
+
88
+ async def close(self) -> None:
89
+ if self.owns_http_client:
90
+ await self.http_client.aclose()
91
+
92
+ async def request(
93
+ self,
94
+ method: str,
95
+ path: str,
96
+ *,
97
+ params: Mapping[str, Any] | None = None,
98
+ body: Any = None,
99
+ idempotent: bool | None = None,
100
+ errors: Mapping[int, type[APIStatusError]] | None = None,
101
+ ) -> httpx.Response:
102
+ """Send one operation and return its successful response.
103
+
104
+ A request is retried, up to ``max_retries`` times, when it failed in a way worth
105
+ retrying (a connection error, a 408, 429 or 5xx) *and* repeating it is safe: its method
106
+ is idempotent, ``idempotent=True`` says so, or it never left the machine. A 401 or 403 is
107
+ never retried -- the SDK cannot mint a better credential, and hammering a rejected one
108
+ only burns the rate budget.
109
+ """
110
+ url = self.base_url + path.lstrip("/")
111
+ retryable = method in IDEMPOTENT_METHODS if idempotent is None else idempotent
112
+ attempt = 0
113
+
114
+ while True:
115
+ request = self._build(
116
+ method, url, params=_query(params), body=body, headers=await self._headers()
117
+ )
118
+ try:
119
+ response = await self.http_client.send(request)
120
+ except httpx.TransportError as error:
121
+ if attempt < self.max_retries and (retryable or isinstance(error, NEVER_SENT)):
122
+ await self._back_off(request, attempt, repr(error))
123
+ attempt += 1
124
+ continue
125
+ if isinstance(error, httpx.TimeoutException):
126
+ raise APITimeoutError(request=request) from error
127
+ raise APIConnectionError(request=request) from error
128
+
129
+ if response.is_success:
130
+ return response
131
+
132
+ status = response.status_code
133
+ if retryable and attempt < self.max_retries and (status in (408, 429) or status >= 500):
134
+ await self._back_off(
135
+ request, attempt, f"HTTP {status}", response.headers.get("retry-after")
136
+ )
137
+ attempt += 1
138
+ continue
139
+ raise make_status_error(response, overrides=errors)
140
+
141
+ async def get(
142
+ self, path: str, model: type[ModelT], *, params: Mapping[str, Any] | None = None
143
+ ) -> ModelT:
144
+ return await self.send("GET", path, model, params=params)
145
+
146
+ async def get_list(
147
+ self, path: str, model: type[ModelT], *, params: Mapping[str, Any] | None = None
148
+ ) -> list[ModelT]:
149
+ """GET an unpaginated array, which the contracts type as nullable: null is empty."""
150
+ response = await self.request("GET", path, params=params)
151
+ data = self.decode(response)
152
+ if data is None:
153
+ return []
154
+ if not isinstance(data, list):
155
+ raise APIResponseValidationError(
156
+ f"GET {path}: expected a JSON array, got {type(data).__name__}", response=response
157
+ )
158
+ return [self.validate(model, item, response) for item in data]
159
+
160
+ async def send(
161
+ self,
162
+ method: str,
163
+ path: str,
164
+ model: type[ModelT],
165
+ *,
166
+ params: Mapping[str, Any] | None = None,
167
+ body: Any = None,
168
+ idempotent: bool | None = None,
169
+ errors: Mapping[int, type[APIStatusError]] | None = None,
170
+ ) -> ModelT:
171
+ response = await self.request(
172
+ method, path, params=params, body=body, idempotent=idempotent, errors=errors
173
+ )
174
+ return self.validate(model, self.decode(response), response)
175
+
176
+ async def send_empty(
177
+ self,
178
+ method: str,
179
+ path: str,
180
+ *,
181
+ params: Mapping[str, Any] | None = None,
182
+ body: Any = None,
183
+ idempotent: bool | None = None,
184
+ ) -> None:
185
+ await self.request(method, path, params=params, body=body, idempotent=idempotent)
186
+
187
+ async def paginate(
188
+ self,
189
+ path: str,
190
+ model: type[ModelT],
191
+ *,
192
+ params: Mapping[str, Any] | None = None,
193
+ limit: int | None = None,
194
+ ) -> AsyncIterator[ModelT]:
195
+ """Yield every row of a cursor-paginated collection, page after page.
196
+
197
+ ``limit`` caps how many rows are yielded, and no page asks for more rows than are still
198
+ wanted. Without it, the walk ends only when the API says there is nothing more.
199
+ """
200
+ filters = dict(params or {})
201
+ cursor: str | None = None
202
+ yielded = 0
203
+ items: list[Any]
204
+ has_more: object
205
+ next_cursor: object
206
+ while True:
207
+ size = MAX_PAGE_SIZE if limit is None else min(MAX_PAGE_SIZE, limit - yielded)
208
+ response = await self.request(
209
+ "GET", path, params={**filters, "limit": size, "cursor": cursor}
210
+ )
211
+ page = self.decode(response)
212
+
213
+ if isinstance(page, list):
214
+ # A deployment older than cursor pagination answers the whole collection.
215
+ items, has_more, next_cursor = page, False, None
216
+ elif isinstance(page, dict) and isinstance(page.get("items"), list):
217
+ items, has_more, next_cursor = (
218
+ page["items"],
219
+ page.get("hasMore"),
220
+ page.get("nextCursor"),
221
+ )
222
+ else:
223
+ raise APIResponseValidationError(
224
+ f"GET {path}: expected a page of items", response=response
225
+ )
226
+
227
+ for item in items:
228
+ yield self.validate(model, item, response)
229
+ yielded += 1
230
+ if limit is not None and yielded >= limit:
231
+ return
232
+ if not has_more:
233
+ return
234
+ if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor:
235
+ raise PaginationError(
236
+ f"GET {path}: the API reported more rows but returned no usable next cursor"
237
+ )
238
+ cursor = next_cursor
239
+
240
+ async def list_all(
241
+ self,
242
+ path: str,
243
+ model: type[ModelT],
244
+ *,
245
+ params: Mapping[str, Any] | None = None,
246
+ limit: int | None = None,
247
+ ) -> list[ModelT]:
248
+ rows: list[ModelT] = []
249
+ async for row in self.paginate(path, model, params=params, limit=limit):
250
+ rows.append(row)
251
+ return rows
252
+
253
+ @staticmethod
254
+ def decode(response: httpx.Response) -> Any:
255
+ if not response.content:
256
+ return None
257
+ try:
258
+ return response.json()
259
+ except ValueError as error:
260
+ raise APIResponseValidationError(
261
+ f"{response.request.method} {response.request.url.path}: the response is not JSON",
262
+ response=response,
263
+ ) from error
264
+
265
+ @staticmethod
266
+ def validate(model: type[ModelT], data: Any, response: httpx.Response) -> ModelT:
267
+ try:
268
+ return model.model_validate(data)
269
+ except ValidationError as error:
270
+ raise APIResponseValidationError(
271
+ f"{response.request.method} {response.request.url.path}: the response does not "
272
+ f"match {model.__name__}: {error}",
273
+ response=response,
274
+ ) from error
275
+
276
+ def _build(
277
+ self,
278
+ method: str,
279
+ url: str,
280
+ *,
281
+ params: dict[str, Any] | None,
282
+ body: Any,
283
+ headers: dict[str, str],
284
+ ) -> httpx.Request:
285
+ if isinstance(self.timeout, NotGiven):
286
+ # The HTTP client's own timeout applies: the default one, or the caller's.
287
+ return self.http_client.build_request(
288
+ method, url, params=params, json=body, headers=headers
289
+ )
290
+ return self.http_client.build_request(
291
+ method, url, params=params, json=body, headers=headers, timeout=self.timeout
292
+ )
293
+
294
+ async def _headers(self) -> dict[str, str]:
295
+ token = check_token(await async_resolve_token(self.credential), self.surface)
296
+ return {
297
+ "Accept": "application/json",
298
+ "User-Agent": USER_AGENT,
299
+ **self.default_headers,
300
+ "Authorization": f"Bearer {token}",
301
+ }
302
+
303
+ async def _back_off(
304
+ self, request: httpx.Request, attempt: int, reason: str, retry_after: str | None = None
305
+ ) -> None:
306
+ delay = retry_delay(attempt, retry_after)
307
+ log.info(
308
+ "Retrying %s %s in %.2fs (retry %d of %d) after %s",
309
+ request.method,
310
+ request.url.path,
311
+ delay,
312
+ attempt + 1,
313
+ self.max_retries,
314
+ reason,
315
+ )
316
+ await async_sleep(delay)
317
+
318
+
319
+ def _query(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
320
+ if not params:
321
+ return None
322
+ query: dict[str, Any] = {}
323
+ for key, value in params.items():
324
+ if value is None:
325
+ continue
326
+ query[key] = ("true" if value else "false") if isinstance(value, bool) else value
327
+ return query