runbay 0.1.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.
Files changed (51) hide show
  1. runbay/__init__.py +46 -0
  2. runbay/api.py +384 -0
  3. runbay/cli/__init__.py +5 -0
  4. runbay/cli/__main__.py +6 -0
  5. runbay/cli/commands/__init__.py +1 -0
  6. runbay/cli/commands/account.py +94 -0
  7. runbay/cli/commands/billing.py +66 -0
  8. runbay/cli/commands/build_utils/__init__.py +16 -0
  9. runbay/cli/commands/build_utils/handler_generator.py +29 -0
  10. runbay/cli/commands/build_utils/manifest.py +32 -0
  11. runbay/cli/commands/build_utils/resource_config_generator.py +40 -0
  12. runbay/cli/commands/build_utils/scanner.py +56 -0
  13. runbay/cli/commands/dicts.py +184 -0
  14. runbay/cli/commands/endpoint.py +80 -0
  15. runbay/cli/commands/environment.py +98 -0
  16. runbay/cli/commands/jobs.py +68 -0
  17. runbay/cli/commands/project.py +294 -0
  18. runbay/cli/commands/queues.py +175 -0
  19. runbay/cli/commands/run.py +182 -0
  20. runbay/cli/commands/sandboxes.py +287 -0
  21. runbay/cli/commands/secrets.py +101 -0
  22. runbay/cli/commands/volumes.py +217 -0
  23. runbay/cli/main.py +64 -0
  24. runbay/cli/utils/__init__.py +1 -0
  25. runbay/cli/utils/client.py +89 -0
  26. runbay/cli/utils/output.py +41 -0
  27. runbay/cli/utils/rich_help.py +136 -0
  28. runbay/cli/utils/volume_io.py +67 -0
  29. runbay/cli/utils/ws_terminal.py +440 -0
  30. runbay/client.py +119 -0
  31. runbay/config.py +54 -0
  32. runbay/decorator.py +534 -0
  33. runbay/dict.py +144 -0
  34. runbay/entrypoint.py +87 -0
  35. runbay/environment.py +111 -0
  36. runbay/exceptions.py +89 -0
  37. runbay/packaging.py +128 -0
  38. runbay/project.py +123 -0
  39. runbay/queue.py +103 -0
  40. runbay/retries.py +77 -0
  41. runbay/sandbox.py +431 -0
  42. runbay/schedule.py +35 -0
  43. runbay/secret.py +69 -0
  44. runbay/serverless.py +61 -0
  45. runbay/vendor.py +98 -0
  46. runbay/volume.py +81 -0
  47. runbay-0.1.0.dist-info/METADATA +8 -0
  48. runbay-0.1.0.dist-info/RECORD +51 -0
  49. runbay-0.1.0.dist-info/WHEEL +5 -0
  50. runbay-0.1.0.dist-info/entry_points.txt +3 -0
  51. runbay-0.1.0.dist-info/top_level.txt +1 -0
