flaxcloud 0.5.0__tar.gz → 0.5.2__tar.gz
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-0.5.0 → flaxcloud-0.5.2}/PKG-INFO +32 -3
- {flaxcloud-0.5.0 → flaxcloud-0.5.2}/README.md +31 -2
- {flaxcloud-0.5.0 → flaxcloud-0.5.2}/pyproject.toml +1 -1
- {flaxcloud-0.5.0 → flaxcloud-0.5.2}/src/flaxcloud/__init__.py +24 -4
- {flaxcloud-0.5.0 → flaxcloud-0.5.2}/src/flaxcloud/aio.py +143 -1
- {flaxcloud-0.5.0 → flaxcloud-0.5.2}/src/flaxcloud/cli.py +36 -0
- flaxcloud-0.5.2/src/flaxcloud/py.typed +1 -0
- {flaxcloud-0.5.0 → flaxcloud-0.5.2}/src/flaxcloud/sandbox.py +7 -0
- flaxcloud-0.5.0/src/flaxcloud/py.typed +0 -0
- {flaxcloud-0.5.0 → flaxcloud-0.5.2}/.gitignore +0 -0
- {flaxcloud-0.5.0 → flaxcloud-0.5.2}/LICENSE +0 -0
- {flaxcloud-0.5.0 → flaxcloud-0.5.2}/src/flaxcloud/client.py +0 -0
- {flaxcloud-0.5.0 → flaxcloud-0.5.2}/src/flaxcloud/errors.py +0 -0
- {flaxcloud-0.5.0 → flaxcloud-0.5.2}/src/flaxcloud/images.py +0 -0
- {flaxcloud-0.5.0 → flaxcloud-0.5.2}/src/flaxcloud/models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flaxcloud
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Python SDK and CLI for Flax Cloud: isolated cloud sandboxes.
|
|
5
5
|
Project-URL: Homepage, https://flaxcloud.com
|
|
6
6
|
Project-URL: Documentation, https://github.com/tomitokko/flax-cloud/blob/main/docs/user/sdk.md
|
|
@@ -65,19 +65,25 @@ CLI (which stores it in `~/.config/flax/config.json`):
|
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
67
|
export FLAX_API_KEY=flax_live_...
|
|
68
|
+
# Optional, for local/self-hosted targets:
|
|
69
|
+
export FLAX_BASE_URL=https://flaxcloud.com
|
|
68
70
|
# or
|
|
69
71
|
flax login
|
|
70
72
|
```
|
|
71
73
|
|
|
74
|
+
Credentials resolve in this order: explicit `api_key`, CLI `--key`, `FLAX_API_KEY`, then
|
|
75
|
+
`~/.config/flax/config.json`. The base URL is `base_url`, CLI `--url`, `FLAX_BASE_URL`, then
|
|
76
|
+
`https://flaxcloud.com`.
|
|
77
|
+
|
|
72
78
|
## SDK
|
|
73
79
|
|
|
74
80
|
```python
|
|
75
81
|
from flaxcloud import FlaxClient
|
|
76
82
|
|
|
77
|
-
flax = FlaxClient() # reads FLAX_API_KEY
|
|
83
|
+
flax = FlaxClient() # reads FLAX_API_KEY and optional FLAX_BASE_URL
|
|
78
84
|
|
|
79
85
|
# create_sandbox returns a handle with methods; use it as a context manager to auto-destroy
|
|
80
|
-
with flax.create_sandbox(template="python", name="ci-run") as sb:
|
|
86
|
+
with flax.create_sandbox(template="python", name="ci-run", memory_mb=512) as sb:
|
|
81
87
|
print(sb.name, sb.id)
|
|
82
88
|
out = sb.run("python3 -c 'print(6*7)'")
|
|
83
89
|
print(out.stdout) # "42\n"
|
|
@@ -91,6 +97,22 @@ with flax.create_sandbox(template="python", name="ci-run") as sb:
|
|
|
91
97
|
print(sb.create_preview_link(8000).url)
|
|
92
98
|
```
|
|
93
99
|
|
|
100
|
+
Per-sandbox memory is plan-limited: Free `1 GB`, Pro `2 GB`, Builder `4 GB`, Team `8 GB`.
|
|
101
|
+
Requesting more than your plan allows raises `FlaxQuotaError` with `code == "memory_limit"`:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from flaxcloud import FlaxClient, FlaxQuotaError
|
|
105
|
+
|
|
106
|
+
flax = FlaxClient()
|
|
107
|
+
try:
|
|
108
|
+
flax.create_sandbox(template="python", memory_mb=2048)
|
|
109
|
+
except FlaxQuotaError as exc:
|
|
110
|
+
if exc.code == "memory_limit":
|
|
111
|
+
print("Choose a smaller sandbox or upgrade your plan.")
|
|
112
|
+
else:
|
|
113
|
+
raise
|
|
114
|
+
```
|
|
115
|
+
|
|
94
116
|
Background commands:
|
|
95
117
|
|
|
96
118
|
```python
|
|
@@ -105,11 +127,15 @@ Environment variables, code execution, git, and filesystem helpers:
|
|
|
105
127
|
```python
|
|
106
128
|
sb = flax.create_sandbox(template="python", env={"API_BASE": "https://example.com"})
|
|
107
129
|
sb.run("printenv TOKEN", env={"TOKEN": "secret"}) # per-command override
|
|
130
|
+
sb.set_env({"API_BASE": "https://new.example.com"}) # future process defaults
|
|
108
131
|
sb.code_run("print(6*7)", language="python") # also node/bash/ruby
|
|
109
132
|
sb.git.clone("https://github.com/me/repo.git", "/workspace/repo")
|
|
110
133
|
sb.mkdir("/workspace/out"); sb.find("/workspace", name="*.py")
|
|
111
134
|
```
|
|
112
135
|
|
|
136
|
+
Environment payloads are encrypted at rest. Flax also places `HOME` and XDG state under the
|
|
137
|
+
durable workspace automatically, so normal tool state survives stop/resume.
|
|
138
|
+
|
|
113
139
|
Stateful sessions - working directory and exported env persist across commands:
|
|
114
140
|
|
|
115
141
|
```python
|
|
@@ -245,6 +271,9 @@ flax session create sbx_abc123 # cwd/env persist across exec
|
|
|
245
271
|
flax session exec ses_xyz "cd src && export E=1"
|
|
246
272
|
flax cp ./app.py sbx_abc123:/workspace/app.py
|
|
247
273
|
flax ls sbx_abc123 /workspace
|
|
274
|
+
flax snapshot create sbx_abc123 agent-base
|
|
275
|
+
flax snapshot ls
|
|
276
|
+
flax snapshot rm snap_abc123
|
|
248
277
|
flax preview sbx_abc123 8000
|
|
249
278
|
flax sandbox rm sbx_abc123
|
|
250
279
|
```
|
|
@@ -16,19 +16,25 @@ CLI (which stores it in `~/.config/flax/config.json`):
|
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
export FLAX_API_KEY=flax_live_...
|
|
19
|
+
# Optional, for local/self-hosted targets:
|
|
20
|
+
export FLAX_BASE_URL=https://flaxcloud.com
|
|
19
21
|
# or
|
|
20
22
|
flax login
|
|
21
23
|
```
|
|
22
24
|
|
|
25
|
+
Credentials resolve in this order: explicit `api_key`, CLI `--key`, `FLAX_API_KEY`, then
|
|
26
|
+
`~/.config/flax/config.json`. The base URL is `base_url`, CLI `--url`, `FLAX_BASE_URL`, then
|
|
27
|
+
`https://flaxcloud.com`.
|
|
28
|
+
|
|
23
29
|
## SDK
|
|
24
30
|
|
|
25
31
|
```python
|
|
26
32
|
from flaxcloud import FlaxClient
|
|
27
33
|
|
|
28
|
-
flax = FlaxClient() # reads FLAX_API_KEY
|
|
34
|
+
flax = FlaxClient() # reads FLAX_API_KEY and optional FLAX_BASE_URL
|
|
29
35
|
|
|
30
36
|
# create_sandbox returns a handle with methods; use it as a context manager to auto-destroy
|
|
31
|
-
with flax.create_sandbox(template="python", name="ci-run") as sb:
|
|
37
|
+
with flax.create_sandbox(template="python", name="ci-run", memory_mb=512) as sb:
|
|
32
38
|
print(sb.name, sb.id)
|
|
33
39
|
out = sb.run("python3 -c 'print(6*7)'")
|
|
34
40
|
print(out.stdout) # "42\n"
|
|
@@ -42,6 +48,22 @@ with flax.create_sandbox(template="python", name="ci-run") as sb:
|
|
|
42
48
|
print(sb.create_preview_link(8000).url)
|
|
43
49
|
```
|
|
44
50
|
|
|
51
|
+
Per-sandbox memory is plan-limited: Free `1 GB`, Pro `2 GB`, Builder `4 GB`, Team `8 GB`.
|
|
52
|
+
Requesting more than your plan allows raises `FlaxQuotaError` with `code == "memory_limit"`:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from flaxcloud import FlaxClient, FlaxQuotaError
|
|
56
|
+
|
|
57
|
+
flax = FlaxClient()
|
|
58
|
+
try:
|
|
59
|
+
flax.create_sandbox(template="python", memory_mb=2048)
|
|
60
|
+
except FlaxQuotaError as exc:
|
|
61
|
+
if exc.code == "memory_limit":
|
|
62
|
+
print("Choose a smaller sandbox or upgrade your plan.")
|
|
63
|
+
else:
|
|
64
|
+
raise
|
|
65
|
+
```
|
|
66
|
+
|
|
45
67
|
Background commands:
|
|
46
68
|
|
|
47
69
|
```python
|
|
@@ -56,11 +78,15 @@ Environment variables, code execution, git, and filesystem helpers:
|
|
|
56
78
|
```python
|
|
57
79
|
sb = flax.create_sandbox(template="python", env={"API_BASE": "https://example.com"})
|
|
58
80
|
sb.run("printenv TOKEN", env={"TOKEN": "secret"}) # per-command override
|
|
81
|
+
sb.set_env({"API_BASE": "https://new.example.com"}) # future process defaults
|
|
59
82
|
sb.code_run("print(6*7)", language="python") # also node/bash/ruby
|
|
60
83
|
sb.git.clone("https://github.com/me/repo.git", "/workspace/repo")
|
|
61
84
|
sb.mkdir("/workspace/out"); sb.find("/workspace", name="*.py")
|
|
62
85
|
```
|
|
63
86
|
|
|
87
|
+
Environment payloads are encrypted at rest. Flax also places `HOME` and XDG state under the
|
|
88
|
+
durable workspace automatically, so normal tool state survives stop/resume.
|
|
89
|
+
|
|
64
90
|
Stateful sessions - working directory and exported env persist across commands:
|
|
65
91
|
|
|
66
92
|
```python
|
|
@@ -196,6 +222,9 @@ flax session create sbx_abc123 # cwd/env persist across exec
|
|
|
196
222
|
flax session exec ses_xyz "cd src && export E=1"
|
|
197
223
|
flax cp ./app.py sbx_abc123:/workspace/app.py
|
|
198
224
|
flax ls sbx_abc123 /workspace
|
|
225
|
+
flax snapshot create sbx_abc123 agent-base
|
|
226
|
+
flax snapshot ls
|
|
227
|
+
flax snapshot rm snap_abc123
|
|
199
228
|
flax preview sbx_abc123 8000
|
|
200
229
|
flax sandbox rm sbx_abc123
|
|
201
230
|
```
|
|
@@ -23,15 +23,30 @@ from .errors import (
|
|
|
23
23
|
FlaxQuotaError,
|
|
24
24
|
FlaxServerError,
|
|
25
25
|
)
|
|
26
|
-
from .models import
|
|
27
|
-
|
|
26
|
+
from .models import (
|
|
27
|
+
Command,
|
|
28
|
+
FileEntry,
|
|
29
|
+
PreviewLink,
|
|
30
|
+
SessionResult,
|
|
31
|
+
Snapshot,
|
|
32
|
+
StartupStatus,
|
|
33
|
+
Template,
|
|
34
|
+
)
|
|
35
|
+
from .sandbox import CommandStream, Sandbox, SandboxBrowser, SandboxGit, Session
|
|
28
36
|
|
|
29
|
-
__version__ = "0.5.
|
|
37
|
+
__version__ = "0.5.2"
|
|
30
38
|
|
|
31
39
|
|
|
32
40
|
def __getattr__(name: str):
|
|
33
41
|
# Lazily expose the async client so importing the package doesn't require asyncio setup.
|
|
34
|
-
if name in (
|
|
42
|
+
if name in (
|
|
43
|
+
"AsyncFlaxClient",
|
|
44
|
+
"AsyncSandbox",
|
|
45
|
+
"AsyncSandboxBrowser",
|
|
46
|
+
"AsyncSandboxGit",
|
|
47
|
+
"AsyncCommandStream",
|
|
48
|
+
"AsyncSession",
|
|
49
|
+
):
|
|
35
50
|
from . import aio
|
|
36
51
|
|
|
37
52
|
return getattr(aio, name)
|
|
@@ -43,11 +58,16 @@ __all__ = [
|
|
|
43
58
|
"FlaxImage",
|
|
44
59
|
"Sandbox",
|
|
45
60
|
"SandboxBrowser",
|
|
61
|
+
"SandboxGit",
|
|
62
|
+
"CommandStream",
|
|
46
63
|
"Session",
|
|
47
64
|
"SessionResult",
|
|
48
65
|
"AsyncFlaxClient",
|
|
49
66
|
"AsyncSandbox",
|
|
50
67
|
"AsyncSandboxBrowser",
|
|
68
|
+
"AsyncSandboxGit",
|
|
69
|
+
"AsyncCommandStream",
|
|
70
|
+
"AsyncSession",
|
|
51
71
|
"Command",
|
|
52
72
|
"FileEntry",
|
|
53
73
|
"PreviewLink",
|
|
@@ -24,6 +24,7 @@ import base64
|
|
|
24
24
|
import os
|
|
25
25
|
import random
|
|
26
26
|
import shlex
|
|
27
|
+
from pathlib import Path
|
|
27
28
|
from typing import TYPE_CHECKING, Any, Optional, Union
|
|
28
29
|
|
|
29
30
|
import httpx
|
|
@@ -38,7 +39,15 @@ from .client import (
|
|
|
38
39
|
_user_agent,
|
|
39
40
|
)
|
|
40
41
|
from .errors import FlaxAuthError, FlaxConnectionError, FlaxError, error_from_response
|
|
41
|
-
from .models import
|
|
42
|
+
from .models import (
|
|
43
|
+
Command,
|
|
44
|
+
FileEntry,
|
|
45
|
+
PreviewLink,
|
|
46
|
+
SessionResult,
|
|
47
|
+
Snapshot,
|
|
48
|
+
StartupStatus,
|
|
49
|
+
Template,
|
|
50
|
+
)
|
|
42
51
|
from .sandbox import WORKSPACE
|
|
43
52
|
|
|
44
53
|
|
|
@@ -296,6 +305,17 @@ class AsyncFlaxClient:
|
|
|
296
305
|
async def usage(self) -> dict:
|
|
297
306
|
return await self.request("GET", "/v1/usage")
|
|
298
307
|
|
|
308
|
+
# ------------------------------- API keys --------------------------------
|
|
309
|
+
async def list_api_keys(self) -> list[dict]:
|
|
310
|
+
data = await self.request("GET", "/v1/keys")
|
|
311
|
+
return data.get("data", []) if isinstance(data, dict) else data
|
|
312
|
+
|
|
313
|
+
async def create_api_key(self, name: str = "default") -> dict:
|
|
314
|
+
return await self.request("POST", "/v1/keys", json={"name": name})
|
|
315
|
+
|
|
316
|
+
async def delete_api_key(self, key_id: str) -> None:
|
|
317
|
+
await self.request("DELETE", f"/v1/keys/{key_id}")
|
|
318
|
+
|
|
299
319
|
# ------------------------------- lifecycle -------------------------------
|
|
300
320
|
async def aclose(self) -> None:
|
|
301
321
|
if self._owns_client:
|
|
@@ -317,14 +337,30 @@ class AsyncSandbox:
|
|
|
317
337
|
def id(self) -> str:
|
|
318
338
|
return self._data["id"]
|
|
319
339
|
|
|
340
|
+
@property
|
|
341
|
+
def name(self) -> Optional[str]:
|
|
342
|
+
return self._data.get("name")
|
|
343
|
+
|
|
320
344
|
@property
|
|
321
345
|
def status(self) -> str:
|
|
322
346
|
return self._data.get("status", "")
|
|
323
347
|
|
|
348
|
+
@property
|
|
349
|
+
def template(self) -> str:
|
|
350
|
+
return self._data.get("template", "")
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def network_mode(self) -> str:
|
|
354
|
+
return self._data.get("network_mode", "bridge")
|
|
355
|
+
|
|
324
356
|
@property
|
|
325
357
|
def env(self) -> dict:
|
|
326
358
|
return dict(self._data.get("env") or {})
|
|
327
359
|
|
|
360
|
+
@property
|
|
361
|
+
def startup_command(self) -> Optional[str]:
|
|
362
|
+
return self._data.get("startup_command")
|
|
363
|
+
|
|
328
364
|
@property
|
|
329
365
|
def capabilities(self) -> list[str]:
|
|
330
366
|
return list(self._data.get("capabilities") or [])
|
|
@@ -334,6 +370,14 @@ class AsyncSandbox:
|
|
|
334
370
|
model = self._data.get("model")
|
|
335
371
|
return dict(model) if isinstance(model, dict) else None
|
|
336
372
|
|
|
373
|
+
@property
|
|
374
|
+
def data(self) -> dict:
|
|
375
|
+
return dict(self._data)
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def git(self) -> "AsyncSandboxGit":
|
|
379
|
+
return AsyncSandboxGit(self)
|
|
380
|
+
|
|
337
381
|
@property
|
|
338
382
|
def browser(self) -> "AsyncSandboxBrowser":
|
|
339
383
|
"""CDP-first browser helpers for browser-capable sandboxes."""
|
|
@@ -346,6 +390,13 @@ class AsyncSandbox:
|
|
|
346
390
|
self._data = await self._client.request("GET", f"/v1/sandboxes/{self.id}")
|
|
347
391
|
return self
|
|
348
392
|
|
|
393
|
+
async def set_env(self, env: dict[str, str]) -> "AsyncSandbox":
|
|
394
|
+
"""Replace defaults used by future commands, terminals, and startup launches."""
|
|
395
|
+
self._data = await self._client.request(
|
|
396
|
+
"PUT", f"/v1/sandboxes/{self.id}/env", json={"env": env}
|
|
397
|
+
)
|
|
398
|
+
return self
|
|
399
|
+
|
|
349
400
|
async def stop(self) -> "AsyncSandbox":
|
|
350
401
|
self._data = await self._client.request("POST", f"/v1/sandboxes/{self.id}/stop")
|
|
351
402
|
return self
|
|
@@ -449,12 +500,21 @@ class AsyncSandbox:
|
|
|
449
500
|
json={"path": path, "content_base64": base64.b64encode(raw).decode()},
|
|
450
501
|
)
|
|
451
502
|
|
|
503
|
+
async def upload_file(self, local_path: str, remote_path: Optional[str] = None) -> None:
|
|
504
|
+
destination = remote_path or f"{WORKSPACE}/{os.path.basename(local_path)}"
|
|
505
|
+
content = await asyncio.to_thread(Path(local_path).read_bytes)
|
|
506
|
+
await self.upload(destination, content)
|
|
507
|
+
|
|
452
508
|
async def download(self, path: str) -> bytes:
|
|
453
509
|
data = await self._client.request(
|
|
454
510
|
"GET", f"/v1/sandboxes/{self.id}/files", params={"path": path}
|
|
455
511
|
)
|
|
456
512
|
return base64.b64decode(data["content_base64"])
|
|
457
513
|
|
|
514
|
+
async def download_file(self, remote_path: str, local_path: str) -> None:
|
|
515
|
+
content = await self.download(remote_path)
|
|
516
|
+
await asyncio.to_thread(Path(local_path).write_bytes, content)
|
|
517
|
+
|
|
458
518
|
async def list_files(self, path: str = WORKSPACE) -> list[FileEntry]:
|
|
459
519
|
data = await self._client.request(
|
|
460
520
|
"GET", f"/v1/sandboxes/{self.id}/files/list", params={"path": path}
|
|
@@ -478,6 +538,16 @@ class AsyncSandbox:
|
|
|
478
538
|
async def exists(self, path: str) -> bool:
|
|
479
539
|
return (await self.run(f"test -e {shlex.quote(path)}")).exit_code == 0
|
|
480
540
|
|
|
541
|
+
async def find(
|
|
542
|
+
self, path: str = WORKSPACE, *, name: Optional[str] = None, max_results: int = 1000
|
|
543
|
+
) -> list[str]:
|
|
544
|
+
cmd = f"find {shlex.quote(path)} -type f"
|
|
545
|
+
if name:
|
|
546
|
+
cmd += f" -name {shlex.quote(name)}"
|
|
547
|
+
cmd += f" 2>/dev/null | head -n {int(max_results)}"
|
|
548
|
+
out = await self.run(cmd)
|
|
549
|
+
return [line for line in out.stdout.splitlines() if line.strip()]
|
|
550
|
+
|
|
481
551
|
# startup
|
|
482
552
|
async def startup(self) -> StartupStatus:
|
|
483
553
|
return StartupStatus.from_dict(
|
|
@@ -504,6 +574,17 @@ class AsyncSandbox:
|
|
|
504
574
|
)
|
|
505
575
|
)
|
|
506
576
|
|
|
577
|
+
async def get_preview_link(self) -> PreviewLink:
|
|
578
|
+
return PreviewLink.from_dict(
|
|
579
|
+
await self._client.request("GET", f"/v1/sandboxes/{self.id}/preview-link")
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
async def disable_preview_link(self) -> None:
|
|
583
|
+
await self._client.request("DELETE", f"/v1/sandboxes/{self.id}/preview-link")
|
|
584
|
+
|
|
585
|
+
def preview_url(self, port: int, path: str = "") -> str:
|
|
586
|
+
return f"{self._client.base_url}/v1/sandboxes/{self.id}/preview/{port}/{path.lstrip('/')}"
|
|
587
|
+
|
|
507
588
|
# sessions
|
|
508
589
|
async def create_session(self) -> "AsyncSession":
|
|
509
590
|
data = await self._client.request("POST", f"/v1/sandboxes/{self.id}/sessions")
|
|
@@ -514,6 +595,59 @@ class AsyncSandbox:
|
|
|
514
595
|
return [AsyncSession(self._client, d) for d in data.get("data", [])]
|
|
515
596
|
|
|
516
597
|
|
|
598
|
+
class AsyncSandboxGit:
|
|
599
|
+
"""Async git convenience operations run inside a sandbox."""
|
|
600
|
+
|
|
601
|
+
def __init__(self, sandbox: AsyncSandbox) -> None:
|
|
602
|
+
self._sb = sandbox
|
|
603
|
+
|
|
604
|
+
async def clone(
|
|
605
|
+
self, url: str, path: Optional[str] = None, *, timeout: int = 300
|
|
606
|
+
) -> Command:
|
|
607
|
+
cmd = f"git clone {shlex.quote(url)}"
|
|
608
|
+
if path:
|
|
609
|
+
cmd += f" {shlex.quote(path)}"
|
|
610
|
+
return await self._sb.run(cmd, timeout=timeout)
|
|
611
|
+
|
|
612
|
+
async def status(self, path: str = WORKSPACE) -> Command:
|
|
613
|
+
return await self._sb.run(f"cd {shlex.quote(path)} && git status --short --branch")
|
|
614
|
+
|
|
615
|
+
async def branches(self, path: str = WORKSPACE) -> list[str]:
|
|
616
|
+
out = await self._sb.run(
|
|
617
|
+
f"cd {shlex.quote(path)} && git branch --format='%(refname:short)'"
|
|
618
|
+
)
|
|
619
|
+
return [line.strip() for line in out.stdout.splitlines() if line.strip()]
|
|
620
|
+
|
|
621
|
+
async def checkout(
|
|
622
|
+
self, ref: str, path: str = WORKSPACE, *, create: bool = False
|
|
623
|
+
) -> Command:
|
|
624
|
+
flag = "-b " if create else ""
|
|
625
|
+
return await self._sb.run(
|
|
626
|
+
f"cd {shlex.quote(path)} && git checkout {flag}{shlex.quote(ref)}"
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
async def add(self, path: str = WORKSPACE, pathspec: str = ".") -> Command:
|
|
630
|
+
return await self._sb.run(
|
|
631
|
+
f"cd {shlex.quote(path)} && git add {shlex.quote(pathspec)}"
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
async def commit(self, message: str, path: str = WORKSPACE) -> Command:
|
|
635
|
+
return await self._sb.run(
|
|
636
|
+
f"cd {shlex.quote(path)} && git commit -m {shlex.quote(message)}"
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
async def push(
|
|
640
|
+
self, path: str = WORKSPACE, *, remote: str = "origin", branch: str = ""
|
|
641
|
+
) -> Command:
|
|
642
|
+
target = f" {shlex.quote(remote)} {shlex.quote(branch)}" if branch else ""
|
|
643
|
+
return await self._sb.run(
|
|
644
|
+
f"cd {shlex.quote(path)} && git push{target}", timeout=300
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
async def pull(self, path: str = WORKSPACE, *, timeout: int = 300) -> Command:
|
|
648
|
+
return await self._sb.run(f"cd {shlex.quote(path)} && git pull", timeout=timeout)
|
|
649
|
+
|
|
650
|
+
|
|
517
651
|
class AsyncSandboxBrowser:
|
|
518
652
|
"""Async CDP-first helpers for the canonical browser process inside a sandbox."""
|
|
519
653
|
|
|
@@ -589,6 +723,10 @@ class AsyncCommandStream:
|
|
|
589
723
|
if error is not None:
|
|
590
724
|
raise FlaxError(f"streamed command failed: {error}")
|
|
591
725
|
|
|
726
|
+
async def read(self) -> str:
|
|
727
|
+
"""Consume the whole stream and return the joined output."""
|
|
728
|
+
return "".join([chunk async for chunk in self])
|
|
729
|
+
|
|
592
730
|
|
|
593
731
|
class AsyncSession:
|
|
594
732
|
"""Async stateful session; cwd/exported env persist across `run()` calls."""
|
|
@@ -601,6 +739,10 @@ class AsyncSession:
|
|
|
601
739
|
def id(self) -> str:
|
|
602
740
|
return self._data["id"]
|
|
603
741
|
|
|
742
|
+
@property
|
|
743
|
+
def sandbox_id(self) -> str:
|
|
744
|
+
return self._data["sandbox_id"]
|
|
745
|
+
|
|
604
746
|
@property
|
|
605
747
|
def cwd(self) -> str:
|
|
606
748
|
return self._data.get("cwd", WORKSPACE)
|
|
@@ -325,6 +325,30 @@ def cmd_template(args: argparse.Namespace) -> int:
|
|
|
325
325
|
return 0
|
|
326
326
|
|
|
327
327
|
|
|
328
|
+
def cmd_snapshot(args: argparse.Namespace) -> int:
|
|
329
|
+
client = _client(args)
|
|
330
|
+
if args.snapshot_cmd == "create":
|
|
331
|
+
snap = client.get_sandbox(args.sandbox_id).create_snapshot(args.name)
|
|
332
|
+
print(snap.id)
|
|
333
|
+
if not args.quiet:
|
|
334
|
+
print(f"name={snap.name} size={snap.size_bytes} template={snap.template}", file=sys.stderr)
|
|
335
|
+
return 0
|
|
336
|
+
if args.snapshot_cmd == "ls":
|
|
337
|
+
snapshots = client.list_snapshots()
|
|
338
|
+
if not snapshots:
|
|
339
|
+
print("No snapshots.")
|
|
340
|
+
return 0
|
|
341
|
+
rows = [[s.name, s.id, s.template, str(s.memory_mb), str(s.size_bytes)] for s in snapshots]
|
|
342
|
+
_print_rows(["NAME", "ID", "TEMPLATE", "MEMORY_MB", "SIZE_BYTES"], rows)
|
|
343
|
+
return 0
|
|
344
|
+
if args.snapshot_cmd == "rm":
|
|
345
|
+
for sid in args.ids:
|
|
346
|
+
client.delete_snapshot(sid)
|
|
347
|
+
print(f"deleted {sid}")
|
|
348
|
+
return 0
|
|
349
|
+
return 0
|
|
350
|
+
|
|
351
|
+
|
|
328
352
|
def cmd_preview(args: argparse.Namespace) -> int:
|
|
329
353
|
link = _client(args).get_sandbox(args.id).create_preview_link(args.port)
|
|
330
354
|
if link.url:
|
|
@@ -434,6 +458,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
434
458
|
trm.add_argument("id")
|
|
435
459
|
trm.set_defaults(func=cmd_template)
|
|
436
460
|
|
|
461
|
+
sn = sub.add_parser("snapshot", help="manage filesystem snapshots")
|
|
462
|
+
sn_sub = sn.add_subparsers(dest="snapshot_cmd", required=True)
|
|
463
|
+
snc = sn_sub.add_parser("create", help="create a snapshot from a sandbox")
|
|
464
|
+
snc.add_argument("sandbox_id")
|
|
465
|
+
snc.add_argument("name", nargs="?", help="snapshot name")
|
|
466
|
+
snc.add_argument("-q", "--quiet", action="store_true", help="print only the snapshot id")
|
|
467
|
+
snc.set_defaults(func=cmd_snapshot)
|
|
468
|
+
sn_sub.add_parser("ls", help="list snapshots").set_defaults(func=cmd_snapshot)
|
|
469
|
+
snrm = sn_sub.add_parser("rm", help="delete snapshot(s)")
|
|
470
|
+
snrm.add_argument("ids", nargs="+")
|
|
471
|
+
snrm.set_defaults(func=cmd_snapshot)
|
|
472
|
+
|
|
437
473
|
lg = sub.add_parser("logs", help="show a command's output/status")
|
|
438
474
|
lg.add_argument("id")
|
|
439
475
|
lg.add_argument("command_id")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -84,6 +84,13 @@ class Sandbox:
|
|
|
84
84
|
self._data = self._client.request("GET", f"/v1/sandboxes/{self.id}")
|
|
85
85
|
return self
|
|
86
86
|
|
|
87
|
+
def set_env(self, env: dict[str, str]) -> "Sandbox":
|
|
88
|
+
"""Replace defaults used by future commands, terminals, and startup launches."""
|
|
89
|
+
self._data = self._client.request(
|
|
90
|
+
"PUT", f"/v1/sandboxes/{self.id}/env", json={"env": env}
|
|
91
|
+
)
|
|
92
|
+
return self
|
|
93
|
+
|
|
87
94
|
def stop(self) -> "Sandbox":
|
|
88
95
|
self._data = self._client.request("POST", f"/v1/sandboxes/{self.id}/stop")
|
|
89
96
|
return self
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|