runta-sdk 0.0.4__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.
- asyncrunta/__init__.py +45 -0
- asyncrunta/_config.py +69 -0
- asyncrunta/_http.py +345 -0
- asyncrunta/client.py +524 -0
- asyncrunta/errors.py +45 -0
- asyncrunta/models.py +271 -0
- asyncrunta/py.typed +1 -0
- runta/__init__.py +49 -0
- runta/_http.py +345 -0
- runta/client.py +541 -0
- runta/py.typed +1 -0
- runta_sdk-0.0.4.dist-info/METADATA +136 -0
- runta_sdk-0.0.4.dist-info/RECORD +15 -0
- runta_sdk-0.0.4.dist-info/WHEEL +5 -0
- runta_sdk-0.0.4.dist-info/top_level.txt +2 -0
asyncrunta/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Async Runta Python SDK."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncRunta, Runtime, Session
|
|
4
|
+
from .errors import ApiError, CommandError, ConfigError, RuntaError
|
|
5
|
+
from .models import (
|
|
6
|
+
CommandResult,
|
|
7
|
+
CpuArch,
|
|
8
|
+
EgressAuditEvent,
|
|
9
|
+
EgressPolicy,
|
|
10
|
+
Injection,
|
|
11
|
+
IngressSpec,
|
|
12
|
+
RuntimeInfo,
|
|
13
|
+
RuntimeStatus,
|
|
14
|
+
SecretInfo,
|
|
15
|
+
SecretInjectionRuleInfo,
|
|
16
|
+
SecretRuleInfo,
|
|
17
|
+
SnapshotInfo,
|
|
18
|
+
SnapshotState,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
Runta = AsyncRunta
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ApiError",
|
|
25
|
+
"AsyncRunta",
|
|
26
|
+
"CommandError",
|
|
27
|
+
"CommandResult",
|
|
28
|
+
"ConfigError",
|
|
29
|
+
"CpuArch",
|
|
30
|
+
"EgressAuditEvent",
|
|
31
|
+
"EgressPolicy",
|
|
32
|
+
"Injection",
|
|
33
|
+
"IngressSpec",
|
|
34
|
+
"Runta",
|
|
35
|
+
"RuntaError",
|
|
36
|
+
"Runtime",
|
|
37
|
+
"RuntimeInfo",
|
|
38
|
+
"RuntimeStatus",
|
|
39
|
+
"SecretInfo",
|
|
40
|
+
"SecretInjectionRuleInfo",
|
|
41
|
+
"SecretRuleInfo",
|
|
42
|
+
"Session",
|
|
43
|
+
"SnapshotInfo",
|
|
44
|
+
"SnapshotState",
|
|
45
|
+
]
|
asyncrunta/_config.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Configuration resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .errors import ConfigError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve_config(
|
|
13
|
+
*,
|
|
14
|
+
endpoint: str | None,
|
|
15
|
+
token: str | None,
|
|
16
|
+
config_path: str | Path | None,
|
|
17
|
+
) -> tuple[str, str]:
|
|
18
|
+
file_config = _read_config(config_path)
|
|
19
|
+
resolved_endpoint = _clean(endpoint) or _clean(os.getenv("RUNTA_ENDPOINT")) or file_config[0]
|
|
20
|
+
resolved_token = (
|
|
21
|
+
_clean(token)
|
|
22
|
+
or _clean(os.getenv("RUNTA_TOKEN"))
|
|
23
|
+
or _read_token_file(os.getenv("RUNTA_TOKEN_FILE"))
|
|
24
|
+
or file_config[1]
|
|
25
|
+
)
|
|
26
|
+
if not resolved_endpoint:
|
|
27
|
+
raise ConfigError(
|
|
28
|
+
"missing endpoint: pass endpoint=..., set RUNTA_ENDPOINT, or set endpoint in config"
|
|
29
|
+
)
|
|
30
|
+
if not resolved_token:
|
|
31
|
+
raise ConfigError("missing token: pass token=... or set RUNTA_TOKEN")
|
|
32
|
+
return resolved_endpoint, resolved_token
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_config(config_path: str | Path | None) -> tuple[str | None, str | None]:
|
|
36
|
+
path = (
|
|
37
|
+
Path(config_path).expanduser()
|
|
38
|
+
if config_path is not None
|
|
39
|
+
else Path(os.getenv("RUNTA_CONFIG", "~/.config/runta/config.toml")).expanduser()
|
|
40
|
+
)
|
|
41
|
+
if not path.exists():
|
|
42
|
+
return None, None
|
|
43
|
+
try:
|
|
44
|
+
data = tomllib.loads(path.read_text())
|
|
45
|
+
except OSError as exc:
|
|
46
|
+
raise ConfigError(f"failed to read config file {path}: {exc}") from exc
|
|
47
|
+
except tomllib.TOMLDecodeError as exc:
|
|
48
|
+
raise ConfigError(f"failed to parse config file {path}: {exc}") from exc
|
|
49
|
+
return _clean(data.get("endpoint")), _clean(data.get("token"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_token_file(token_file: str | None) -> str | None:
|
|
53
|
+
token_file = _clean(token_file)
|
|
54
|
+
if token_file is None:
|
|
55
|
+
return None
|
|
56
|
+
path = Path(token_file).expanduser()
|
|
57
|
+
if not path.exists():
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
return _clean(path.read_text())
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
raise ConfigError(f"failed to read token file {path}: {exc}") from exc
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _clean(value: object) -> str | None:
|
|
66
|
+
if not isinstance(value, str):
|
|
67
|
+
return None
|
|
68
|
+
value = value.strip()
|
|
69
|
+
return value or None
|
asyncrunta/_http.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Low-level async REST client for runta-api."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .errors import ApiError
|
|
13
|
+
from .models import (
|
|
14
|
+
CommandResult,
|
|
15
|
+
EgressAuditEvent,
|
|
16
|
+
EgressPolicy,
|
|
17
|
+
RuntimeInfo,
|
|
18
|
+
SecretInfo,
|
|
19
|
+
SecretInjectionRuleInfo,
|
|
20
|
+
SnapshotInfo,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HttpClient:
|
|
25
|
+
"""Small typed wrapper around runta-api's HTTP/JSON surface."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, endpoint: str, token: str, timeout: float = 30.0) -> None:
|
|
28
|
+
self.endpoint = endpoint.rstrip("/")
|
|
29
|
+
self.token = token
|
|
30
|
+
self.timeout = timeout
|
|
31
|
+
self._runtime_base_path = "/v1/runtimes"
|
|
32
|
+
self._client = httpx.AsyncClient(
|
|
33
|
+
base_url=self.endpoint,
|
|
34
|
+
timeout=timeout,
|
|
35
|
+
trust_env=False,
|
|
36
|
+
headers={
|
|
37
|
+
"authorization": f"Bearer {token}",
|
|
38
|
+
"user-agent": "runta-python-sdk/0.1.0",
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
async def close(self) -> None:
|
|
43
|
+
await self._client.aclose()
|
|
44
|
+
|
|
45
|
+
async def request(
|
|
46
|
+
self,
|
|
47
|
+
method: str,
|
|
48
|
+
path: str,
|
|
49
|
+
*,
|
|
50
|
+
json: Mapping[str, Any] | None = None,
|
|
51
|
+
params: Mapping[str, Any] | None = None,
|
|
52
|
+
) -> Any:
|
|
53
|
+
headers = {"x-request-id": str(uuid.uuid4())}
|
|
54
|
+
try:
|
|
55
|
+
response = await self._client.request(
|
|
56
|
+
method,
|
|
57
|
+
path,
|
|
58
|
+
json=json,
|
|
59
|
+
params=params,
|
|
60
|
+
headers=headers,
|
|
61
|
+
)
|
|
62
|
+
except httpx.HTTPError as exc:
|
|
63
|
+
raise ApiError(f"HTTP request failed: {exc}") from exc
|
|
64
|
+
if response.status_code >= 400:
|
|
65
|
+
raise _api_error(response)
|
|
66
|
+
if not response.content:
|
|
67
|
+
return None
|
|
68
|
+
payload = response.json()
|
|
69
|
+
return payload.get("data", payload)
|
|
70
|
+
|
|
71
|
+
async def runtime_request(
|
|
72
|
+
self,
|
|
73
|
+
method: str,
|
|
74
|
+
suffix: str = "",
|
|
75
|
+
*,
|
|
76
|
+
json: Mapping[str, Any] | None = None,
|
|
77
|
+
params: Mapping[str, Any] | None = None,
|
|
78
|
+
) -> Any:
|
|
79
|
+
runtime_base_path = getattr(self, "_runtime_base_path", "/v1/runtimes")
|
|
80
|
+
path = f"{runtime_base_path}{suffix}"
|
|
81
|
+
try:
|
|
82
|
+
return await self.request(method, path, json=json, params=params)
|
|
83
|
+
except ApiError as exc:
|
|
84
|
+
if exc.status_code != 404 or runtime_base_path != "/v1/runtimes":
|
|
85
|
+
raise
|
|
86
|
+
legacy_path = f"/v1/sandboxes{suffix}"
|
|
87
|
+
data = await self.request(method, legacy_path, json=json, params=params)
|
|
88
|
+
self._runtime_base_path = "/v1/sandboxes"
|
|
89
|
+
return data
|
|
90
|
+
|
|
91
|
+
async def runtime_file_request(
|
|
92
|
+
self,
|
|
93
|
+
method: str,
|
|
94
|
+
runtime_id: str,
|
|
95
|
+
path: str,
|
|
96
|
+
*,
|
|
97
|
+
content: bytes | None = None,
|
|
98
|
+
) -> httpx.Response:
|
|
99
|
+
headers = {"x-request-id": str(uuid.uuid4())}
|
|
100
|
+
if content is not None:
|
|
101
|
+
headers["content-type"] = "application/octet-stream"
|
|
102
|
+
runtime_base_path = getattr(self, "_runtime_base_path", "/v1/runtimes")
|
|
103
|
+
route = f"{runtime_base_path}/{runtime_id}/files"
|
|
104
|
+
try:
|
|
105
|
+
response = await self._client.request(
|
|
106
|
+
method,
|
|
107
|
+
route,
|
|
108
|
+
params={"path": path},
|
|
109
|
+
content=content,
|
|
110
|
+
headers=headers,
|
|
111
|
+
)
|
|
112
|
+
except httpx.HTTPError as exc:
|
|
113
|
+
raise ApiError(f"HTTP request failed: {exc}") from exc
|
|
114
|
+
if response.status_code == 404 and runtime_base_path == "/v1/runtimes":
|
|
115
|
+
legacy_route = f"/v1/sandboxes/{runtime_id}/files"
|
|
116
|
+
try:
|
|
117
|
+
response = await self._client.request(
|
|
118
|
+
method,
|
|
119
|
+
legacy_route,
|
|
120
|
+
params={"path": path},
|
|
121
|
+
content=content,
|
|
122
|
+
headers=headers,
|
|
123
|
+
)
|
|
124
|
+
except httpx.HTTPError as exc:
|
|
125
|
+
raise ApiError(f"HTTP request failed: {exc}") from exc
|
|
126
|
+
if response.status_code < 400:
|
|
127
|
+
self._runtime_base_path = "/v1/sandboxes"
|
|
128
|
+
if response.status_code >= 400:
|
|
129
|
+
raise _api_error(response)
|
|
130
|
+
return response
|
|
131
|
+
|
|
132
|
+
async def health(self) -> Any:
|
|
133
|
+
return await self.request("GET", "/healthz")
|
|
134
|
+
|
|
135
|
+
async def create_runtime(
|
|
136
|
+
self,
|
|
137
|
+
name: str | None = None,
|
|
138
|
+
*,
|
|
139
|
+
snapshot_id: str | None = None,
|
|
140
|
+
vcpus: int = 1,
|
|
141
|
+
cur_memory_mib: int = 1024,
|
|
142
|
+
min_memory_mib: int = 0,
|
|
143
|
+
max_memory_mib: int = 0,
|
|
144
|
+
ingress_specs: Sequence[dict[str, Any]] | None = None,
|
|
145
|
+
arch: str | None = None,
|
|
146
|
+
egress_policy: EgressPolicy | None = None,
|
|
147
|
+
idle_suspend_after_secs: int | None = None,
|
|
148
|
+
**_ignored: Any,
|
|
149
|
+
) -> RuntimeInfo:
|
|
150
|
+
body: dict[str, Any] = {
|
|
151
|
+
"name": name,
|
|
152
|
+
"snapshot_id": snapshot_id,
|
|
153
|
+
"vcpus": vcpus,
|
|
154
|
+
"cur_memory_mib": cur_memory_mib,
|
|
155
|
+
"min_memory_mib": min_memory_mib,
|
|
156
|
+
"max_memory_mib": max_memory_mib,
|
|
157
|
+
"ingress_specs": list(ingress_specs or []),
|
|
158
|
+
"egress_policy": egress_policy.to_json() if egress_policy else EgressPolicy().to_json(),
|
|
159
|
+
"idle_suspend_after_secs": idle_suspend_after_secs,
|
|
160
|
+
}
|
|
161
|
+
if arch is not None:
|
|
162
|
+
body["arch"] = arch
|
|
163
|
+
return RuntimeInfo.from_json(await self.runtime_request("POST", json=_drop_none(body)))
|
|
164
|
+
|
|
165
|
+
async def list_runtimes(self, status: str | None = None) -> list[RuntimeInfo]:
|
|
166
|
+
params = {"status": status} if status else None
|
|
167
|
+
data = await self.runtime_request("GET", params=params)
|
|
168
|
+
return [RuntimeInfo.from_json(item) for item in data]
|
|
169
|
+
|
|
170
|
+
async def get_runtime(self, runtime_id: str) -> RuntimeInfo:
|
|
171
|
+
return RuntimeInfo.from_json(await self.runtime_request("GET", f"/{runtime_id}"))
|
|
172
|
+
|
|
173
|
+
async def delete_runtime(self, runtime_id: str) -> None:
|
|
174
|
+
await self.runtime_request("DELETE", f"/{runtime_id}")
|
|
175
|
+
|
|
176
|
+
async def read_file(self, runtime_id: str, path: str) -> bytes:
|
|
177
|
+
response = await self.runtime_file_request("GET", runtime_id, path)
|
|
178
|
+
return response.content
|
|
179
|
+
|
|
180
|
+
async def write_file(self, runtime_id: str, path: str, content: bytes | str) -> None:
|
|
181
|
+
if isinstance(content, str):
|
|
182
|
+
content = content.encode()
|
|
183
|
+
await self.runtime_file_request("PUT", runtime_id, path, content=content)
|
|
184
|
+
|
|
185
|
+
async def update_runtime_status(self, runtime_id: str, action: str) -> RuntimeInfo:
|
|
186
|
+
path = {
|
|
187
|
+
"running": "start",
|
|
188
|
+
"active": "start",
|
|
189
|
+
"paused": "pause",
|
|
190
|
+
"pause": "pause",
|
|
191
|
+
"resume": "resume",
|
|
192
|
+
"shutdown": "stop",
|
|
193
|
+
"stopped": "stop",
|
|
194
|
+
"stop": "stop",
|
|
195
|
+
}[action]
|
|
196
|
+
return RuntimeInfo.from_json(await self.runtime_request("POST", f"/{runtime_id}/{path}"))
|
|
197
|
+
|
|
198
|
+
async def enable_egress(self, runtime_id: str) -> RuntimeInfo:
|
|
199
|
+
return RuntimeInfo.from_json(await self.runtime_request("POST", f"/{runtime_id}/egress/enable"))
|
|
200
|
+
|
|
201
|
+
async def disable_egress(self, runtime_id: str) -> RuntimeInfo:
|
|
202
|
+
return RuntimeInfo.from_json(await self.runtime_request("POST", f"/{runtime_id}/egress/disable"))
|
|
203
|
+
|
|
204
|
+
async def get_egress(self, runtime_id: str) -> dict[str, Any]:
|
|
205
|
+
return await self.runtime_request("GET", f"/{runtime_id}/egress")
|
|
206
|
+
|
|
207
|
+
async def list_egress_audit(self, runtime_id: str, limit: int = 100) -> list[EgressAuditEvent]:
|
|
208
|
+
data = await self.runtime_request(
|
|
209
|
+
"GET",
|
|
210
|
+
f"/{runtime_id}/egress/audit",
|
|
211
|
+
params={"limit": limit},
|
|
212
|
+
)
|
|
213
|
+
return [EgressAuditEvent.from_json(item) for item in data]
|
|
214
|
+
|
|
215
|
+
async def add_allowlist_hosts(self, runtime_id: str, hosts: Sequence[str]) -> dict[str, Any]:
|
|
216
|
+
return await self.runtime_request(
|
|
217
|
+
"PATCH",
|
|
218
|
+
f"/{runtime_id}/egress/allowlist",
|
|
219
|
+
json={"add": list(hosts)},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
async def remove_allowlist_hosts(self, runtime_id: str, hosts: Sequence[str]) -> dict[str, Any]:
|
|
223
|
+
return await self.runtime_request(
|
|
224
|
+
"PATCH",
|
|
225
|
+
f"/{runtime_id}/egress/allowlist",
|
|
226
|
+
json={"remove": list(hosts)},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def execute(
|
|
230
|
+
self,
|
|
231
|
+
runtime_id: str,
|
|
232
|
+
command: str,
|
|
233
|
+
args: Sequence[str] = (),
|
|
234
|
+
*,
|
|
235
|
+
env: Mapping[str, str] | None = None,
|
|
236
|
+
stdin: bytes | str | None = None,
|
|
237
|
+
timeout: float | None = None,
|
|
238
|
+
cwd: str | None = None,
|
|
239
|
+
max_output_bytes: int | None = None,
|
|
240
|
+
) -> CommandResult:
|
|
241
|
+
started = time.monotonic()
|
|
242
|
+
if isinstance(stdin, bytes):
|
|
243
|
+
stdin = stdin.decode(errors="replace")
|
|
244
|
+
body = {
|
|
245
|
+
"command": command,
|
|
246
|
+
"args": list(args),
|
|
247
|
+
"timeout_secs": int(timeout) if timeout is not None else None,
|
|
248
|
+
"env": dict(env) if env else None,
|
|
249
|
+
"stdin": stdin,
|
|
250
|
+
"cwd": cwd,
|
|
251
|
+
"max_output_bytes": max_output_bytes,
|
|
252
|
+
}
|
|
253
|
+
data = await self.runtime_request("POST", f"/{runtime_id}/exec", json=_drop_none(body))
|
|
254
|
+
return CommandResult(
|
|
255
|
+
exit_code=int(data["exit_code"]),
|
|
256
|
+
stdout=str(data.get("stdout", "")).encode(),
|
|
257
|
+
stderr=str(data.get("stderr", "")).encode(),
|
|
258
|
+
duration=float(data.get("duration_ms", 0)) / 1000.0 or time.monotonic() - started,
|
|
259
|
+
stdout_truncated=bool(data.get("stdout_truncated", False)),
|
|
260
|
+
stderr_truncated=bool(data.get("stderr_truncated", False)),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
async def create_secret(self, name: str, value: str, cache_ttl_secs: int = 0) -> SecretInfo:
|
|
264
|
+
data = await self.request(
|
|
265
|
+
"POST",
|
|
266
|
+
"/v1/secrets",
|
|
267
|
+
json={"name": name, "value": value, "cache_ttl_secs": cache_ttl_secs},
|
|
268
|
+
)
|
|
269
|
+
return SecretInfo.from_json(data)
|
|
270
|
+
|
|
271
|
+
async def list_secrets(self) -> list[SecretInfo]:
|
|
272
|
+
return [SecretInfo.from_json(item) for item in await self.request("GET", "/v1/secrets")]
|
|
273
|
+
|
|
274
|
+
async def get_secret(self, secret_id: str) -> SecretInfo:
|
|
275
|
+
return SecretInfo.from_json(await self.request("GET", f"/v1/secrets/{secret_id}"))
|
|
276
|
+
|
|
277
|
+
async def delete_secret(self, secret_id: str) -> None:
|
|
278
|
+
await self.request("DELETE", f"/v1/secrets/{secret_id}")
|
|
279
|
+
|
|
280
|
+
async def create_secret_injection_rule(
|
|
281
|
+
self,
|
|
282
|
+
runtime_id: str,
|
|
283
|
+
host: str,
|
|
284
|
+
path: str | None,
|
|
285
|
+
secret: str,
|
|
286
|
+
injection: Mapping[str, Any],
|
|
287
|
+
) -> SecretInjectionRuleInfo:
|
|
288
|
+
data = await self.runtime_request(
|
|
289
|
+
"POST",
|
|
290
|
+
f"/{runtime_id}/secret-injection-rules",
|
|
291
|
+
json=_drop_none({"host": host, "path": path, "secret": secret, "injection": dict(injection)}),
|
|
292
|
+
)
|
|
293
|
+
return SecretInjectionRuleInfo.from_json(data)
|
|
294
|
+
|
|
295
|
+
async def list_secret_injection_rules(self, runtime_id: str) -> list[SecretInjectionRuleInfo]:
|
|
296
|
+
data = await self.runtime_request("GET", f"/{runtime_id}/secret-injection-rules")
|
|
297
|
+
return [SecretInjectionRuleInfo.from_json(item) for item in data]
|
|
298
|
+
|
|
299
|
+
async def get_secret_injection_rule(self, runtime_id: str, rule_id: str) -> SecretInjectionRuleInfo:
|
|
300
|
+
data = await self.runtime_request("GET", f"/{runtime_id}/secret-injection-rules/{rule_id}")
|
|
301
|
+
return SecretInjectionRuleInfo.from_json(data)
|
|
302
|
+
|
|
303
|
+
async def delete_secret_injection_rule(self, runtime_id: str, rule_id: str) -> None:
|
|
304
|
+
await self.runtime_request("DELETE", f"/{runtime_id}/secret-injection-rules/{rule_id}")
|
|
305
|
+
|
|
306
|
+
create_secret_rule = create_secret_injection_rule
|
|
307
|
+
list_secret_rules = list_secret_injection_rules
|
|
308
|
+
get_secret_rule = get_secret_injection_rule
|
|
309
|
+
delete_secret_rule = delete_secret_injection_rule
|
|
310
|
+
|
|
311
|
+
async def create_snapshot(self, runtime_id: str, name: str | None = None, kind: str = "full") -> SnapshotInfo:
|
|
312
|
+
data = await self.runtime_request(
|
|
313
|
+
"POST",
|
|
314
|
+
f"/{runtime_id}/snapshots",
|
|
315
|
+
json=_drop_none({"name": name, "kind": kind}),
|
|
316
|
+
)
|
|
317
|
+
return SnapshotInfo.from_json(data)
|
|
318
|
+
|
|
319
|
+
async def list_snapshots(self) -> list[SnapshotInfo]:
|
|
320
|
+
return [SnapshotInfo.from_json(item) for item in await self.request("GET", "/v1/snapshots")]
|
|
321
|
+
|
|
322
|
+
async def get_snapshot(self, snapshot_id: str) -> SnapshotInfo:
|
|
323
|
+
return SnapshotInfo.from_json(await self.request("GET", f"/v1/snapshots/{snapshot_id}"))
|
|
324
|
+
|
|
325
|
+
async def delete_snapshot(self, snapshot_id: str) -> None:
|
|
326
|
+
await self.request("DELETE", f"/v1/snapshots/{snapshot_id}")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _api_error(response: httpx.Response) -> ApiError:
|
|
330
|
+
code = None
|
|
331
|
+
request = response.request
|
|
332
|
+
message = response.text.strip()
|
|
333
|
+
try:
|
|
334
|
+
body = response.json().get("error", {})
|
|
335
|
+
code = body.get("code")
|
|
336
|
+
message = body.get("message") or message
|
|
337
|
+
except ValueError:
|
|
338
|
+
pass
|
|
339
|
+
if not message:
|
|
340
|
+
message = f"{request.method} {request.url.path} failed"
|
|
341
|
+
return ApiError(message, status_code=response.status_code, code=code)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _drop_none(data: Mapping[str, Any]) -> dict[str, Any]:
|
|
345
|
+
return {key: value for key, value in data.items() if value is not None}
|