runbay/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """RunBay SDK — deploy and call serverless GPU endpoints."""
2
+
3
+ from .client import Endpoint, Job
4
+ from .decorator import (
5
+ CpuGroup,
6
+ CpuInstanceType,
7
+ EndpointFunction,
8
+ GpuGroup,
9
+ asgi_app,
10
+ endpoint,
11
+ fastapi_endpoint,
12
+ web_server,
13
+ )
14
+ from .dict import Dict
15
+ from .entrypoint import LocalEntrypoint, is_local, local_entrypoint
16
+ from .environment import Environment
17
+ from .exceptions import (
18
+ ApiError,
19
+ AuthError,
20
+ Error,
21
+ InsufficientBalanceError,
22
+ JobError,
23
+ NotFoundError,
24
+ RemoteError,
25
+ SandboxError,
26
+ TimeoutError,
27
+ )
28
+ from .queue import Queue
29
+ from .retries import Retries
30
+ from .sandbox import Sandbox, SandboxSnapshot, Tunnel
31
+ from .schedule import Cron, Period
32
+ from .secret import Secret
33
+ from .volume import Volume
34
+ from . import serverless
35
+
36
+ __version__ = "0.1.0"
37
+ __all__ = [
38
+ "endpoint", "asgi_app", "web_server", "fastapi_endpoint", "local_entrypoint", "LocalEntrypoint", "is_local",
39
+ "GpuGroup", "CpuGroup", "CpuInstanceType", "Cron", "Period", "Retries",
40
+ "EndpointFunction", "Endpoint", "Job", "Sandbox", "SandboxSnapshot", "Tunnel",
41
+ "Secret", "Volume", "Dict", "Queue", "Environment",
42
+ # exceptions
43
+ "Error", "ApiError", "AuthError", "NotFoundError", "InsufficientBalanceError",
44
+ "SandboxError", "JobError", "RemoteError", "TimeoutError",
45
+ "serverless",
46
+ ]
runbay/api.py ADDED
@@ -0,0 +1,384 @@
1
+ """Management API client (auth, endpoints CRUD, artifact upload)."""
2
+
3
+ from typing import Any, Dict, List, Optional
4
+ from urllib.parse import quote
5
+
6
+ import requests
7
+
8
+ from .config import get_api_url, get_auth_token
9
+ # Re-exported so `from .api import ApiError` (and callers) keep working.
10
+ from .exceptions import ApiError, api_error
11
+
12
+
13
+ class ManagementClient:
14
+ def __init__(self, api_url: Optional[str] = None, token: Optional[str] = None):
15
+ self.api_url = (api_url or get_api_url()).rstrip("/")
16
+ self.token = token or get_auth_token()
17
+ self.session = requests.Session()
18
+ # explicit UA: proxies (e.g. Cloudflare) may block default python UAs
19
+ self.session.headers["User-Agent"] = "runbay-sdk/0.1.0"
20
+
21
+ def _headers(self, auth: bool = True) -> Dict[str, str]:
22
+ # requests sets Content-Type itself when json= is passed; sending it
23
+ # on body-less POSTs makes Fastify reject the empty body with a 400
24
+ headers: Dict[str, str] = {}
25
+ if auth:
26
+ if not self.token:
27
+ raise ApiError(401, "Not logged in — run 'rb login' or set RB_API_KEY")
28
+ headers["Authorization"] = f"Bearer {self.token}"
29
+ return headers
30
+
31
+ def _request(self, method: str, path: str, auth: bool = True, **kwargs) -> Any:
32
+ res = self.session.request(
33
+ method,
34
+ f"{self.api_url}{path}",
35
+ headers=self._headers(auth),
36
+ timeout=kwargs.pop("timeout", 60),
37
+ **kwargs,
38
+ )
39
+ if res.status_code >= 400:
40
+ try:
41
+ message = res.json().get("error", res.text)
42
+ except ValueError:
43
+ message = res.text
44
+ raise api_error(res.status_code, message)
45
+ return res.json() if res.text else {}
46
+
47
+ # ---- auth ----
48
+
49
+ def register(self, email: str, name: str, password: str) -> Dict[str, Any]:
50
+ return self._request(
51
+ "POST", "/user/register", auth=False,
52
+ json={"email": email, "name": name, "password": password},
53
+ )
54
+
55
+ def login(self, email: str, password: str) -> Dict[str, Any]:
56
+ return self._request(
57
+ "POST", "/user/login", auth=False, json={"email": email, "password": password}
58
+ )
59
+
60
+ def create_api_key(self, name: str = "default") -> Dict[str, Any]:
61
+ return self._request("POST", "/user/apikey", json={"name": name})
62
+
63
+ def me(self) -> Dict[str, Any]:
64
+ return self._request("GET", "/user/me")
65
+
66
+ def balance(self) -> Dict[str, Any]:
67
+ return self._request("GET", "/user/balance")
68
+
69
+ def usage(self, limit: int = 50) -> Dict[str, Any]:
70
+ return self._request("GET", "/user/usage", params={"limit": str(limit)})
71
+
72
+ def gpus(self) -> list:
73
+ """Merged compute catalog (GPU + CPU entries, each with a `kind` field).
74
+
75
+ Backend returns `{gpus: [...], cpus: [...]}`; we merge into one list so
76
+ callers reading `kind` stay backward compatible.
77
+ """
78
+ data = self._request("GET", "/api/gpus")
79
+ return [*data.get("gpus", []), *data.get("cpus", [])]
80
+
81
+ # ---- sandboxes ----
82
+
83
+ def create_sandbox(self, spec: Dict[str, Any]) -> Dict[str, Any]:
84
+ return self._request("POST", "/api/sandboxes", json=spec)["sandbox"]
85
+
86
+ def list_sandboxes(self, include_terminated: bool = False) -> list:
87
+ params = {"all": "true"} if include_terminated else {}
88
+ return self._request("GET", "/api/sandboxes", params=params)["sandboxes"]
89
+
90
+ def get_sandbox(self, sandbox_id: str) -> Dict[str, Any]:
91
+ return self._request("GET", f"/api/sandboxes/{sandbox_id}")["sandbox"]
92
+
93
+ def terminate_sandbox(self, sandbox_id: str) -> Dict[str, Any]:
94
+ return self._request("DELETE", f"/api/sandboxes/{sandbox_id}")
95
+
96
+ # ---- sandbox snapshots ----
97
+
98
+ def create_sandbox_snapshot(self, sandbox_id: str) -> Dict[str, Any]:
99
+ return self._request("POST", f"/api/sandboxes/{sandbox_id}/snapshot")["snapshot"]
100
+
101
+ def list_sandbox_snapshots(self, sandbox_id: Optional[str] = None) -> list:
102
+ params = {"sandboxId": sandbox_id} if sandbox_id else {}
103
+ return self._request("GET", "/api/sandbox-snapshots", params=params)["snapshots"]
104
+
105
+ def get_sandbox_snapshot(self, snapshot_id: str) -> Dict[str, Any]:
106
+ return self._request("GET", f"/api/sandbox-snapshots/{snapshot_id}")["snapshot"]
107
+
108
+ def delete_sandbox_snapshot(self, snapshot_id: str) -> Dict[str, Any]:
109
+ return self._request("DELETE", f"/api/sandbox-snapshots/{snapshot_id}")
110
+
111
+ # ---- endpoints ----
112
+
113
+ def create_endpoint(self, spec: Dict[str, Any]) -> Dict[str, Any]:
114
+ return self._request("POST", "/api/endpoints", json=spec)["endpoint"]
115
+
116
+ def list_endpoints(self, environment: Optional[str] = None) -> list:
117
+ params = {"environment": environment} if environment else {}
118
+ return self._request("GET", "/api/endpoints", params=params)["endpoints"]
119
+
120
+ # ---- environments ----
121
+
122
+ def list_environments(self) -> list:
123
+ return self._request("GET", "/api/environments")["environments"]
124
+
125
+ def create_environment(self, name: str, web_suffix: str = "") -> Dict[str, Any]:
126
+ body: Dict[str, Any] = {"name": name}
127
+ if web_suffix:
128
+ body["webSuffix"] = web_suffix
129
+ return self._request("POST", "/api/environments", json=body)["environment"]
130
+
131
+ def update_environment(
132
+ self,
133
+ name: str,
134
+ set_name: Optional[str] = None,
135
+ set_web_suffix: Optional[str] = None,
136
+ ) -> Dict[str, Any]:
137
+ body: Dict[str, Any] = {}
138
+ if set_name is not None:
139
+ body["setName"] = set_name
140
+ if set_web_suffix is not None:
141
+ body["setWebSuffix"] = set_web_suffix
142
+ if not body:
143
+ raise ValueError("update_environment requires set_name or set_web_suffix")
144
+ return self._request("PATCH", f"/api/environments/{name}", json=body)["environment"]
145
+
146
+ def delete_environment(self, name: str) -> Dict[str, Any]:
147
+ return self._request("DELETE", f"/api/environments/{name}")
148
+
149
+ def storage(self) -> Dict[str, Any]:
150
+ return self._request("GET", "/user/storage")["storage"]
151
+
152
+ def get_endpoint(self, endpoint_id: str) -> Dict[str, Any]:
153
+ return self._request("GET", f"/api/endpoints/{endpoint_id}")["endpoint"]
154
+
155
+ def update_endpoint(self, endpoint_id: str, spec: Dict[str, Any]) -> Dict[str, Any]:
156
+ return self._request("PATCH", f"/api/endpoints/{endpoint_id}", json=spec)["endpoint"]
157
+
158
+ def delete_endpoint(self, endpoint_id: str) -> Dict[str, Any]:
159
+ return self._request("DELETE", f"/api/endpoints/{endpoint_id}")
160
+
161
+ def list_jobs(self, endpoint_id: str, status: Optional[str] = None, limit: int = 50) -> list:
162
+ params = {"limit": str(limit)}
163
+ if status:
164
+ params["status"] = status
165
+ return self._request("GET", f"/api/endpoints/{endpoint_id}/jobs", params=params)["jobs"]
166
+
167
+ def list_workers(self, endpoint_id: str) -> list:
168
+ return self._request("GET", f"/api/endpoints/{endpoint_id}/workers")["workers"]
169
+
170
+ # ---- volumes ----
171
+
172
+ def create_volume(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
173
+ body: Dict[str, Any] = {"name": name}
174
+ if environment is not None:
175
+ body["environment"] = environment
176
+ return self._request("POST", "/api/volumes", json=body)["volume"]
177
+
178
+ def list_volumes(self, environment: Optional[str] = None) -> list:
179
+ params = {"environment": environment} if environment else {}
180
+ return self._request("GET", "/api/volumes", params=params)["volumes"]
181
+
182
+ def get_volume(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
183
+ params = {"environment": environment} if environment else {}
184
+ return self._request("GET", f"/api/volumes/{name}", params=params)["volume"]
185
+
186
+ def delete_volume(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
187
+ params = {"environment": environment} if environment else {}
188
+ return self._request("DELETE", f"/api/volumes/{name}", params=params)
189
+
190
+ def rename_volume(self, old_name: str, new_name: str, environment: Optional[str] = None) -> Dict[str, Any]:
191
+ params = {"environment": environment} if environment else {}
192
+ return self._request("PATCH", f"/api/volumes/{old_name}", params=params, json={"name": new_name})["volume"]
193
+
194
+ def list_volume_files(self, name: str, prefix: str = "", environment: Optional[str] = None) -> list:
195
+ params: Dict[str, str] = {}
196
+ if prefix:
197
+ params["prefix"] = prefix
198
+ if environment is not None:
199
+ params["environment"] = environment
200
+ return self._request("GET", f"/api/volumes/{name}/files", params=params)["files"]
201
+
202
+ def delete_volume_path(self, name: str, path: str, recursive: bool = False, environment: Optional[str] = None) -> Dict[str, Any]:
203
+ params = {"environment": environment} if environment else {}
204
+ return self._request("DELETE", f"/api/volumes/{name}/files", params=params, json={"path": path, "recursive": recursive})
205
+
206
+ def get_volume_upload_url(self, name: str, path: str, environment: Optional[str] = None) -> Dict[str, Any]:
207
+ params = {"environment": environment} if environment else {}
208
+ return self._request("POST", f"/api/volumes/{name}/upload-url", params=params, json={"path": path})
209
+
210
+ def get_volume_download_url(self, name: str, path: str, environment: Optional[str] = None) -> Dict[str, Any]:
211
+ params = {"environment": environment} if environment else {}
212
+ return self._request("POST", f"/api/volumes/{name}/download-url", params=params, json={"path": path})
213
+
214
+ # ---- dicts ----
215
+
216
+ def create_dict(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
217
+ body: Dict[str, Any] = {"name": name}
218
+ if environment is not None:
219
+ body["environment"] = environment
220
+ return self._request("POST", "/api/dicts", json=body)["dict"]
221
+
222
+ def list_dicts(self, environment: Optional[str] = None) -> list:
223
+ params = {"environment": environment} if environment else {}
224
+ return self._request("GET", "/api/dicts", params=params)["dicts"]
225
+
226
+ def get_dict(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
227
+ params = {"environment": environment} if environment else {}
228
+ return self._request("GET", f"/api/dicts/{name}", params=params)["dict"]
229
+
230
+ def rename_dict(self, old_name: str, new_name: str, environment: Optional[str] = None) -> Dict[str, Any]:
231
+ params = {"environment": environment} if environment else {}
232
+ return self._request("PATCH", f"/api/dicts/{old_name}", params=params, json={"name": new_name})["dict"]
233
+
234
+ def delete_dict(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
235
+ params = {"environment": environment} if environment else {}
236
+ return self._request("DELETE", f"/api/dicts/{name}", params=params)
237
+
238
+ def list_dict_entries(
239
+ self, name: str, limit: int = 100, search: str = "", environment: Optional[str] = None
240
+ ) -> Dict[str, Any]:
241
+ params: Dict[str, str] = {"limit": str(limit)}
242
+ if search:
243
+ params["search"] = search
244
+ if environment is not None:
245
+ params["environment"] = environment
246
+ return self._request("GET", f"/api/dicts/{name}/entries", params=params)
247
+
248
+ def get_dict_entry(self, name: str, key: str, environment: Optional[str] = None) -> Dict[str, Any]:
249
+ params = {"environment": environment} if environment else {}
250
+ return self._request("GET", f"/api/dicts/{name}/entries/{quote(key, safe='')}", params=params)
251
+
252
+ def put_dict_entry(self, name: str, key: str, value: Any, environment: Optional[str] = None) -> Dict[str, Any]:
253
+ params = {"environment": environment} if environment else {}
254
+ return self._request(
255
+ "PUT", f"/api/dicts/{name}/entries/{quote(key, safe='')}", params=params, json={"value": value}
256
+ )
257
+
258
+ def put_dict_entries(self, name: str, entries: Dict[str, Any], environment: Optional[str] = None) -> Dict[str, Any]:
259
+ params = {"environment": environment} if environment else {}
260
+ return self._request("POST", f"/api/dicts/{name}/entries", params=params, json={"entries": entries})
261
+
262
+ def delete_dict_entry(self, name: str, key: str, environment: Optional[str] = None) -> Dict[str, Any]:
263
+ params = {"environment": environment} if environment else {}
264
+ return self._request("DELETE", f"/api/dicts/{name}/entries/{quote(key, safe='')}", params=params)
265
+
266
+ def clear_dict(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
267
+ params = {"environment": environment} if environment else {}
268
+ return self._request("POST", f"/api/dicts/{name}/clear", params=params)
269
+
270
+ # ---- queues ----
271
+
272
+ def create_queue(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
273
+ body: Dict[str, Any] = {"name": name}
274
+ if environment is not None:
275
+ body["environment"] = environment
276
+ return self._request("POST", "/api/queues", json=body)["queue"]
277
+
278
+ def list_queues(self, environment: Optional[str] = None) -> list:
279
+ params = {"environment": environment} if environment else {}
280
+ return self._request("GET", "/api/queues", params=params)["queues"]
281
+
282
+ def get_queue(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
283
+ params = {"environment": environment} if environment else {}
284
+ return self._request("GET", f"/api/queues/{name}", params=params)["queue"]
285
+
286
+ def rename_queue(self, old_name: str, new_name: str, environment: Optional[str] = None) -> Dict[str, Any]:
287
+ params = {"environment": environment} if environment else {}
288
+ return self._request("PATCH", f"/api/queues/{old_name}", params=params, json={"name": new_name})["queue"]
289
+
290
+ def delete_queue(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
291
+ params = {"environment": environment} if environment else {}
292
+ return self._request("DELETE", f"/api/queues/{name}", params=params)
293
+
294
+ def queue_put(self, name: str, items: List[Any], environment: Optional[str] = None) -> Dict[str, Any]:
295
+ params = {"environment": environment} if environment else {}
296
+ return self._request("POST", f"/api/queues/{name}/put", params=params, json={"items": items})
297
+
298
+ def queue_pop(self, name: str, n: int = 1, environment: Optional[str] = None) -> List[Any]:
299
+ params = {"environment": environment} if environment else {}
300
+ return self._request("POST", f"/api/queues/{name}/pop", params=params, json={"n": n})["items"]
301
+
302
+ def queue_peek(self, name: str, limit: int = 50, environment: Optional[str] = None) -> list:
303
+ params: Dict[str, str] = {"limit": str(limit)}
304
+ if environment is not None:
305
+ params["environment"] = environment
306
+ return self._request("GET", f"/api/queues/{name}/peek", params=params)["entries"]
307
+
308
+ def queue_len(self, name: str, environment: Optional[str] = None) -> int:
309
+ params = {"environment": environment} if environment else {}
310
+ return self._request("GET", f"/api/queues/{name}/len", params=params)["length"]
311
+
312
+ def clear_queue(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
313
+ params = {"environment": environment} if environment else {}
314
+ return self._request("POST", f"/api/queues/{name}/clear", params=params)
315
+
316
+ # ---- secrets ----
317
+
318
+ def create_secret(self, name: str, values: Dict[str, str], environment: Optional[str] = None) -> Dict[str, Any]:
319
+ body: Dict[str, Any] = {"name": name, "values": values}
320
+ if environment is not None:
321
+ body["environment"] = environment
322
+ return self._request("POST", "/api/secrets", json=body)["secret"]
323
+
324
+ def list_secrets(self, environment: Optional[str] = None) -> list:
325
+ params = {"environment": environment} if environment else {}
326
+ return self._request("GET", "/api/secrets", params=params)["secrets"]
327
+
328
+ def get_secret(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
329
+ params = {"environment": environment} if environment else {}
330
+ return self._request("GET", f"/api/secrets/{name}", params=params)["secret"]
331
+
332
+ def update_secret(
333
+ self,
334
+ name: str,
335
+ *,
336
+ new_name: Optional[str] = None,
337
+ values: Optional[Dict[str, str]] = None,
338
+ environment: Optional[str] = None,
339
+ ) -> Dict[str, Any]:
340
+ body: Dict[str, Any] = {}
341
+ if new_name is not None:
342
+ body["name"] = new_name
343
+ if values is not None:
344
+ body["values"] = values
345
+ params = {"environment": environment} if environment else {}
346
+ return self._request("PATCH", f"/api/secrets/{name}", params=params, json=body)["secret"]
347
+
348
+ def delete_secret(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
349
+ params = {"environment": environment} if environment else {}
350
+ return self._request("DELETE", f"/api/secrets/{name}", params=params)
351
+
352
+ # ---- deploy ----
353
+
354
+ def get_upload_url(self, endpoint_id: str) -> Dict[str, Any]:
355
+ return self._request("POST", f"/api/endpoints/{endpoint_id}/artifact/upload-url")
356
+
357
+ def upload_artifact(self, upload_url: str, bundle_path: str) -> None:
358
+ with open(bundle_path, "rb") as f:
359
+ res = requests.put(
360
+ upload_url, data=f, headers={"Content-Type": "application/gzip"}, timeout=600
361
+ )
362
+ if res.status_code >= 400:
363
+ raise ApiError(res.status_code, f"artifact upload failed: {res.text[:500]}")
364
+
365
+ def commit_artifact(
366
+ self,
367
+ endpoint_id: str,
368
+ key: str,
369
+ version: int,
370
+ handler: Optional[str] = None,
371
+ python: Optional[str] = None,
372
+ packages: Optional[List[Any]] = None,
373
+ files: Optional[List[Dict[str, Any]]] = None,
374
+ ) -> Dict[str, Any]:
375
+ body: Dict[str, Any] = {"key": key, "version": version}
376
+ if handler:
377
+ body["handler"] = handler
378
+ if python:
379
+ body["python"] = python
380
+ if packages is not None:
381
+ body["packages"] = packages
382
+ if files is not None:
383
+ body["files"] = files
384
+ return self._request("POST", f"/api/endpoints/{endpoint_id}/artifact/commit", json=body)
runbay/cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """RunBay CLI (`rb`) — command package."""
2
+
3
+ from .main import main
4
+
5
+ __all__ = ["main"]
runbay/cli/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m runbay.cli` entry point."""
2
+
3
+ from .main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1 @@
1
+ """CLI command modules (each owns its Click group/commands)."""
@@ -0,0 +1,94 @@
1
+ """Account commands: login, whoami, register(hidden), apikey(hidden)."""
2
+
3
+ import getpass
4
+ from typing import Any, Dict, Optional
5
+
6
+ import click
7
+
8
+ from ...api import ApiError, ManagementClient
9
+ from ...config import save_credentials
10
+ from ..utils.client import _browser_login, _client, _save_login
11
+ from ..utils.output import _die, _print_json
12
+ from ..utils.rich_help import RichCommand as _Cmd
13
+
14
+
15
+ @click.command(cls=_Cmd)
16
+ @click.argument("api_key", required=False)
17
+ @click.option("--browser", "use_browser", is_flag=True, help="Authorize through the web console")
18
+ @click.option("--api-url", default=None, help="Backend API URL (persisted)")
19
+ def login(api_key: Optional[str], use_browser: bool, api_url: Optional[str]) -> None:
20
+ """Log in — through the browser (default) or with an API key.
21
+
22
+ rb login interactive: browser or paste an API key
23
+ rb login --browser straight to browser authorization
24
+ rb login rb_... use an existing API key
25
+ """
26
+ if api_key:
27
+ return _save_login(api_key, api_url)
28
+ if use_browser:
29
+ return _browser_login(api_url)
30
+
31
+ choice = click.prompt(
32
+ "how do you want to authenticate?\n [1] browser (opens the web console)\n [2] paste an API key\n",
33
+ type=click.Choice(["1", "2"]),
34
+ default="1",
35
+ show_choices=False,
36
+ )
37
+ if choice == "1":
38
+ return _browser_login(api_url)
39
+
40
+ api_key = getpass.getpass("API key (rb_...): ").strip()
41
+ if not api_key:
42
+ _die("an API key is required")
43
+ _save_login(api_key, api_url)
44
+
45
+
46
+ @click.command(cls=_Cmd, hidden=True)
47
+ @click.option("--email", prompt=True)
48
+ @click.option("--name", prompt=True)
49
+ @click.option("--api-url", default=None, help="Backend API URL (persisted)")
50
+ def register(email: str, name: str, api_url: Optional[str]) -> None:
51
+ """Create an account and an initial API key."""
52
+ password = getpass.getpass("Password: ")
53
+ client = ManagementClient(api_url=api_url)
54
+ try:
55
+ data = client.register(email, name, password)
56
+ # mint the first API key with the fresh session and store it
57
+ session = ManagementClient(api_url=api_url, token=data["token"])
58
+ key = session.create_api_key("default")["apiKey"]
59
+ except ApiError as exc:
60
+ _die(str(exc))
61
+
62
+ creds: Dict[str, Any] = {"api_key": key, "email": email}
63
+ if api_url:
64
+ creds["api_url"] = api_url
65
+ save_credentials(creds)
66
+ click.secho(f"account created for {email}", fg="green")
67
+ click.echo(f"API key (default): {key}")
68
+ click.secho("store it now — it cannot be retrieved again (saved to ~/.runbay)", fg="yellow")
69
+
70
+
71
+ @click.command(cls=_Cmd, hidden=True)
72
+ @click.option("--name", default="default", help="Label for the key")
73
+ @click.option("--save/--no-save", default=True, help="Store the key in ~/.runbay")
74
+ def apikey(name: str, save: bool) -> None:
75
+ """Create a new API key."""
76
+ try:
77
+ data = _client().create_api_key(name)
78
+ except ApiError as exc:
79
+ _die(str(exc))
80
+ click.echo(f"API key ({name}): {data['apiKey']}")
81
+ click.secho("store it now — it cannot be retrieved again", fg="yellow")
82
+ if save:
83
+ save_credentials({"api_key": data["apiKey"]})
84
+ click.echo("saved to ~/.runbay/config.json (used for future CLI/SDK calls)")
85
+
86
+
87
+ @click.command(cls=_Cmd)
88
+ def whoami() -> None:
89
+ """Show the logged-in account."""
90
+ try:
91
+ data = _client().me()
92
+ except ApiError as exc:
93
+ _die(str(exc))
94
+ _print_json(data)
@@ -0,0 +1,66 @@
1
+ """Billing commands (hidden from `rb --help`): gpus, balance.
2
+
3
+ The catalog and usage live in the web console; these stay for power users.
4
+ """
5
+
6
+ import click
7
+
8
+ from ...api import ApiError
9
+ from ..utils.client import _client
10
+ from ..utils.output import _die, _format_bytes, _print_table
11
+ from ..utils.rich_help import RichCommand as _Cmd
12
+
13
+
14
+ @click.command(cls=_Cmd, hidden=True)
15
+ def gpus() -> None:
16
+ """List available compute types (GPU and CPU) and pricing."""
17
+ try:
18
+ catalog = _client().gpus()
19
+ except ApiError as exc:
20
+ _die(str(exc))
21
+ # group by kind (gpu first, then cpu) so the two sections read clearly
22
+ catalog.sort(key=lambda g: (0 if g.get("kind", "gpu") == "gpu" else 1, g["pricePerHour"]))
23
+ _print_table(
24
+ ["TYPE", "KIND", "NAME", "MEMORY", "PRICE/HR"],
25
+ [
26
+ [g["id"], g.get("kind", "gpu"), g["name"], f"{g['memoryGb']} GB", f"${g['pricePerHour']:.2f}"]
27
+ for g in catalog
28
+ ],
29
+ )
30
+
31
+
32
+ @click.command(cls=_Cmd, hidden=True)
33
+ @click.option("--limit", default=15, help="Usage records to show")
34
+ def balance(limit: int) -> None:
35
+ """Show balance and recent GPU usage (billed per second)."""
36
+ try:
37
+ data = _client().usage(limit=limit)
38
+ except ApiError as exc:
39
+ _die(str(exc))
40
+ click.echo(f"balance: ${data['balance']:.4f}")
41
+ try:
42
+ storage = _client().storage()
43
+ gb = storage["totalBytes"] / (1024 ** 3)
44
+ click.echo(
45
+ f"storage: {gb:.2f} GB used ({storage['freeGb']} GB free tier, "
46
+ f"${storage['pricePerGbMonth']:.2f}/GB/mo -> est ${storage['estMonthlyUsd']:.2f}/mo)"
47
+ )
48
+ except ApiError:
49
+ pass
50
+ click.echo(f"total spent: ${data['totalSpent']:.4f} ({int(data['totalSeconds'])}s of GPU time)")
51
+ if data["usage"]:
52
+ click.echo("")
53
+ _print_table(
54
+ ["WHEN", "KIND", "REF", "GPU", "SECONDS", "COST"],
55
+ [
56
+ [
57
+ str(u.get("startedAt", ""))[:19],
58
+ u.get("kind", ""),
59
+ u.get("refId", ""),
60
+ f"{u.get('gpuType', '')} x{u.get('gpuNum', 1)}",
61
+ str(int(u.get("seconds", 0))),
62
+ f"${u.get('amount', 0):.4f}",
63
+ ]
64
+ for u in data["usage"][:limit]
65
+ ],
66
+ )
@@ -0,0 +1,16 @@
1
+ """Deploy build utilities: handler scaffolding, source scanning, resource configs, manifest."""
2
+
3
+ from .handler_generator import generate_handler
4
+ from .manifest import finalize_manifest, RB_REQUIREMENTS_FILE, ARTIFACT_NAME
5
+ from .resource_config_generator import build_resource
6
+ from .scanner import discover, DiscoveredFn
7
+
8
+ __all__ = [
9
+ "generate_handler",
10
+ "finalize_manifest",
11
+ "build_resource",
12
+ "discover",
13
+ "DiscoveredFn",
14
+ "RB_REQUIREMENTS_FILE",
15
+ "ARTIFACT_NAME",
16
+ ]
@@ -0,0 +1,29 @@
1
+ """Generate the starter `handler.py` for `rb init`."""
2
+
3
+ _HANDLER_TEMPLATE = '''\
4
+ from runbay import endpoint
5
+
6
+
7
+ @endpoint(
8
+ name="{name}",
9
+ # gpu=GpuGroup.ADA_24, # GPU product id or alias (platform default if omitted)
10
+ workers=1, # max concurrent workers
11
+ idle_timeout=60, # seconds idle before scale-down (to zero)
12
+ dependencies=[], # pip packages installed on the worker
13
+ )
14
+ def {fn_name}(name="world"):
15
+ """Runs on a serverless GPU worker. Return anything JSON-serializable."""
16
+ return {{"greeting": f"Hello, {{name}}!"}}
17
+
18
+
19
+ if __name__ == "__main__":
20
+ # local test: python handler.py
21
+ # deploy: rb deploy
22
+ # remote call: {fn_name}("RunBay") (after deploy, from any script)
23
+ print({fn_name}.local(name="local"))
24
+ '''
25
+
26
+
27
+ def generate_handler(name: str, fn_name: str) -> str:
28
+ """Render the starter handler source for an endpoint named `name`."""
29
+ return _HANDLER_TEMPLATE.format(name=name, fn_name=fn_name)