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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flaxcloud
3
- Version: 0.5.0
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
  ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "flaxcloud"
7
- version = "0.5.0"
7
+ version = "0.5.2"
8
8
  description = "Python SDK and CLI for Flax Cloud: isolated cloud sandboxes."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -23,15 +23,30 @@ from .errors import (
23
23
  FlaxQuotaError,
24
24
  FlaxServerError,
25
25
  )
26
- from .models import Command, FileEntry, PreviewLink, SessionResult, Snapshot, StartupStatus, Template
27
- from .sandbox import Sandbox, SandboxBrowser, Session
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.0"
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 ("AsyncFlaxClient", "AsyncSandbox", "AsyncSandboxBrowser"):
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 Command, FileEntry, PreviewLink, SessionResult, Snapshot, StartupStatus, Template
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