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