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 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