vertracloud-api 0.1.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.
@@ -0,0 +1,29 @@
1
+ """Typed Python client for the Vertra Cloud public API (`import vertracloud`)."""
2
+
3
+ from .client import VertraClient, __version__
4
+ from .errors import (
5
+ AuthenticationError,
6
+ NotFoundError,
7
+ RateLimitError,
8
+ ScopeDeniedError,
9
+ ValidationError,
10
+ VertraAPIError,
11
+ )
12
+ from .sse import SseEvent, SseStream
13
+ from .transport import Response, Transport, UrllibTransport
14
+
15
+ __all__ = [
16
+ "VertraClient",
17
+ "__version__",
18
+ "VertraAPIError",
19
+ "AuthenticationError",
20
+ "ScopeDeniedError",
21
+ "NotFoundError",
22
+ "ValidationError",
23
+ "RateLimitError",
24
+ "SseEvent",
25
+ "SseStream",
26
+ "Transport",
27
+ "UrllibTransport",
28
+ "Response",
29
+ ]
vertracloud/client.py ADDED
@@ -0,0 +1,228 @@
1
+ """Central HTTP client. Every `resources/*.py` calls into `VertraClient` — no
2
+ resource method builds a URL, sets a header, or handles errors on its own.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import mimetypes
9
+ import uuid
10
+ from collections.abc import Iterable
11
+ from typing import Any
12
+ from urllib.parse import quote, urlencode
13
+
14
+ from .errors import error_from_response
15
+ from .sse import SseStream
16
+ from .transport import Response, Transport, UrllibTransport
17
+ from .types import JsonValue
18
+
19
+ __version__ = "0.1.0" # x-release-please-version
20
+
21
+ DEFAULT_BASE_URL = "https://api.vertracloud.app"
22
+ DEFAULT_TIMEOUT = 30.0
23
+ # Download/upload/create for an app may involve a large zip file, so the timeout is longer.
24
+ LARGE_PAYLOAD_TIMEOUT = 120.0
25
+
26
+
27
+ def encode_path_param(value: str) -> str:
28
+ """`quote(safe="")` — a path param never lets `/`, `?`, etc. leak into the URL structure."""
29
+ return quote(str(value), safe="")
30
+
31
+
32
+ class MultipartField:
33
+ """One part of a multipart body: plain text, or a file (with filename/content-type)."""
34
+
35
+ __slots__ = ("name", "value", "filename", "content_type")
36
+
37
+ def __init__(
38
+ self,
39
+ name: str,
40
+ value: str | bytes,
41
+ *,
42
+ filename: str | None = None,
43
+ content_type: str | None = None,
44
+ ) -> None:
45
+ self.name = name
46
+ self.value = value
47
+ self.filename = filename
48
+ self.content_type = content_type
49
+
50
+
51
+ def _build_multipart(fields: Iterable[MultipartField]) -> tuple[bytes, str]:
52
+ boundary = f"----vertracloud-{uuid.uuid4().hex}"
53
+ parts: list[bytes] = []
54
+ for field in fields:
55
+ disposition = f'form-data; name="{field.name}"'
56
+ if field.filename:
57
+ disposition += f'; filename="{field.filename}"'
58
+ header = f"--{boundary}\r\nContent-Disposition: {disposition}\r\n"
59
+ content_type = field.content_type
60
+ if content_type is None and field.filename:
61
+ content_type = mimetypes.guess_type(field.filename)[0] or "application/octet-stream"
62
+ if content_type:
63
+ header += f"Content-Type: {content_type}\r\n"
64
+ header += "\r\n"
65
+ body = field.value if isinstance(field.value, bytes) else field.value.encode("utf-8")
66
+ parts.append(header.encode("utf-8") + body + b"\r\n")
67
+ parts.append(f"--{boundary}--\r\n".encode())
68
+ return b"".join(parts), boundary
69
+
70
+
71
+ class VertraClient:
72
+ """Client for the Vertra Cloud public API.
73
+
74
+ Args:
75
+ api_key: the user's API key (`Authorization: Bearer <key>`).
76
+ base_url: defaults to `https://api.vertracloud.app`.
77
+ timeout: seconds, defaults to 30. Can be overridden per call (download/upload/create).
78
+ user_agent: defaults to `vertracloud-sdk-py/<version>`.
79
+ transport: injectable `Transport` implementation (tests use a fake one).
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ *,
85
+ api_key: str,
86
+ base_url: str = DEFAULT_BASE_URL,
87
+ timeout: float = DEFAULT_TIMEOUT,
88
+ user_agent: str | None = None,
89
+ transport: Transport | None = None,
90
+ ) -> None:
91
+ if not api_key:
92
+ raise ValueError("api_key is required")
93
+ self._api_key = api_key
94
+ self.base_url = base_url.rstrip("/")
95
+ self.timeout = timeout
96
+ self.user_agent = user_agent or f"vertracloud-sdk-py/{__version__}"
97
+ self.transport: Transport = transport or UrllibTransport()
98
+
99
+ # Resources are imported here to avoid an import cycle at module top level.
100
+ from .resources.account import AccountResource
101
+ from .resources.apps import AppsResource
102
+ from .resources.billing import BillingResource
103
+ from .resources.databases import DatabasesResource
104
+ from .resources.snapshots import SnapshotsResource
105
+ from .resources.workspaces import WorkspacesResource
106
+
107
+ self.apps = AppsResource(self)
108
+ self.databases = DatabasesResource(self)
109
+ self.snapshots = SnapshotsResource(self)
110
+ self.account = AccountResource(self)
111
+ self.workspaces = WorkspacesResource(self)
112
+ self.billing = BillingResource(self)
113
+
114
+ def __repr__(self) -> str:
115
+ return f"VertraClient(base_url={self.base_url!r})"
116
+
117
+ # -- HTTP core -------------------------------------------------------
118
+
119
+ def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
120
+ headers = {
121
+ "Authorization": f"Bearer {self._api_key}",
122
+ "User-Agent": self.user_agent,
123
+ "Accept": "application/json",
124
+ }
125
+ if extra:
126
+ headers.update(extra)
127
+ return headers
128
+
129
+ def _url(self, path: str, query: dict[str, Any] | None = None) -> str:
130
+ url = f"{self.base_url}{path}"
131
+ if query:
132
+ clean = {
133
+ k: ("true" if v is True else "false" if v is False else v) for k, v in query.items() if v is not None
134
+ }
135
+ if clean:
136
+ url += f"?{urlencode(clean)}"
137
+ return url
138
+
139
+ def request_json(
140
+ self,
141
+ method: str,
142
+ path: str,
143
+ *,
144
+ query: dict[str, Any] | None = None,
145
+ json_body: Any = None,
146
+ timeout: float | None = None,
147
+ ) -> JsonValue:
148
+ """Standard JSON call: sends `json_body` (if given), unwraps `{response}`."""
149
+ headers = self._headers({"Content-Type": "application/json"} if json_body is not None else None)
150
+ body = json.dumps(json_body).encode("utf-8") if json_body is not None else None
151
+ resp = self.transport.request(method, self._url(path, query), headers, body, timeout or self.timeout)
152
+ return self._unwrap(resp)
153
+
154
+ def request_multipart(
155
+ self,
156
+ method: str,
157
+ path: str,
158
+ *,
159
+ fields: list[MultipartField],
160
+ query: dict[str, Any] | None = None,
161
+ timeout: float | None = None,
162
+ ) -> JsonValue:
163
+ body, boundary = _build_multipart(fields)
164
+ headers = self._headers({"Content-Type": f"multipart/form-data; boundary={boundary}"})
165
+ resp = self.transport.request(method, self._url(path, query), headers, body, timeout or self.timeout)
166
+ return self._unwrap(resp)
167
+
168
+ def request_binary(
169
+ self,
170
+ method: str,
171
+ path: str,
172
+ *,
173
+ query: dict[str, Any] | None = None,
174
+ timeout: float | None = None,
175
+ ) -> bytes:
176
+ """Binary downloads (app/snapshot zip, certificate). No JSON envelope."""
177
+ headers = self._headers({"Accept": "*/*"})
178
+ resp = self.transport.request(method, self._url(path, query), headers, None, timeout or self.timeout)
179
+ if resp.status >= 400:
180
+ self._raise_for_error(resp)
181
+ return resp.body
182
+
183
+ def request_sse(
184
+ self,
185
+ path: str,
186
+ *,
187
+ query: dict[str, Any] | None = None,
188
+ timeout: float | None = None,
189
+ ) -> SseStream:
190
+ """`GET /v1/apps/:id/realtime` — the only SSE route in the catalog."""
191
+ headers = self._headers({"Accept": "text/event-stream"})
192
+ resp = self.transport.request(
193
+ "GET", self._url(path, query), headers, None, timeout or self.timeout, stream=True
194
+ )
195
+ if resp.status >= 400:
196
+ self._raise_for_error(resp)
197
+ if resp.stream is None:
198
+ raise VertraClientError("Transport did not return a stream for an SSE request")
199
+ return SseStream(resp.stream)
200
+
201
+ # -- envelope --------------------------------------------------------
202
+
203
+ def _unwrap(self, resp: Response) -> JsonValue:
204
+ if resp.status >= 400:
205
+ self._raise_for_error(resp)
206
+ if not resp.body:
207
+ return {}
208
+ payload: Any = json.loads(resp.body)
209
+ if isinstance(payload, dict) and "response" in payload:
210
+ return payload["response"]
211
+ return payload
212
+
213
+ def _raise_for_error(self, resp: Response) -> None:
214
+ try:
215
+ payload = json.loads(resp.body) if resp.body else {}
216
+ except json.JSONDecodeError:
217
+ payload = {}
218
+ if not isinstance(payload, dict):
219
+ payload = {}
220
+ if "retry_after" not in payload:
221
+ retry_after_header = resp.headers.get("retry-after")
222
+ if retry_after_header is not None:
223
+ payload = {**payload, "retry_after": retry_after_header}
224
+ raise error_from_response(resp.status, payload)
225
+
226
+
227
+ class VertraClientError(Exception):
228
+ """Client usage error (not from the API), such as a misbehaving transport."""
vertracloud/errors.py ADDED
@@ -0,0 +1,93 @@
1
+ """Typed SDK exceptions, derived from the API's error envelope.
2
+
3
+ Error envelope: `{code, message?, details?, retry_after?}`. The class is picked from the
4
+ HTTP status; `code` is always accessible on the exception. Never print the API key
5
+ here — `__repr__`/`__str__` only show status/code/message.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ class VertraAPIError(Exception):
14
+ """Generic API error. Base class for every typed SDK exception."""
15
+
16
+ def __init__(
17
+ self,
18
+ message: str | None,
19
+ *,
20
+ status: int,
21
+ code: str,
22
+ details: Any = None,
23
+ ) -> None:
24
+ self.status = status
25
+ self.code = code
26
+ self.details = details
27
+ super().__init__(message or code)
28
+
29
+ def __str__(self) -> str:
30
+ return f"{self.code} ({self.status}): {super().__str__()}"
31
+
32
+ def __repr__(self) -> str:
33
+ return f"{self.__class__.__name__}(status={self.status!r}, code={self.code!r})"
34
+
35
+
36
+ class AuthenticationError(VertraAPIError):
37
+ """401 — invalid key or user not found (`API_KEY_INVALID`, `USER_NOT_FOUND`)."""
38
+
39
+
40
+ class ScopeDeniedError(VertraAPIError):
41
+ """403 — key missing the required scope or IP, a ban, or a WEBSITE_ONLY route."""
42
+
43
+
44
+ class NotFoundError(VertraAPIError):
45
+ """404 — resource does not exist (`APP_NOT_FOUND`, `DATABASE_NOT_FOUND`, ...)."""
46
+
47
+
48
+ class ValidationError(VertraAPIError):
49
+ """400/422 — invalid body, query, or path."""
50
+
51
+
52
+ class RateLimitError(VertraAPIError):
53
+ """429 — per-minute limit or daily quota exceeded."""
54
+
55
+ def __init__(
56
+ self,
57
+ message: str | None,
58
+ *,
59
+ status: int,
60
+ code: str,
61
+ details: Any = None,
62
+ retry_after: float | None = None,
63
+ ) -> None:
64
+ super().__init__(message, status=status, code=code, details=details)
65
+ self.retry_after = retry_after
66
+
67
+ def __repr__(self) -> str:
68
+ return (
69
+ f"{self.__class__.__name__}(status={self.status!r}, code={self.code!r}, retry_after={self.retry_after!r})"
70
+ )
71
+
72
+
73
+ def error_from_response(status: int, body: dict[str, Any]) -> VertraAPIError:
74
+ """Picks the class from the HTTP status; `code` comes from the error response body."""
75
+ code = str(body.get("code") or "UNKNOWN_ERROR")
76
+ message = body.get("message")
77
+ details = body.get("details")
78
+ if status == 401:
79
+ return AuthenticationError(message, status=status, code=code, details=details)
80
+ if status == 403:
81
+ return ScopeDeniedError(message, status=status, code=code, details=details)
82
+ if status == 404:
83
+ return NotFoundError(message, status=status, code=code, details=details)
84
+ if status in (400, 422):
85
+ return ValidationError(message, status=status, code=code, details=details)
86
+ if status == 429:
87
+ retry_after = body.get("retry_after")
88
+ try:
89
+ retry_after = float(retry_after) if retry_after is not None else None
90
+ except (TypeError, ValueError):
91
+ retry_after = None
92
+ return RateLimitError(message, status=status, code=code, details=details, retry_after=retry_after)
93
+ return VertraAPIError(message, status=status, code=code, details=details)
vertracloud/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,112 @@
1
+ """`account` domain — 10 routes (scopes `account:read/write`).
2
+
3
+ The user's activity log is website-only — there is no corresponding method here.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ from ..client import encode_path_param
11
+ from ..types import (
12
+ APIUserInfoResponse,
13
+ APIUserSession,
14
+ APIWorkspaceResourceFolder,
15
+ CreateFolderBody,
16
+ UpdateAccountBody,
17
+ UpdateFolderBody,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from ..client import VertraClient
22
+
23
+
24
+ class AccountSessionsResource:
25
+ def __init__(self, client: VertraClient) -> None:
26
+ self._c = client
27
+
28
+ def list(self) -> list[APIUserSession]:
29
+ """GET /v1/users/me/sessions — scope `account:read`."""
30
+ return self._c.request_json("GET", "/v1/users/me/sessions")
31
+
32
+
33
+ class AccountFoldersResource:
34
+ def __init__(self, client: VertraClient) -> None:
35
+ self._c = client
36
+
37
+ def create(self, body: CreateFolderBody) -> APIWorkspaceResourceFolder:
38
+ """POST /v1/users/me/resource-organization/folders — scope `account:write`."""
39
+ return self._c.request_json("POST", "/v1/users/me/resource-organization/folders", json_body=body)
40
+
41
+ def update(self, folder_id: str, body: UpdateFolderBody) -> APIWorkspaceResourceFolder:
42
+ """PATCH /v1/users/me/resource-organization/folders/:folder_id — scope `account:write`."""
43
+ return self._c.request_json(
44
+ "PATCH", f"/v1/users/me/resource-organization/folders/{encode_path_param(folder_id)}", json_body=body
45
+ )
46
+
47
+ def delete(self, folder_id: str) -> None:
48
+ """DELETE /v1/users/me/resource-organization/folders/:folder_id — scope `account:write`."""
49
+ return self._c.request_json(
50
+ "DELETE", f"/v1/users/me/resource-organization/folders/{encode_path_param(folder_id)}"
51
+ )
52
+
53
+ def add_resource(
54
+ self, folder_id: str, resource_type: str, resource_id: str, *, position: int | None = None
55
+ ) -> APIWorkspaceResourceFolder:
56
+ """PUT .../folders/:folder_id/resources/:resource_type/:resource_id — scope `account:write`."""
57
+ path = (
58
+ f"/v1/users/me/resource-organization/folders/{encode_path_param(folder_id)}"
59
+ f"/resources/{encode_path_param(resource_type)}/{encode_path_param(resource_id)}"
60
+ )
61
+ body = {"position": position} if position is not None else {}
62
+ return self._c.request_json("PUT", path, json_body=body)
63
+
64
+ def remove_resource(self, folder_id: str, resource_type: str, resource_id: str) -> None:
65
+ """DELETE .../folders/:folder_id/resources/:resource_type/:resource_id — scope `account:write`."""
66
+ path = (
67
+ f"/v1/users/me/resource-organization/folders/{encode_path_param(folder_id)}"
68
+ f"/resources/{encode_path_param(resource_type)}/{encode_path_param(resource_id)}"
69
+ )
70
+ return self._c.request_json("DELETE", path)
71
+
72
+
73
+ class AccountFavoritesResource:
74
+ def __init__(self, client: VertraClient) -> None:
75
+ self._c = client
76
+
77
+ def add(self, resource_type: str, resource_id: str, *, position: int | None = None) -> None:
78
+ """PUT /v1/users/me/resource-organization/favorites/:resource_type/:resource_id — `account:write`."""
79
+ path = (
80
+ f"/v1/users/me/resource-organization/favorites/{encode_path_param(resource_type)}"
81
+ f"/{encode_path_param(resource_id)}"
82
+ )
83
+ body = {"position": position} if position is not None else {}
84
+ return self._c.request_json("PUT", path, json_body=body)
85
+
86
+ def remove(self, resource_type: str, resource_id: str) -> None:
87
+ """DELETE .../favorites/:resource_type/:resource_id — scope `account:write`."""
88
+ path = (
89
+ f"/v1/users/me/resource-organization/favorites/{encode_path_param(resource_type)}"
90
+ f"/{encode_path_param(resource_id)}"
91
+ )
92
+ return self._c.request_json("DELETE", path)
93
+
94
+
95
+ class AccountResource:
96
+ """`account.*` — get/update, `sessions`, `folders`, `favorites`."""
97
+
98
+ def __init__(self, client: VertraClient) -> None:
99
+ self._c = client
100
+ self.sessions = AccountSessionsResource(client)
101
+ self.folders = AccountFoldersResource(client)
102
+ self.favorites = AccountFavoritesResource(client)
103
+
104
+ def get(self) -> APIUserInfoResponse:
105
+ """GET /v1/users/me — scope `account:read`. There is no `GET /v1/apps` or `/v1/databases`:
106
+ the user's list of apps/databases comes from here (`applications[]`, `databases[]`) or
107
+ from `apps.status_all()`/`databases.status_all()`."""
108
+ return self._c.request_json("GET", "/v1/users/me")
109
+
110
+ def update(self, body: UpdateAccountBody) -> APIUserInfoResponse:
111
+ """PATCH /v1/users/me — scope `account:write`."""
112
+ return self._c.request_json("PATCH", "/v1/users/me", json_body=body)