runbay 0.1.0__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.
- runbay-0.1.0/PKG-INFO +8 -0
- runbay-0.1.0/README.md +55 -0
- runbay-0.1.0/pyproject.toml +21 -0
- runbay-0.1.0/runbay/__init__.py +46 -0
- runbay-0.1.0/runbay/api.py +384 -0
- runbay-0.1.0/runbay/cli/__init__.py +5 -0
- runbay-0.1.0/runbay/cli/__main__.py +6 -0
- runbay-0.1.0/runbay/cli/commands/__init__.py +1 -0
- runbay-0.1.0/runbay/cli/commands/account.py +94 -0
- runbay-0.1.0/runbay/cli/commands/billing.py +66 -0
- runbay-0.1.0/runbay/cli/commands/build_utils/__init__.py +16 -0
- runbay-0.1.0/runbay/cli/commands/build_utils/handler_generator.py +29 -0
- runbay-0.1.0/runbay/cli/commands/build_utils/manifest.py +32 -0
- runbay-0.1.0/runbay/cli/commands/build_utils/resource_config_generator.py +40 -0
- runbay-0.1.0/runbay/cli/commands/build_utils/scanner.py +56 -0
- runbay-0.1.0/runbay/cli/commands/dicts.py +184 -0
- runbay-0.1.0/runbay/cli/commands/endpoint.py +80 -0
- runbay-0.1.0/runbay/cli/commands/environment.py +98 -0
- runbay-0.1.0/runbay/cli/commands/jobs.py +68 -0
- runbay-0.1.0/runbay/cli/commands/project.py +294 -0
- runbay-0.1.0/runbay/cli/commands/queues.py +175 -0
- runbay-0.1.0/runbay/cli/commands/run.py +182 -0
- runbay-0.1.0/runbay/cli/commands/sandboxes.py +287 -0
- runbay-0.1.0/runbay/cli/commands/secrets.py +101 -0
- runbay-0.1.0/runbay/cli/commands/volumes.py +217 -0
- runbay-0.1.0/runbay/cli/main.py +64 -0
- runbay-0.1.0/runbay/cli/utils/__init__.py +1 -0
- runbay-0.1.0/runbay/cli/utils/client.py +89 -0
- runbay-0.1.0/runbay/cli/utils/output.py +41 -0
- runbay-0.1.0/runbay/cli/utils/rich_help.py +136 -0
- runbay-0.1.0/runbay/cli/utils/volume_io.py +67 -0
- runbay-0.1.0/runbay/cli/utils/ws_terminal.py +440 -0
- runbay-0.1.0/runbay/client.py +119 -0
- runbay-0.1.0/runbay/config.py +54 -0
- runbay-0.1.0/runbay/decorator.py +534 -0
- runbay-0.1.0/runbay/dict.py +144 -0
- runbay-0.1.0/runbay/entrypoint.py +87 -0
- runbay-0.1.0/runbay/environment.py +111 -0
- runbay-0.1.0/runbay/exceptions.py +89 -0
- runbay-0.1.0/runbay/packaging.py +128 -0
- runbay-0.1.0/runbay/project.py +123 -0
- runbay-0.1.0/runbay/queue.py +103 -0
- runbay-0.1.0/runbay/retries.py +77 -0
- runbay-0.1.0/runbay/sandbox.py +431 -0
- runbay-0.1.0/runbay/schedule.py +35 -0
- runbay-0.1.0/runbay/secret.py +69 -0
- runbay-0.1.0/runbay/serverless.py +61 -0
- runbay-0.1.0/runbay/vendor.py +98 -0
- runbay-0.1.0/runbay/volume.py +81 -0
- runbay-0.1.0/runbay.egg-info/PKG-INFO +8 -0
- runbay-0.1.0/runbay.egg-info/SOURCES.txt +54 -0
- runbay-0.1.0/runbay.egg-info/dependency_links.txt +1 -0
- runbay-0.1.0/runbay.egg-info/entry_points.txt +3 -0
- runbay-0.1.0/runbay.egg-info/requires.txt +3 -0
- runbay-0.1.0/runbay.egg-info/top_level.txt +1 -0
- runbay-0.1.0/setup.cfg +4 -0
runbay-0.1.0/PKG-INFO
ADDED
runbay-0.1.0/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# runbay — SDK & CLI
|
|
2
|
+
|
|
3
|
+
Deploy Python functions to serverless GPUs. Endpoints scale to zero when idle
|
|
4
|
+
and spin GPU workers back up on demand. Everything is declared in code — no
|
|
5
|
+
config files.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
# handler.py
|
|
17
|
+
from runbay import endpoint, GpuGroup
|
|
18
|
+
|
|
19
|
+
@endpoint(
|
|
20
|
+
name="text-generation",
|
|
21
|
+
gpu=GpuGroup.ADA_24,
|
|
22
|
+
workers=3,
|
|
23
|
+
idle_timeout=600,
|
|
24
|
+
dependencies=["transformers", "torch", "accelerate"],
|
|
25
|
+
)
|
|
26
|
+
def generate_text(prompt, max_length=50):
|
|
27
|
+
...
|
|
28
|
+
return {"generated_text": ...}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
rb register # once — creates account + API key
|
|
33
|
+
rb deploy # provision + upload
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from handler import generate_text
|
|
38
|
+
|
|
39
|
+
generate_text("Once upon a time") # runs on a serverless GPU
|
|
40
|
+
generate_text.spawn("...").output() # async
|
|
41
|
+
generate_text.local("...") # run on your machine
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## CLI overview
|
|
45
|
+
|
|
46
|
+
| Command | Purpose |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `rb login [API_KEY]` / `rb whoami` | Authenticate with an API key |
|
|
49
|
+
| `rb init` | Scaffold a starter handler.py |
|
|
50
|
+
| `rb deploy [SCRIPT]` | Deploy all @endpoint functions |
|
|
51
|
+
| `rb run [ID] --input '{...}'` | Invoke over HTTP (sync by default) |
|
|
52
|
+
| `rb jobs status ID JOB_ID` / `rb jobs cancel ID JOB_ID` | Job control |
|
|
53
|
+
| `rb jobs list ID` / `rb jobs purge-queue ID` | Job listing + queue control |
|
|
54
|
+
| `rb endpoint health [ID]` | Endpoint runtime state |
|
|
55
|
+
| `rb endpoint list/get/delete/workers` | Endpoint management |
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runbay"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "RunBay SDK & CLI — deploy Python functions to serverless GPUs"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"requests>=2.31",
|
|
12
|
+
"click>=8.1",
|
|
13
|
+
"rich>=13.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
rb = "runbay.cli:main"
|
|
18
|
+
runbay = "runbay.cli:main"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
include = ["runbay*"]
|
|
@@ -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
|
+
]
|
|
@@ -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)
|
|
@@ -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)
|