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/cli.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""`flax` — the Flax Cloud command-line interface, built on the Python SDK.
|
|
2
|
+
|
|
3
|
+
Run `flax --help` for the full command list. Credentials resolve in this order: `--key` flag,
|
|
4
|
+
`$FLAX_API_KEY`, then `~/.config/flax/config.json` (written by `flax login`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .client import DEFAULT_BASE_URL, FlaxClient
|
|
17
|
+
from .errors import FlaxError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ------------------------------- config storage -------------------------------
|
|
21
|
+
def _config_path() -> str:
|
|
22
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
|
23
|
+
return os.path.join(base, "flax", "config.json")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_config() -> dict:
|
|
27
|
+
try:
|
|
28
|
+
with open(_config_path(), "r", encoding="utf-8") as fh:
|
|
29
|
+
return json.load(fh)
|
|
30
|
+
except (OSError, ValueError):
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _save_config(cfg: dict) -> None:
|
|
35
|
+
path = _config_path()
|
|
36
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
37
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
38
|
+
json.dump(cfg, fh, indent=2)
|
|
39
|
+
try:
|
|
40
|
+
os.chmod(path, 0o600) # the key is a secret
|
|
41
|
+
except OSError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_key(args: argparse.Namespace) -> Optional[str]:
|
|
46
|
+
return getattr(args, "key", None) or os.environ.get("FLAX_API_KEY") or _load_config().get("api_key")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_base_url(args: argparse.Namespace) -> str:
|
|
50
|
+
return (
|
|
51
|
+
getattr(args, "url", None)
|
|
52
|
+
or os.environ.get("FLAX_BASE_URL")
|
|
53
|
+
or _load_config().get("base_url")
|
|
54
|
+
or DEFAULT_BASE_URL
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _client(args: argparse.Namespace) -> FlaxClient:
|
|
59
|
+
key = _resolve_key(args)
|
|
60
|
+
if not key:
|
|
61
|
+
_die("not logged in. Run `flax login` or set FLAX_API_KEY.")
|
|
62
|
+
return FlaxClient(api_key=key, base_url=_resolve_base_url(args))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _die(message: str, code: int = 1) -> "None":
|
|
66
|
+
print(f"error: {message}", file=sys.stderr)
|
|
67
|
+
raise SystemExit(code)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _print_rows(headers: list[str], rows: list[list[str]]) -> None:
|
|
71
|
+
widths = [len(h) for h in headers]
|
|
72
|
+
for row in rows:
|
|
73
|
+
for i, cell in enumerate(row):
|
|
74
|
+
widths[i] = max(widths[i], len(str(cell)))
|
|
75
|
+
fmt = " ".join(f"{{:<{w}}}" for w in widths)
|
|
76
|
+
print(fmt.format(*headers))
|
|
77
|
+
for row in rows:
|
|
78
|
+
print(fmt.format(*[str(c) for c in row]))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ----------------------------------- commands ---------------------------------
|
|
82
|
+
def cmd_login(args: argparse.Namespace) -> int:
|
|
83
|
+
key = args.key or os.environ.get("FLAX_API_KEY")
|
|
84
|
+
if not key:
|
|
85
|
+
try:
|
|
86
|
+
import getpass
|
|
87
|
+
|
|
88
|
+
key = getpass.getpass("Flax API key (flax_live_...): ").strip()
|
|
89
|
+
except (EOFError, KeyboardInterrupt):
|
|
90
|
+
print()
|
|
91
|
+
return 1
|
|
92
|
+
if not key:
|
|
93
|
+
_die("no API key provided")
|
|
94
|
+
base_url = args.url or _load_config().get("base_url") or DEFAULT_BASE_URL
|
|
95
|
+
# Validate the key before saving.
|
|
96
|
+
try:
|
|
97
|
+
me = FlaxClient(api_key=key, base_url=base_url).me()
|
|
98
|
+
except FlaxError as exc:
|
|
99
|
+
_die(f"login failed: {exc}")
|
|
100
|
+
_save_config({"api_key": key, "base_url": base_url})
|
|
101
|
+
print(f"Logged in as {me.get('email', 'unknown')} ({me.get('plan', '?')} plan).")
|
|
102
|
+
print(f"Saved credentials to {_config_path()}")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_whoami(args: argparse.Namespace) -> int:
|
|
107
|
+
me = _client(args).me()
|
|
108
|
+
print(f"{me.get('email')} · plan={me.get('plan')} · id={me.get('id')}")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_sandbox_create(args: argparse.Namespace) -> int:
|
|
113
|
+
sb = _client(args).create_sandbox(
|
|
114
|
+
template=args.template,
|
|
115
|
+
memory_mb=args.memory,
|
|
116
|
+
network_mode=args.network,
|
|
117
|
+
startup_command=args.startup,
|
|
118
|
+
)
|
|
119
|
+
print(sb.id)
|
|
120
|
+
if not args.quiet:
|
|
121
|
+
print(f"status={sb.status} template={sb.template}", file=sys.stderr)
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_sandbox_ls(args: argparse.Namespace) -> int:
|
|
126
|
+
sandboxes = _client(args).list_sandboxes()
|
|
127
|
+
if not sandboxes:
|
|
128
|
+
print("No sandboxes.")
|
|
129
|
+
return 0
|
|
130
|
+
rows = [[s.id, s.status, s.template, s.network_mode] for s in sandboxes]
|
|
131
|
+
_print_rows(["ID", "STATUS", "TEMPLATE", "NETWORK"], rows)
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_sandbox_rm(args: argparse.Namespace) -> int:
|
|
136
|
+
client = _client(args)
|
|
137
|
+
for sid in args.ids:
|
|
138
|
+
client.get_sandbox(sid).destroy()
|
|
139
|
+
print(f"destroyed {sid}")
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_sandbox_stop(args: argparse.Namespace) -> int:
|
|
144
|
+
sb = _client(args).get_sandbox(args.id).stop()
|
|
145
|
+
print(f"{sb.id} -> {sb.status}")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_sandbox_resume(args: argparse.Namespace) -> int:
|
|
150
|
+
sb = _client(args).get_sandbox(args.id).resume()
|
|
151
|
+
print(f"{sb.id} -> {sb.status}")
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
156
|
+
sb = _client(args).get_sandbox(args.id)
|
|
157
|
+
command = " ".join(args.command)
|
|
158
|
+
if args.stream:
|
|
159
|
+
stream = sb.run_stream(command, timeout=args.timeout)
|
|
160
|
+
for chunk in stream:
|
|
161
|
+
sys.stdout.write(chunk)
|
|
162
|
+
sys.stdout.flush()
|
|
163
|
+
return 0 if (stream.exit_code or 0) == 0 else (stream.exit_code or 1)
|
|
164
|
+
cmd = sb.run(command, timeout=args.timeout, background=args.background)
|
|
165
|
+
if args.background:
|
|
166
|
+
print(cmd.id)
|
|
167
|
+
print(f"running in background; poll with `flax logs {args.id} {cmd.id}`", file=sys.stderr)
|
|
168
|
+
return 0
|
|
169
|
+
if cmd.stdout:
|
|
170
|
+
sys.stdout.write(cmd.stdout)
|
|
171
|
+
if cmd.stderr:
|
|
172
|
+
sys.stderr.write(cmd.stderr)
|
|
173
|
+
return 0 if cmd.ok else (cmd.exit_code or 1)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_logs(args: argparse.Namespace) -> int:
|
|
177
|
+
sb = _client(args).get_sandbox(args.id)
|
|
178
|
+
cmd = sb.get_command(args.command_id)
|
|
179
|
+
if cmd.stdout:
|
|
180
|
+
sys.stdout.write(cmd.stdout)
|
|
181
|
+
if cmd.stderr:
|
|
182
|
+
sys.stderr.write(cmd.stderr)
|
|
183
|
+
print(f"[{cmd.status}{'' if cmd.exit_code is None else f', exit {cmd.exit_code}'}]", file=sys.stderr)
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_ls(args: argparse.Namespace) -> int:
|
|
188
|
+
entries = _client(args).get_sandbox(args.id).list_files(args.path)
|
|
189
|
+
if not entries:
|
|
190
|
+
print("(empty)")
|
|
191
|
+
return 0
|
|
192
|
+
for e in sorted(entries, key=lambda x: (not x.is_dir, x.name)):
|
|
193
|
+
suffix = "/" if e.is_dir else ""
|
|
194
|
+
size = "" if e.is_dir else f" {e.size_bytes}"
|
|
195
|
+
print(f"{e.name}{suffix}{size}")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _parse_remote(spec: str) -> Optional[tuple]:
|
|
200
|
+
# "sbx_xxx:/path" -> (id, path); returns None for a local path.
|
|
201
|
+
if ":" in spec:
|
|
202
|
+
head, _, tail = spec.partition(":")
|
|
203
|
+
if head.startswith("sbx_"):
|
|
204
|
+
return head, tail or "/workspace"
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def cmd_cp(args: argparse.Namespace) -> int:
|
|
209
|
+
client = _client(args)
|
|
210
|
+
src_remote = _parse_remote(args.src)
|
|
211
|
+
dst_remote = _parse_remote(args.dst)
|
|
212
|
+
if src_remote and not dst_remote: # download
|
|
213
|
+
sid, remote = src_remote
|
|
214
|
+
client.get_sandbox(sid).download_file(remote, args.dst)
|
|
215
|
+
print(f"downloaded {sid}:{remote} -> {args.dst}")
|
|
216
|
+
elif dst_remote and not src_remote: # upload
|
|
217
|
+
sid, remote = dst_remote
|
|
218
|
+
client.get_sandbox(sid).upload_file(args.src, remote)
|
|
219
|
+
print(f"uploaded {args.src} -> {sid}:{remote}")
|
|
220
|
+
else:
|
|
221
|
+
_die("cp needs exactly one remote side, e.g. `flax cp ./a.txt sbx_x:/workspace/a.txt`")
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def cmd_startup(args: argparse.Namespace) -> int:
|
|
226
|
+
sb = _client(args).get_sandbox(args.id)
|
|
227
|
+
if args.set is not None:
|
|
228
|
+
st = sb.set_startup(args.set)
|
|
229
|
+
print(f"startup command {'set' if args.set else 'cleared'}")
|
|
230
|
+
if args.run:
|
|
231
|
+
st = sb.run_startup()
|
|
232
|
+
print("startup launched")
|
|
233
|
+
if args.set is None and not args.run:
|
|
234
|
+
st = sb.startup()
|
|
235
|
+
print(f"configured={st.configured} status={st.status} source={st.source}")
|
|
236
|
+
if st.effective_command:
|
|
237
|
+
print(f"command: {st.effective_command}")
|
|
238
|
+
if args.show_logs and st.logs:
|
|
239
|
+
print("--- logs ---")
|
|
240
|
+
print(st.logs)
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def cmd_session(args: argparse.Namespace) -> int:
|
|
245
|
+
client = _client(args)
|
|
246
|
+
if args.session_cmd == "create":
|
|
247
|
+
s = client.get_sandbox(args.id).create_session()
|
|
248
|
+
print(s.id)
|
|
249
|
+
return 0
|
|
250
|
+
if args.session_cmd == "ls":
|
|
251
|
+
sessions = client.get_sandbox(args.id).sessions()
|
|
252
|
+
if not sessions:
|
|
253
|
+
print("No sessions.")
|
|
254
|
+
return 0
|
|
255
|
+
_print_rows(["ID", "CWD"], [[s.id, s.cwd] for s in sessions])
|
|
256
|
+
return 0
|
|
257
|
+
if args.session_cmd == "rm":
|
|
258
|
+
from .sandbox import Session
|
|
259
|
+
|
|
260
|
+
Session(client, {"id": args.session_id, "sandbox_id": "", "cwd": "/workspace"}).delete()
|
|
261
|
+
print(f"deleted {args.session_id}")
|
|
262
|
+
return 0
|
|
263
|
+
if args.session_cmd == "exec":
|
|
264
|
+
from .sandbox import Session
|
|
265
|
+
|
|
266
|
+
s = Session(client, {"id": args.session_id, "sandbox_id": "", "cwd": "/workspace"})
|
|
267
|
+
res = s.run(" ".join(args.command), timeout=args.timeout)
|
|
268
|
+
if res.stdout:
|
|
269
|
+
sys.stdout.write(res.stdout)
|
|
270
|
+
if res.stderr:
|
|
271
|
+
sys.stderr.write(res.stderr)
|
|
272
|
+
print(f"[cwd={res.cwd} exit={res.exit_code}]", file=sys.stderr)
|
|
273
|
+
return 0 if res.ok else (res.exit_code or 1)
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def cmd_preview(args: argparse.Namespace) -> int:
|
|
278
|
+
link = _client(args).get_sandbox(args.id).create_preview_link(args.port)
|
|
279
|
+
if link.url:
|
|
280
|
+
print(link.url)
|
|
281
|
+
else:
|
|
282
|
+
print(f"preview link active on port {link.port} (URL shown only at creation)")
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ------------------------------------ parser ----------------------------------
|
|
287
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
288
|
+
p = argparse.ArgumentParser(prog="flax", description="Flax Cloud CLI — isolated cloud sandboxes.")
|
|
289
|
+
p.add_argument("--version", action="version", version=f"flax {__version__}")
|
|
290
|
+
p.add_argument("--key", help="API key (overrides env/config)")
|
|
291
|
+
p.add_argument("--url", help="API base URL (default: https://flaxcloud.com)")
|
|
292
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
293
|
+
|
|
294
|
+
sp = sub.add_parser("login", help="save and verify your API key")
|
|
295
|
+
sp.set_defaults(func=cmd_login)
|
|
296
|
+
|
|
297
|
+
sub.add_parser("whoami", help="show the authenticated account").set_defaults(func=cmd_whoami)
|
|
298
|
+
|
|
299
|
+
sbx = sub.add_parser("sandbox", help="manage sandboxes")
|
|
300
|
+
sbx_sub = sbx.add_subparsers(dest="sandbox_cmd", required=True)
|
|
301
|
+
c = sbx_sub.add_parser("create", help="create a sandbox")
|
|
302
|
+
c.add_argument("--template", default="blank", help="blank | python | node")
|
|
303
|
+
c.add_argument("--memory", type=int, help="memory limit (MB)")
|
|
304
|
+
c.add_argument("--network", choices=["bridge", "none"], help="network mode")
|
|
305
|
+
c.add_argument("--startup", help="startup command (auto-run on create/resume)")
|
|
306
|
+
c.add_argument("-q", "--quiet", action="store_true", help="print only the sandbox id")
|
|
307
|
+
c.set_defaults(func=cmd_sandbox_create)
|
|
308
|
+
sbx_sub.add_parser("ls", help="list sandboxes").set_defaults(func=cmd_sandbox_ls)
|
|
309
|
+
rm = sbx_sub.add_parser("rm", help="destroy sandbox(es)")
|
|
310
|
+
rm.add_argument("ids", nargs="+")
|
|
311
|
+
rm.set_defaults(func=cmd_sandbox_rm)
|
|
312
|
+
st = sbx_sub.add_parser("stop", help="stop a sandbox")
|
|
313
|
+
st.add_argument("id")
|
|
314
|
+
st.set_defaults(func=cmd_sandbox_stop)
|
|
315
|
+
rs = sbx_sub.add_parser("resume", help="resume a sandbox")
|
|
316
|
+
rs.add_argument("id")
|
|
317
|
+
rs.set_defaults(func=cmd_sandbox_resume)
|
|
318
|
+
|
|
319
|
+
run = sub.add_parser("run", help="run a command in a sandbox")
|
|
320
|
+
run.add_argument("id")
|
|
321
|
+
run.add_argument("command", nargs=argparse.REMAINDER, help="the command to run")
|
|
322
|
+
run.add_argument("--timeout", type=int, help="command timeout (seconds)")
|
|
323
|
+
run.add_argument("--background", "--async", action="store_true", dest="background")
|
|
324
|
+
run.add_argument("--stream", action="store_true", help="stream output live as it runs")
|
|
325
|
+
run.set_defaults(func=cmd_run)
|
|
326
|
+
|
|
327
|
+
se = sub.add_parser("session", help="stateful sessions (cwd/env persist across commands)")
|
|
328
|
+
se_sub = se.add_subparsers(dest="session_cmd", required=True)
|
|
329
|
+
sc = se_sub.add_parser("create", help="create a session in a sandbox")
|
|
330
|
+
sc.add_argument("id")
|
|
331
|
+
sc.set_defaults(func=cmd_session)
|
|
332
|
+
sls = se_sub.add_parser("ls", help="list a sandbox's sessions")
|
|
333
|
+
sls.add_argument("id")
|
|
334
|
+
sls.set_defaults(func=cmd_session)
|
|
335
|
+
sex = se_sub.add_parser("exec", help="run a command in a session")
|
|
336
|
+
sex.add_argument("session_id")
|
|
337
|
+
sex.add_argument("command", nargs=argparse.REMAINDER, help="the command to run")
|
|
338
|
+
sex.add_argument("--timeout", type=int, help="command timeout (seconds)")
|
|
339
|
+
sex.set_defaults(func=cmd_session)
|
|
340
|
+
srm = se_sub.add_parser("rm", help="delete a session")
|
|
341
|
+
srm.add_argument("session_id")
|
|
342
|
+
srm.set_defaults(func=cmd_session)
|
|
343
|
+
|
|
344
|
+
lg = sub.add_parser("logs", help="show a command's output/status")
|
|
345
|
+
lg.add_argument("id")
|
|
346
|
+
lg.add_argument("command_id")
|
|
347
|
+
lg.set_defaults(func=cmd_logs)
|
|
348
|
+
|
|
349
|
+
ls = sub.add_parser("ls", help="list files in a sandbox")
|
|
350
|
+
ls.add_argument("id")
|
|
351
|
+
ls.add_argument("path", nargs="?", default="/workspace")
|
|
352
|
+
ls.set_defaults(func=cmd_ls)
|
|
353
|
+
|
|
354
|
+
cp = sub.add_parser("cp", help="copy files to/from a sandbox (use sbx_id:/path)")
|
|
355
|
+
cp.add_argument("src")
|
|
356
|
+
cp.add_argument("dst")
|
|
357
|
+
cp.set_defaults(func=cmd_cp)
|
|
358
|
+
|
|
359
|
+
su = sub.add_parser("startup", help="view/set/run a sandbox's startup command")
|
|
360
|
+
su.add_argument("id")
|
|
361
|
+
su.add_argument("--set", help="set the startup command (empty string clears it)")
|
|
362
|
+
su.add_argument("--run", action="store_true", help="launch the startup command now")
|
|
363
|
+
su.add_argument("--show-logs", action="store_true", help="print the startup log tail")
|
|
364
|
+
su.set_defaults(func=cmd_startup)
|
|
365
|
+
|
|
366
|
+
pv = sub.add_parser("preview", help="create/print a shareable preview link")
|
|
367
|
+
pv.add_argument("id")
|
|
368
|
+
pv.add_argument("port", type=int)
|
|
369
|
+
pv.set_defaults(func=cmd_preview)
|
|
370
|
+
|
|
371
|
+
return p
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def main(argv: Optional[list] = None) -> int:
|
|
375
|
+
args = build_parser().parse_args(argv)
|
|
376
|
+
try:
|
|
377
|
+
return args.func(args)
|
|
378
|
+
except FlaxError as exc:
|
|
379
|
+
_die(str(exc))
|
|
380
|
+
except BrokenPipeError: # e.g. piping into `head`
|
|
381
|
+
return 0
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
if __name__ == "__main__": # pragma: no cover
|
|
385
|
+
raise SystemExit(main())
|
flaxcloud/client.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The Flax Cloud API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .errors import FlaxAuthError, FlaxConnectionError, FlaxError, error_from_response
|
|
13
|
+
from .sandbox import Sandbox
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://flaxcloud.com"
|
|
16
|
+
|
|
17
|
+
# Methods safe to retry after a server/network error without risking duplicate side effects.
|
|
18
|
+
_IDEMPOTENT = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
|
|
19
|
+
# Transient server statuses worth retrying.
|
|
20
|
+
_RETRY_STATUSES = frozenset({429, 502, 503, 504})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _user_agent() -> str:
|
|
24
|
+
from . import __version__
|
|
25
|
+
|
|
26
|
+
return f"flaxcloud-python/{__version__} httpx/{httpx.__version__}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FlaxClient:
|
|
30
|
+
"""Entry point for talking to Flax Cloud.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from flaxcloud import FlaxClient
|
|
34
|
+
|
|
35
|
+
flax = FlaxClient() # reads FLAX_API_KEY from the environment
|
|
36
|
+
sb = flax.create_sandbox(template="python")
|
|
37
|
+
print(sb.run("python3 -c 'print(6*7)'").stdout)
|
|
38
|
+
sb.destroy()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The API key is read from `api_key`, else `$FLAX_API_KEY`. `base_url` defaults to
|
|
42
|
+
`$FLAX_BASE_URL` or https://flaxcloud.com.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
api_key: Optional[str] = None,
|
|
48
|
+
*,
|
|
49
|
+
base_url: Optional[str] = None,
|
|
50
|
+
timeout: float = 60.0,
|
|
51
|
+
max_retries: int = 2,
|
|
52
|
+
http_client: Optional[httpx.Client] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._api_key = api_key or os.environ.get("FLAX_API_KEY")
|
|
55
|
+
if not self._api_key:
|
|
56
|
+
raise FlaxAuthError(
|
|
57
|
+
"no API key: pass api_key=... or set the FLAX_API_KEY environment variable"
|
|
58
|
+
)
|
|
59
|
+
base_url = base_url or os.environ.get("FLAX_BASE_URL") or DEFAULT_BASE_URL
|
|
60
|
+
self._max_retries = max(0, int(max_retries))
|
|
61
|
+
# An injected client (e.g. an ASGI transport in tests) is used as-is; auth is added
|
|
62
|
+
# per request so the caller doesn't have to wire headers.
|
|
63
|
+
self._http = http_client or httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout)
|
|
64
|
+
self._owns_client = http_client is None
|
|
65
|
+
self.base_url = base_url.rstrip("/")
|
|
66
|
+
|
|
67
|
+
# --------------------------- low-level request ---------------------------
|
|
68
|
+
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
69
|
+
"""Send an authenticated request (with retries) and return parsed JSON (or None)."""
|
|
70
|
+
headers = dict(kwargs.pop("headers", {}))
|
|
71
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
72
|
+
headers.setdefault("User-Agent", _user_agent())
|
|
73
|
+
method = method.upper()
|
|
74
|
+
|
|
75
|
+
attempt = 0
|
|
76
|
+
while True:
|
|
77
|
+
try:
|
|
78
|
+
resp = self._http.request(method, path, headers=headers, **kwargs)
|
|
79
|
+
except httpx.ConnectError as exc:
|
|
80
|
+
# The request never reached the server, so retrying is always safe.
|
|
81
|
+
if attempt < self._max_retries:
|
|
82
|
+
self._sleep_backoff(attempt)
|
|
83
|
+
attempt += 1
|
|
84
|
+
continue
|
|
85
|
+
raise FlaxConnectionError(f"could not reach Flax Cloud: {exc}") from exc
|
|
86
|
+
except httpx.HTTPError as exc:
|
|
87
|
+
if method in _IDEMPOTENT and attempt < self._max_retries:
|
|
88
|
+
self._sleep_backoff(attempt)
|
|
89
|
+
attempt += 1
|
|
90
|
+
continue
|
|
91
|
+
raise FlaxConnectionError(f"could not reach Flax Cloud: {exc}") from exc
|
|
92
|
+
|
|
93
|
+
if (
|
|
94
|
+
resp.status_code in _RETRY_STATUSES
|
|
95
|
+
and method in _IDEMPOTENT
|
|
96
|
+
and attempt < self._max_retries
|
|
97
|
+
):
|
|
98
|
+
self._sleep_backoff(attempt, resp.headers.get("Retry-After"))
|
|
99
|
+
attempt += 1
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
return self._parse(resp)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _sleep_backoff(attempt: int, retry_after: Optional[str] = None) -> None:
|
|
106
|
+
if retry_after:
|
|
107
|
+
try:
|
|
108
|
+
time.sleep(min(float(retry_after), 30.0))
|
|
109
|
+
return
|
|
110
|
+
except (TypeError, ValueError):
|
|
111
|
+
pass
|
|
112
|
+
# Exponential backoff with jitter: ~0.25s, 0.5s, 1s, ...
|
|
113
|
+
time.sleep(min(0.25 * (2 ** attempt), 5.0) * (0.5 + random.random()))
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _parse(resp: httpx.Response) -> Any:
|
|
117
|
+
if resp.status_code >= 400:
|
|
118
|
+
code = None
|
|
119
|
+
message = resp.text or resp.reason_phrase
|
|
120
|
+
try:
|
|
121
|
+
payload = resp.json()
|
|
122
|
+
if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
|
|
123
|
+
code = payload["error"].get("code")
|
|
124
|
+
message = payload["error"].get("message", message)
|
|
125
|
+
except ValueError:
|
|
126
|
+
pass
|
|
127
|
+
raise error_from_response(resp.status_code, code, message)
|
|
128
|
+
|
|
129
|
+
if resp.status_code == 204 or not resp.content:
|
|
130
|
+
return None
|
|
131
|
+
return resp.json()
|
|
132
|
+
|
|
133
|
+
# ------------------------------- sandboxes -------------------------------
|
|
134
|
+
def create_sandbox(
|
|
135
|
+
self,
|
|
136
|
+
template: str = "blank",
|
|
137
|
+
*,
|
|
138
|
+
memory_mb: Optional[int] = None,
|
|
139
|
+
timeout_seconds: Optional[int] = None,
|
|
140
|
+
network_mode: Optional[str] = None,
|
|
141
|
+
env: Optional[dict] = None,
|
|
142
|
+
startup_command: Optional[str] = None,
|
|
143
|
+
restore_from: Optional[str] = None,
|
|
144
|
+
metadata: Optional[dict] = None,
|
|
145
|
+
) -> Sandbox:
|
|
146
|
+
body: dict[str, Any] = {"template": template}
|
|
147
|
+
if memory_mb is not None:
|
|
148
|
+
body["memory_mb"] = memory_mb
|
|
149
|
+
if timeout_seconds is not None:
|
|
150
|
+
body["timeout_seconds"] = timeout_seconds
|
|
151
|
+
if network_mode is not None:
|
|
152
|
+
body["network_mode"] = network_mode
|
|
153
|
+
if env is not None:
|
|
154
|
+
body["env"] = env
|
|
155
|
+
if startup_command is not None:
|
|
156
|
+
body["startup_command"] = startup_command
|
|
157
|
+
if restore_from is not None:
|
|
158
|
+
body["restore_from"] = restore_from
|
|
159
|
+
if metadata is not None:
|
|
160
|
+
body["metadata"] = metadata
|
|
161
|
+
data = self.request("POST", "/v1/sandboxes", json=body)
|
|
162
|
+
return Sandbox(self, data)
|
|
163
|
+
|
|
164
|
+
def get_sandbox(self, sandbox_id: str) -> Sandbox:
|
|
165
|
+
data = self.request("GET", f"/v1/sandboxes/{sandbox_id}")
|
|
166
|
+
return Sandbox(self, data)
|
|
167
|
+
|
|
168
|
+
def list_sandboxes(self) -> list[Sandbox]:
|
|
169
|
+
data = self.request("GET", "/v1/sandboxes")
|
|
170
|
+
return [Sandbox(self, d) for d in data.get("data", [])]
|
|
171
|
+
|
|
172
|
+
def me(self) -> dict:
|
|
173
|
+
"""Return the authenticated account (id, email, plan, …)."""
|
|
174
|
+
return self.request("GET", "/v1/me")
|
|
175
|
+
|
|
176
|
+
def usage(self) -> dict:
|
|
177
|
+
"""Return current usage and plan limits for the account."""
|
|
178
|
+
return self.request("GET", "/v1/usage")
|
|
179
|
+
|
|
180
|
+
# ------------------------------- API keys --------------------------------
|
|
181
|
+
def list_api_keys(self) -> list[dict]:
|
|
182
|
+
data = self.request("GET", "/v1/keys")
|
|
183
|
+
return data.get("data", []) if isinstance(data, dict) else data
|
|
184
|
+
|
|
185
|
+
def create_api_key(self, name: str = "default") -> dict:
|
|
186
|
+
"""Create a new API key. The full key is returned once, in the `key` field."""
|
|
187
|
+
return self.request("POST", "/v1/keys", json={"name": name})
|
|
188
|
+
|
|
189
|
+
def delete_api_key(self, key_id: str) -> None:
|
|
190
|
+
self.request("DELETE", f"/v1/keys/{key_id}")
|
|
191
|
+
|
|
192
|
+
# ------------------------------- lifecycle -------------------------------
|
|
193
|
+
def close(self) -> None:
|
|
194
|
+
if self._owns_client:
|
|
195
|
+
self._http.close()
|
|
196
|
+
|
|
197
|
+
def __enter__(self) -> "FlaxClient":
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
def __exit__(self, *exc: object) -> None:
|
|
201
|
+
self.close()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
__all__ = ["FlaxClient", "FlaxError"]
|
flaxcloud/errors.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Typed exceptions for the Flax Cloud SDK.
|
|
2
|
+
|
|
3
|
+
Every API error envelope (`{"error": {"code", "message"}}`) is mapped to a `FlaxError`
|
|
4
|
+
subclass so callers can branch on the failure type instead of parsing strings.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FlaxError(Exception):
|
|
11
|
+
"""Base class for all SDK errors."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, *, code: str | None = None, status: int | None = None):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.message = message
|
|
16
|
+
self.code = code
|
|
17
|
+
self.status = status
|
|
18
|
+
|
|
19
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
20
|
+
parts = [self.message]
|
|
21
|
+
if self.code:
|
|
22
|
+
parts.append(f"(code={self.code})")
|
|
23
|
+
return " ".join(parts)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FlaxAuthError(FlaxError):
|
|
27
|
+
"""Missing/invalid API key or session (HTTP 401)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FlaxNotFoundError(FlaxError):
|
|
31
|
+
"""The requested resource does not exist or isn't yours (HTTP 404)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FlaxQuotaError(FlaxError):
|
|
35
|
+
"""A plan limit/quota was exceeded (HTTP 402)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FlaxConflictError(FlaxError):
|
|
39
|
+
"""The resource is in a state that doesn't allow this action (HTTP 409)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FlaxBadRequestError(FlaxError):
|
|
43
|
+
"""The request was rejected (HTTP 4xx)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FlaxServerError(FlaxError):
|
|
47
|
+
"""The server failed to handle the request (HTTP 5xx)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FlaxConnectionError(FlaxError):
|
|
51
|
+
"""The client could not reach the API at all."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def error_from_response(status: int, code: str | None, message: str) -> FlaxError:
|
|
55
|
+
if status == 401:
|
|
56
|
+
return FlaxAuthError(message, code=code, status=status)
|
|
57
|
+
if status == 402:
|
|
58
|
+
return FlaxQuotaError(message, code=code, status=status)
|
|
59
|
+
if status == 404:
|
|
60
|
+
return FlaxNotFoundError(message, code=code, status=status)
|
|
61
|
+
if status == 409:
|
|
62
|
+
return FlaxConflictError(message, code=code, status=status)
|
|
63
|
+
if 400 <= status < 500:
|
|
64
|
+
return FlaxBadRequestError(message, code=code, status=status)
|
|
65
|
+
return FlaxServerError(message, code=code, status=status)
|