flaxcloud 0.3.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.
- flaxcloud/__init__.py +59 -0
- flaxcloud/aio.py +440 -0
- flaxcloud/cli.py +385 -0
- flaxcloud/client.py +204 -0
- flaxcloud/errors.py +65 -0
- flaxcloud/models.py +138 -0
- flaxcloud/py.typed +0 -0
- flaxcloud/sandbox.py +463 -0
- flaxcloud-0.3.0.dist-info/METADATA +164 -0
- flaxcloud-0.3.0.dist-info/RECORD +13 -0
- flaxcloud-0.3.0.dist-info/WHEEL +4 -0
- flaxcloud-0.3.0.dist-info/entry_points.txt +2 -0
- flaxcloud-0.3.0.dist-info/licenses/LICENSE +21 -0
flaxcloud/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Flax Cloud — Python SDK and CLI for isolated cloud sandboxes.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from flaxcloud import FlaxClient
|
|
5
|
+
|
|
6
|
+
flax = FlaxClient() # reads FLAX_API_KEY
|
|
7
|
+
with flax.create_sandbox(template="python") as sb:
|
|
8
|
+
print(sb.run("python3 -c 'print(6*7)'").stdout)
|
|
9
|
+
```
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .client import FlaxClient
|
|
15
|
+
from .errors import (
|
|
16
|
+
FlaxAuthError,
|
|
17
|
+
FlaxBadRequestError,
|
|
18
|
+
FlaxConflictError,
|
|
19
|
+
FlaxConnectionError,
|
|
20
|
+
FlaxError,
|
|
21
|
+
FlaxNotFoundError,
|
|
22
|
+
FlaxQuotaError,
|
|
23
|
+
FlaxServerError,
|
|
24
|
+
)
|
|
25
|
+
from .models import Command, FileEntry, PreviewLink, SessionResult, StartupStatus
|
|
26
|
+
from .sandbox import Sandbox, Session
|
|
27
|
+
|
|
28
|
+
__version__ = "0.3.0"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def __getattr__(name: str):
|
|
32
|
+
# Lazily expose the async client so importing the package doesn't require asyncio setup.
|
|
33
|
+
if name in ("AsyncFlaxClient", "AsyncSandbox"):
|
|
34
|
+
from . import aio
|
|
35
|
+
|
|
36
|
+
return getattr(aio, name)
|
|
37
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"FlaxClient",
|
|
42
|
+
"Sandbox",
|
|
43
|
+
"Session",
|
|
44
|
+
"SessionResult",
|
|
45
|
+
"AsyncFlaxClient",
|
|
46
|
+
"AsyncSandbox",
|
|
47
|
+
"Command",
|
|
48
|
+
"FileEntry",
|
|
49
|
+
"PreviewLink",
|
|
50
|
+
"StartupStatus",
|
|
51
|
+
"FlaxError",
|
|
52
|
+
"FlaxAuthError",
|
|
53
|
+
"FlaxNotFoundError",
|
|
54
|
+
"FlaxQuotaError",
|
|
55
|
+
"FlaxConflictError",
|
|
56
|
+
"FlaxBadRequestError",
|
|
57
|
+
"FlaxServerError",
|
|
58
|
+
"FlaxConnectionError",
|
|
59
|
+
]
|
flaxcloud/aio.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Async client for Flax Cloud — `AsyncFlaxClient` + `AsyncSandbox`.
|
|
2
|
+
|
|
3
|
+
Mirrors the sync `FlaxClient`/`Sandbox` API using `httpx.AsyncClient` and `await`.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import asyncio
|
|
7
|
+
from flaxcloud.aio import AsyncFlaxClient
|
|
8
|
+
|
|
9
|
+
async def main():
|
|
10
|
+
async with AsyncFlaxClient() as flax:
|
|
11
|
+
sb = await flax.create_sandbox(template="python")
|
|
12
|
+
out = await sb.run("python3 -c 'print(6*7)'")
|
|
13
|
+
print(out.stdout)
|
|
14
|
+
await sb.destroy()
|
|
15
|
+
|
|
16
|
+
asyncio.run(main())
|
|
17
|
+
```
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import base64
|
|
24
|
+
import os
|
|
25
|
+
import random
|
|
26
|
+
import shlex
|
|
27
|
+
from typing import Any, Optional, Union
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from .client import (
|
|
32
|
+
_IDEMPOTENT,
|
|
33
|
+
_RETRY_STATUSES,
|
|
34
|
+
DEFAULT_BASE_URL,
|
|
35
|
+
_user_agent,
|
|
36
|
+
)
|
|
37
|
+
from .errors import FlaxAuthError, FlaxConnectionError, error_from_response
|
|
38
|
+
from .models import Command, FileEntry, PreviewLink, SessionResult, StartupStatus
|
|
39
|
+
from .sandbox import WORKSPACE
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AsyncFlaxClient:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: Optional[str] = None,
|
|
46
|
+
*,
|
|
47
|
+
base_url: Optional[str] = None,
|
|
48
|
+
timeout: float = 60.0,
|
|
49
|
+
max_retries: int = 2,
|
|
50
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._api_key = api_key or os.environ.get("FLAX_API_KEY")
|
|
53
|
+
if not self._api_key:
|
|
54
|
+
raise FlaxAuthError(
|
|
55
|
+
"no API key: pass api_key=... or set the FLAX_API_KEY environment variable"
|
|
56
|
+
)
|
|
57
|
+
base_url = base_url or os.environ.get("FLAX_BASE_URL") or DEFAULT_BASE_URL
|
|
58
|
+
self._max_retries = max(0, int(max_retries))
|
|
59
|
+
self._http = http_client or httpx.AsyncClient(base_url=base_url.rstrip("/"), timeout=timeout)
|
|
60
|
+
self._owns_client = http_client is None
|
|
61
|
+
self.base_url = base_url.rstrip("/")
|
|
62
|
+
|
|
63
|
+
async def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
64
|
+
headers = dict(kwargs.pop("headers", {}))
|
|
65
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
66
|
+
headers.setdefault("User-Agent", _user_agent())
|
|
67
|
+
method = method.upper()
|
|
68
|
+
|
|
69
|
+
attempt = 0
|
|
70
|
+
while True:
|
|
71
|
+
try:
|
|
72
|
+
resp = await self._http.request(method, path, headers=headers, **kwargs)
|
|
73
|
+
except httpx.ConnectError as exc:
|
|
74
|
+
if attempt < self._max_retries:
|
|
75
|
+
await self._sleep_backoff(attempt)
|
|
76
|
+
attempt += 1
|
|
77
|
+
continue
|
|
78
|
+
raise FlaxConnectionError(f"could not reach Flax Cloud: {exc}") from exc
|
|
79
|
+
except httpx.HTTPError as exc:
|
|
80
|
+
if method in _IDEMPOTENT and attempt < self._max_retries:
|
|
81
|
+
await self._sleep_backoff(attempt)
|
|
82
|
+
attempt += 1
|
|
83
|
+
continue
|
|
84
|
+
raise FlaxConnectionError(f"could not reach Flax Cloud: {exc}") from exc
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
resp.status_code in _RETRY_STATUSES
|
|
88
|
+
and method in _IDEMPOTENT
|
|
89
|
+
and attempt < self._max_retries
|
|
90
|
+
):
|
|
91
|
+
await self._sleep_backoff(attempt, resp.headers.get("Retry-After"))
|
|
92
|
+
attempt += 1
|
|
93
|
+
continue
|
|
94
|
+
return self._parse(resp)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
async def _sleep_backoff(attempt: int, retry_after: Optional[str] = None) -> None:
|
|
98
|
+
if retry_after:
|
|
99
|
+
try:
|
|
100
|
+
await asyncio.sleep(min(float(retry_after), 30.0))
|
|
101
|
+
return
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
pass
|
|
104
|
+
await asyncio.sleep(min(0.25 * (2 ** attempt), 5.0) * (0.5 + random.random()))
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _parse(resp: httpx.Response) -> Any:
|
|
108
|
+
if resp.status_code >= 400:
|
|
109
|
+
code = None
|
|
110
|
+
message = resp.text or resp.reason_phrase
|
|
111
|
+
try:
|
|
112
|
+
payload = resp.json()
|
|
113
|
+
if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
|
|
114
|
+
code = payload["error"].get("code")
|
|
115
|
+
message = payload["error"].get("message", message)
|
|
116
|
+
except ValueError:
|
|
117
|
+
pass
|
|
118
|
+
raise error_from_response(resp.status_code, code, message)
|
|
119
|
+
if resp.status_code == 204 or not resp.content:
|
|
120
|
+
return None
|
|
121
|
+
return resp.json()
|
|
122
|
+
|
|
123
|
+
# ------------------------------- sandboxes -------------------------------
|
|
124
|
+
async def create_sandbox(
|
|
125
|
+
self,
|
|
126
|
+
template: str = "blank",
|
|
127
|
+
*,
|
|
128
|
+
memory_mb: Optional[int] = None,
|
|
129
|
+
timeout_seconds: Optional[int] = None,
|
|
130
|
+
network_mode: Optional[str] = None,
|
|
131
|
+
env: Optional[dict] = None,
|
|
132
|
+
startup_command: Optional[str] = None,
|
|
133
|
+
restore_from: Optional[str] = None,
|
|
134
|
+
metadata: Optional[dict] = None,
|
|
135
|
+
) -> "AsyncSandbox":
|
|
136
|
+
body: dict[str, Any] = {"template": template}
|
|
137
|
+
if memory_mb is not None:
|
|
138
|
+
body["memory_mb"] = memory_mb
|
|
139
|
+
if timeout_seconds is not None:
|
|
140
|
+
body["timeout_seconds"] = timeout_seconds
|
|
141
|
+
if network_mode is not None:
|
|
142
|
+
body["network_mode"] = network_mode
|
|
143
|
+
if env is not None:
|
|
144
|
+
body["env"] = env
|
|
145
|
+
if startup_command is not None:
|
|
146
|
+
body["startup_command"] = startup_command
|
|
147
|
+
if restore_from is not None:
|
|
148
|
+
body["restore_from"] = restore_from
|
|
149
|
+
if metadata is not None:
|
|
150
|
+
body["metadata"] = metadata
|
|
151
|
+
return AsyncSandbox(self, await self.request("POST", "/v1/sandboxes", json=body))
|
|
152
|
+
|
|
153
|
+
async def get_sandbox(self, sandbox_id: str) -> "AsyncSandbox":
|
|
154
|
+
return AsyncSandbox(self, await self.request("GET", f"/v1/sandboxes/{sandbox_id}"))
|
|
155
|
+
|
|
156
|
+
async def list_sandboxes(self) -> list["AsyncSandbox"]:
|
|
157
|
+
data = await self.request("GET", "/v1/sandboxes")
|
|
158
|
+
return [AsyncSandbox(self, d) for d in data.get("data", [])]
|
|
159
|
+
|
|
160
|
+
async def me(self) -> dict:
|
|
161
|
+
return await self.request("GET", "/v1/me")
|
|
162
|
+
|
|
163
|
+
async def usage(self) -> dict:
|
|
164
|
+
return await self.request("GET", "/v1/usage")
|
|
165
|
+
|
|
166
|
+
# ------------------------------- lifecycle -------------------------------
|
|
167
|
+
async def aclose(self) -> None:
|
|
168
|
+
if self._owns_client:
|
|
169
|
+
await self._http.aclose()
|
|
170
|
+
|
|
171
|
+
async def __aenter__(self) -> "AsyncFlaxClient":
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
175
|
+
await self.aclose()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class AsyncSandbox:
|
|
179
|
+
def __init__(self, client: AsyncFlaxClient, data: dict) -> None:
|
|
180
|
+
self._client = client
|
|
181
|
+
self._data = data
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def id(self) -> str:
|
|
185
|
+
return self._data["id"]
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def status(self) -> str:
|
|
189
|
+
return self._data.get("status", "")
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def env(self) -> dict:
|
|
193
|
+
return dict(self._data.get("env") or {})
|
|
194
|
+
|
|
195
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
196
|
+
return f"<AsyncSandbox {self.id} status={self.status!r}>"
|
|
197
|
+
|
|
198
|
+
async def refresh(self) -> "AsyncSandbox":
|
|
199
|
+
self._data = await self._client.request("GET", f"/v1/sandboxes/{self.id}")
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
async def stop(self) -> "AsyncSandbox":
|
|
203
|
+
self._data = await self._client.request("POST", f"/v1/sandboxes/{self.id}/stop")
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
async def resume(self) -> "AsyncSandbox":
|
|
207
|
+
self._data = await self._client.request("POST", f"/v1/sandboxes/{self.id}/resume")
|
|
208
|
+
return self
|
|
209
|
+
|
|
210
|
+
async def destroy(self) -> None:
|
|
211
|
+
await self._client.request("DELETE", f"/v1/sandboxes/{self.id}")
|
|
212
|
+
|
|
213
|
+
async def __aenter__(self) -> "AsyncSandbox":
|
|
214
|
+
return self
|
|
215
|
+
|
|
216
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
217
|
+
try:
|
|
218
|
+
await self.destroy()
|
|
219
|
+
except Exception: # noqa: BLE001
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
async def run(
|
|
223
|
+
self,
|
|
224
|
+
command: str,
|
|
225
|
+
*,
|
|
226
|
+
timeout: Optional[int] = None,
|
|
227
|
+
working_directory: Optional[str] = None,
|
|
228
|
+
env: Optional[dict] = None,
|
|
229
|
+
background: bool = False,
|
|
230
|
+
) -> Command:
|
|
231
|
+
body: dict = {"command": command}
|
|
232
|
+
if timeout is not None:
|
|
233
|
+
body["timeout_seconds"] = timeout
|
|
234
|
+
if working_directory is not None:
|
|
235
|
+
body["working_directory"] = working_directory
|
|
236
|
+
if env is not None:
|
|
237
|
+
body["env"] = env
|
|
238
|
+
if background:
|
|
239
|
+
body["async"] = True
|
|
240
|
+
return Command.from_dict(
|
|
241
|
+
await self._client.request("POST", f"/v1/sandboxes/{self.id}/commands", json=body)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async def code_run(
|
|
245
|
+
self, code: str, *, language: str = "python", timeout: Optional[int] = None,
|
|
246
|
+
env: Optional[dict] = None, background: bool = False,
|
|
247
|
+
) -> Command:
|
|
248
|
+
from .sandbox import Sandbox
|
|
249
|
+
|
|
250
|
+
interp = Sandbox._CODE_INTERPRETERS.get(language.lower())
|
|
251
|
+
if interp is None:
|
|
252
|
+
raise ValueError(f"unsupported language {language!r}")
|
|
253
|
+
encoded = base64.b64encode(code.encode("utf-8")).decode("ascii")
|
|
254
|
+
return await self.run(
|
|
255
|
+
f"echo {encoded} | base64 -d | {interp}",
|
|
256
|
+
timeout=timeout, env=env, background=background,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def run_stream(
|
|
260
|
+
self, command: str, *, timeout: Optional[int] = None, env: Optional[dict] = None
|
|
261
|
+
) -> "AsyncCommandStream":
|
|
262
|
+
body: dict = {"command": command}
|
|
263
|
+
if timeout is not None:
|
|
264
|
+
body["timeout_seconds"] = timeout
|
|
265
|
+
if env is not None:
|
|
266
|
+
body["env"] = env
|
|
267
|
+
return AsyncCommandStream(self._client, f"/v1/sandboxes/{self.id}/commands/stream", body)
|
|
268
|
+
|
|
269
|
+
async def get_command(self, command_id: str) -> Command:
|
|
270
|
+
return Command.from_dict(await self._client.request("GET", f"/v1/commands/{command_id}"))
|
|
271
|
+
|
|
272
|
+
async def wait(
|
|
273
|
+
self, command: Union[Command, str], *, interval: float = 1.0, timeout: float = 300.0
|
|
274
|
+
) -> Command:
|
|
275
|
+
command_id = command.id if isinstance(command, Command) else command
|
|
276
|
+
loop = asyncio.get_event_loop()
|
|
277
|
+
deadline = loop.time() + timeout
|
|
278
|
+
current = await self.get_command(command_id)
|
|
279
|
+
while not current.done:
|
|
280
|
+
if loop.time() > deadline:
|
|
281
|
+
raise TimeoutError(f"command {command_id} did not finish within {timeout}s")
|
|
282
|
+
await asyncio.sleep(interval)
|
|
283
|
+
current = await self.get_command(command_id)
|
|
284
|
+
return current
|
|
285
|
+
|
|
286
|
+
# files
|
|
287
|
+
async def upload(self, path: str, content: Union[bytes, str]) -> None:
|
|
288
|
+
raw = content.encode("utf-8") if isinstance(content, str) else content
|
|
289
|
+
await self._client.request(
|
|
290
|
+
"POST", f"/v1/sandboxes/{self.id}/files",
|
|
291
|
+
json={"path": path, "content_base64": base64.b64encode(raw).decode()},
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
async def download(self, path: str) -> bytes:
|
|
295
|
+
data = await self._client.request(
|
|
296
|
+
"GET", f"/v1/sandboxes/{self.id}/files", params={"path": path}
|
|
297
|
+
)
|
|
298
|
+
return base64.b64decode(data["content_base64"])
|
|
299
|
+
|
|
300
|
+
async def list_files(self, path: str = WORKSPACE) -> list[FileEntry]:
|
|
301
|
+
data = await self._client.request(
|
|
302
|
+
"GET", f"/v1/sandboxes/{self.id}/files/list", params={"path": path}
|
|
303
|
+
)
|
|
304
|
+
return [FileEntry.from_dict(d) for d in data.get("data", [])]
|
|
305
|
+
|
|
306
|
+
async def delete_file(self, path: str) -> None:
|
|
307
|
+
await self._client.request(
|
|
308
|
+
"DELETE", f"/v1/sandboxes/{self.id}/files", params={"path": path}
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
async def read_text(self, path: str, encoding: str = "utf-8") -> str:
|
|
312
|
+
return (await self.download(path)).decode(encoding)
|
|
313
|
+
|
|
314
|
+
async def mkdir(self, path: str) -> None:
|
|
315
|
+
await self.run(f"mkdir -p {shlex.quote(path)}")
|
|
316
|
+
|
|
317
|
+
async def move(self, src: str, dst: str) -> None:
|
|
318
|
+
await self.run(f"mv {shlex.quote(src)} {shlex.quote(dst)}")
|
|
319
|
+
|
|
320
|
+
async def exists(self, path: str) -> bool:
|
|
321
|
+
return (await self.run(f"test -e {shlex.quote(path)}")).exit_code == 0
|
|
322
|
+
|
|
323
|
+
# startup
|
|
324
|
+
async def startup(self) -> StartupStatus:
|
|
325
|
+
return StartupStatus.from_dict(
|
|
326
|
+
await self._client.request("GET", f"/v1/sandboxes/{self.id}/startup")
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
async def set_startup(self, command: Optional[str]) -> StartupStatus:
|
|
330
|
+
return StartupStatus.from_dict(
|
|
331
|
+
await self._client.request(
|
|
332
|
+
"PUT", f"/v1/sandboxes/{self.id}/startup", json={"command": command}
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
async def run_startup(self) -> StartupStatus:
|
|
337
|
+
return StartupStatus.from_dict(
|
|
338
|
+
await self._client.request("POST", f"/v1/sandboxes/{self.id}/startup/run")
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# previews
|
|
342
|
+
async def create_preview_link(self, port: int) -> PreviewLink:
|
|
343
|
+
return PreviewLink.from_dict(
|
|
344
|
+
await self._client.request(
|
|
345
|
+
"POST", f"/v1/sandboxes/{self.id}/preview-link", json={"port": port}
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# sessions
|
|
350
|
+
async def create_session(self) -> "AsyncSession":
|
|
351
|
+
data = await self._client.request("POST", f"/v1/sandboxes/{self.id}/sessions")
|
|
352
|
+
return AsyncSession(self._client, data)
|
|
353
|
+
|
|
354
|
+
async def sessions(self) -> list["AsyncSession"]:
|
|
355
|
+
data = await self._client.request("GET", f"/v1/sandboxes/{self.id}/sessions")
|
|
356
|
+
return [AsyncSession(self._client, d) for d in data.get("data", [])]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class AsyncCommandStream:
|
|
360
|
+
"""Async iterable of live output chunks; `exit_code` is set once the stream ends."""
|
|
361
|
+
|
|
362
|
+
def __init__(self, client: AsyncFlaxClient, path: str, body: dict) -> None:
|
|
363
|
+
self._client = client
|
|
364
|
+
self._path = path
|
|
365
|
+
self._body = body
|
|
366
|
+
self.exit_code: Optional[int] = None
|
|
367
|
+
|
|
368
|
+
async def __aiter__(self):
|
|
369
|
+
from .errors import FlaxError
|
|
370
|
+
|
|
371
|
+
headers = {
|
|
372
|
+
"Authorization": f"Bearer {self._client._api_key}",
|
|
373
|
+
"User-Agent": _user_agent(),
|
|
374
|
+
"Accept": "text/event-stream",
|
|
375
|
+
}
|
|
376
|
+
async with self._client._http.stream(
|
|
377
|
+
"POST", self._path, json=self._body, headers=headers
|
|
378
|
+
) as resp:
|
|
379
|
+
if resp.status_code >= 400:
|
|
380
|
+
await resp.aread()
|
|
381
|
+
AsyncFlaxClient._parse(resp)
|
|
382
|
+
error: Optional[str] = None
|
|
383
|
+
event: Optional[str] = None
|
|
384
|
+
async for line in resp.aiter_lines():
|
|
385
|
+
if not line:
|
|
386
|
+
event = None
|
|
387
|
+
continue
|
|
388
|
+
if line.startswith("event:"):
|
|
389
|
+
event = line[len("event:"):].strip()
|
|
390
|
+
elif line.startswith("data:"):
|
|
391
|
+
data = line[len("data:"):].strip()
|
|
392
|
+
if event == "chunk":
|
|
393
|
+
yield base64.b64decode(data).decode("utf-8", "replace")
|
|
394
|
+
elif event == "error":
|
|
395
|
+
error = base64.b64decode(data).decode("utf-8", "replace")
|
|
396
|
+
elif event == "end":
|
|
397
|
+
try:
|
|
398
|
+
self.exit_code = int(data)
|
|
399
|
+
except ValueError:
|
|
400
|
+
self.exit_code = None
|
|
401
|
+
if error is not None:
|
|
402
|
+
raise FlaxError(f"streamed command failed: {error}")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class AsyncSession:
|
|
406
|
+
"""Async stateful session; cwd/exported env persist across `run()` calls."""
|
|
407
|
+
|
|
408
|
+
def __init__(self, client: AsyncFlaxClient, data: dict) -> None:
|
|
409
|
+
self._client = client
|
|
410
|
+
self._data = data
|
|
411
|
+
|
|
412
|
+
@property
|
|
413
|
+
def id(self) -> str:
|
|
414
|
+
return self._data["id"]
|
|
415
|
+
|
|
416
|
+
@property
|
|
417
|
+
def cwd(self) -> str:
|
|
418
|
+
return self._data.get("cwd", WORKSPACE)
|
|
419
|
+
|
|
420
|
+
async def run(self, command: str, *, timeout: Optional[int] = None) -> SessionResult:
|
|
421
|
+
body: dict = {"command": command}
|
|
422
|
+
if timeout is not None:
|
|
423
|
+
body["timeout_seconds"] = timeout
|
|
424
|
+
result = SessionResult.from_dict(
|
|
425
|
+
await self._client.request("POST", f"/v1/sessions/{self.id}/exec", json=body)
|
|
426
|
+
)
|
|
427
|
+
self._data["cwd"] = result.cwd
|
|
428
|
+
return result
|
|
429
|
+
|
|
430
|
+
async def delete(self) -> None:
|
|
431
|
+
await self._client.request("DELETE", f"/v1/sessions/{self.id}")
|
|
432
|
+
|
|
433
|
+
async def __aenter__(self) -> "AsyncSession":
|
|
434
|
+
return self
|
|
435
|
+
|
|
436
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
437
|
+
try:
|
|
438
|
+
await self.delete()
|
|
439
|
+
except Exception: # noqa: BLE001
|
|
440
|
+
pass
|