runbox-sdk 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runbox_sdk-0.2.2/LICENSE +21 -0
- runbox_sdk-0.2.2/PKG-INFO +40 -0
- runbox_sdk-0.2.2/README.md +22 -0
- runbox_sdk-0.2.2/pyproject.toml +29 -0
- runbox_sdk-0.2.2/setup.cfg +4 -0
- runbox_sdk-0.2.2/src/runbox/__init__.py +8 -0
- runbox_sdk-0.2.2/src/runbox/client.py +321 -0
- runbox_sdk-0.2.2/src/runbox/errors.py +17 -0
- runbox_sdk-0.2.2/src/runbox/identity.py +53 -0
- runbox_sdk-0.2.2/src/runbox/py.typed +0 -0
- runbox_sdk-0.2.2/src/runbox_sdk.egg-info/PKG-INFO +40 -0
- runbox_sdk-0.2.2/src/runbox_sdk.egg-info/SOURCES.txt +14 -0
- runbox_sdk-0.2.2/src/runbox_sdk.egg-info/dependency_links.txt +1 -0
- runbox_sdk-0.2.2/src/runbox_sdk.egg-info/top_level.txt +1 -0
- runbox_sdk-0.2.2/tests/test_client.py +149 -0
- runbox_sdk-0.2.2/tests/test_identity.py +37 -0
runbox_sdk-0.2.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Runbox contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runbox-sdk
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Python client for Runbox, a lightweight sandbox for agent commands and files
|
|
5
|
+
Author: Runbox contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://runbox.xyun.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/xy200303/runbox
|
|
9
|
+
Project-URL: Documentation, https://github.com/xy200303/runbox/blob/main/docs/api.md
|
|
10
|
+
Keywords: runbox,sandbox,agent,code-execution
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Runbox Python SDK
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install runbox-sdk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from runbox import Client, Identity
|
|
27
|
+
|
|
28
|
+
client = Client(
|
|
29
|
+
"http://localhost:8099",
|
|
30
|
+
api_key="...",
|
|
31
|
+
identity=Identity(namespace="team-a", principal="user-42"),
|
|
32
|
+
)
|
|
33
|
+
result = client.exec('python3 -c "print(6 * 7)"')
|
|
34
|
+
print(result["stdout"])
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The client uses only the Python standard library. Uploads stream from disk;
|
|
38
|
+
downloads and tar archives stream to a destination path.
|
|
39
|
+
|
|
40
|
+
Full API reference: [docs/api.md](../../docs/api.md).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Runbox Python SDK
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
pip install runbox-sdk
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from runbox import Client, Identity
|
|
9
|
+
|
|
10
|
+
client = Client(
|
|
11
|
+
"http://localhost:8099",
|
|
12
|
+
api_key="...",
|
|
13
|
+
identity=Identity(namespace="team-a", principal="user-42"),
|
|
14
|
+
)
|
|
15
|
+
result = client.exec('python3 -c "print(6 * 7)"')
|
|
16
|
+
print(result["stdout"])
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The client uses only the Python standard library. Uploads stream from disk;
|
|
20
|
+
downloads and tar archives stream to a destination path.
|
|
21
|
+
|
|
22
|
+
Full API reference: [docs/api.md](../../docs/api.md).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runbox-sdk"
|
|
7
|
+
version = "0.2.2"
|
|
8
|
+
description = "Python client for Runbox, a lightweight sandbox for agent commands and files"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Runbox contributors" }]
|
|
13
|
+
keywords = ["runbox", "sandbox", "agent", "code-execution"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://runbox.xyun.dev"
|
|
22
|
+
Repository = "https://github.com/xy200303/runbox"
|
|
23
|
+
Documentation = "https://github.com/xy200303/runbox/blob/main/docs/api.md"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.package-data]
|
|
29
|
+
runbox = ["py.typed"]
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Stdlib-only client for the Runbox HTTP API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import shutil
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
|
|
15
|
+
|
|
16
|
+
from .errors import RunboxError
|
|
17
|
+
from .identity import Identity
|
|
18
|
+
|
|
19
|
+
PathLike = Union[str, "os.PathLike[str]"]
|
|
20
|
+
|
|
21
|
+
DEFAULT_TIMEOUT = 330.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class FileUpload:
|
|
26
|
+
"""One file sent in a multipart request.
|
|
27
|
+
|
|
28
|
+
Use ``data`` for bytes already in memory, or ``path`` to stream from disk.
|
|
29
|
+
Exactly one must be set.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
data: Optional[bytes] = None
|
|
34
|
+
path: Optional[PathLike] = None
|
|
35
|
+
size: Optional[int] = None
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if (self.data is None) == (self.path is None):
|
|
39
|
+
raise ValueError("set exactly one of data or path")
|
|
40
|
+
if self.path is not None:
|
|
41
|
+
self.size = os.path.getsize(self.path)
|
|
42
|
+
elif self.size is None:
|
|
43
|
+
self.size = len(self.data or b"")
|
|
44
|
+
|
|
45
|
+
def open(self):
|
|
46
|
+
if self.path is not None:
|
|
47
|
+
return open(self.path, "rb")
|
|
48
|
+
import io
|
|
49
|
+
|
|
50
|
+
return io.BytesIO(self.data or b"")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class _Part:
|
|
55
|
+
header: bytes
|
|
56
|
+
length: int
|
|
57
|
+
value: bytes
|
|
58
|
+
path: Optional[PathLike] = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Client:
|
|
62
|
+
"""A Runbox HTTP client."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
base_url: str,
|
|
67
|
+
api_key: str = "",
|
|
68
|
+
identity: Optional[Identity] = None,
|
|
69
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.base_url = base_url.rstrip("/")
|
|
72
|
+
if not self.base_url.startswith(("http://", "https://")):
|
|
73
|
+
raise ValueError("base_url must be an absolute http(s) URL")
|
|
74
|
+
self.api_key = api_key
|
|
75
|
+
self.identity = identity
|
|
76
|
+
self.timeout = timeout
|
|
77
|
+
parts = urllib.parse.urlsplit(self.base_url)
|
|
78
|
+
self._scheme = parts.scheme
|
|
79
|
+
self._host = parts.hostname or ""
|
|
80
|
+
self._port = parts.port
|
|
81
|
+
self._prefix = parts.path.rstrip("/")
|
|
82
|
+
|
|
83
|
+
def _resolve_identity(self, identity: Optional[Identity]) -> Optional[Identity]:
|
|
84
|
+
return identity or self.identity
|
|
85
|
+
|
|
86
|
+
def _headers(
|
|
87
|
+
self,
|
|
88
|
+
identity: Optional[Identity] = None,
|
|
89
|
+
extra: Optional[Dict[str, str]] = None,
|
|
90
|
+
) -> Dict[str, str]:
|
|
91
|
+
headers = {"Accept": "application/json", "User-Agent": "runbox-python/0.2.2"}
|
|
92
|
+
selected = self._resolve_identity(identity)
|
|
93
|
+
if selected is not None:
|
|
94
|
+
headers.update(selected.headers())
|
|
95
|
+
if self.api_key:
|
|
96
|
+
headers["Authorization"] = "Bearer " + self.api_key
|
|
97
|
+
if extra:
|
|
98
|
+
headers.update(extra)
|
|
99
|
+
return headers
|
|
100
|
+
|
|
101
|
+
def _url(self, path: str, query: Optional[Dict[str, str]] = None) -> str:
|
|
102
|
+
url = self.base_url + path
|
|
103
|
+
if query:
|
|
104
|
+
url += "?" + urllib.parse.urlencode(query, doseq=True)
|
|
105
|
+
return url
|
|
106
|
+
|
|
107
|
+
def _connection(self) -> http.client.HTTPConnection:
|
|
108
|
+
if self._scheme == "https":
|
|
109
|
+
return http.client.HTTPSConnection(self._host, self._port, timeout=self.timeout)
|
|
110
|
+
return http.client.HTTPConnection(self._host, self._port, timeout=self.timeout)
|
|
111
|
+
|
|
112
|
+
def _json(
|
|
113
|
+
self,
|
|
114
|
+
method: str,
|
|
115
|
+
path: str,
|
|
116
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
117
|
+
identity: Optional[Identity] = None,
|
|
118
|
+
timeout: Optional[float] = None,
|
|
119
|
+
) -> Any:
|
|
120
|
+
body = json.dumps(payload).encode() if payload is not None else None
|
|
121
|
+
extra = {"Content-Type": "application/json"} if body is not None else None
|
|
122
|
+
request = urllib.request.Request(
|
|
123
|
+
self._url(path),
|
|
124
|
+
data=body,
|
|
125
|
+
method=method,
|
|
126
|
+
headers=self._headers(identity, extra),
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
with urllib.request.urlopen(request, timeout=timeout or self.timeout) as response:
|
|
130
|
+
data = response.read()
|
|
131
|
+
except urllib.error.HTTPError as error:
|
|
132
|
+
raise _error(error.code, error.read()) from None
|
|
133
|
+
if not data:
|
|
134
|
+
return None
|
|
135
|
+
return json.loads(data)
|
|
136
|
+
|
|
137
|
+
def health(self) -> Dict[str, Any]:
|
|
138
|
+
return self._json("GET", "/health")
|
|
139
|
+
|
|
140
|
+
def capabilities(self) -> Dict[str, Any]:
|
|
141
|
+
return self._json("GET", "/v1/capabilities")
|
|
142
|
+
|
|
143
|
+
def exec(
|
|
144
|
+
self,
|
|
145
|
+
command: str,
|
|
146
|
+
timeout: int = 60,
|
|
147
|
+
files: Optional[Iterable[FileUpload]] = None,
|
|
148
|
+
identity: Optional[Identity] = None,
|
|
149
|
+
) -> Dict[str, Any]:
|
|
150
|
+
file_list = list(files or [])
|
|
151
|
+
request_timeout = max(self.timeout, timeout + 30)
|
|
152
|
+
if not file_list:
|
|
153
|
+
return self._json(
|
|
154
|
+
"POST",
|
|
155
|
+
"/v1/exec",
|
|
156
|
+
{"command": command, "timeout_seconds": timeout},
|
|
157
|
+
identity=identity,
|
|
158
|
+
timeout=request_timeout,
|
|
159
|
+
)
|
|
160
|
+
fields: List[Tuple[str, str]] = [("command", command), ("timeout_seconds", str(timeout))]
|
|
161
|
+
return self._multipart("/v1/exec", fields, file_list, identity, request_timeout)
|
|
162
|
+
|
|
163
|
+
def upload(self, name: str, data: bytes) -> Dict[str, Any]:
|
|
164
|
+
return self.upload_batch([FileUpload(name=name, data=data)])[0]
|
|
165
|
+
|
|
166
|
+
def upload_file(self, path: PathLike) -> Dict[str, Any]:
|
|
167
|
+
return self.upload_batch([FileUpload(name=os.path.basename(os.fspath(path)), path=path)])[0]
|
|
168
|
+
|
|
169
|
+
def upload_batch(
|
|
170
|
+
self,
|
|
171
|
+
files: Iterable[FileUpload],
|
|
172
|
+
identity: Optional[Identity] = None,
|
|
173
|
+
) -> List[Dict[str, Any]]:
|
|
174
|
+
uploaded = self._multipart("/v1/files/batch", [], list(files), identity, self.timeout)
|
|
175
|
+
return list(uploaded.get("files", []))
|
|
176
|
+
|
|
177
|
+
def list(self, path: str = "", identity: Optional[Identity] = None) -> List[Dict[str, Any]]:
|
|
178
|
+
query = {"path": path} if path else None
|
|
179
|
+
data = self._json("GET", self._url_path("/v1/list", query), identity=identity)
|
|
180
|
+
return list(data.get("entries", []))
|
|
181
|
+
|
|
182
|
+
def delete_workspace(self, identity: Optional[Identity] = None) -> None:
|
|
183
|
+
self._json("DELETE", "/v1/workspace", identity=identity)
|
|
184
|
+
|
|
185
|
+
def download_path(
|
|
186
|
+
self,
|
|
187
|
+
path: str,
|
|
188
|
+
destination: PathLike,
|
|
189
|
+
identity: Optional[Identity] = None,
|
|
190
|
+
range_header: Optional[str] = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
extra = {"Range": range_header} if range_header else None
|
|
193
|
+
self._download(self._url_path("/v1/path", {"path": path}), destination, identity, extra)
|
|
194
|
+
|
|
195
|
+
def download_archive(
|
|
196
|
+
self,
|
|
197
|
+
paths: Iterable[str],
|
|
198
|
+
destination: PathLike,
|
|
199
|
+
identity: Optional[Identity] = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
query = [("path", path) for path in paths]
|
|
202
|
+
if not query:
|
|
203
|
+
raise ValueError("at least one path is required")
|
|
204
|
+
self._download(self._url_path("/v1/archive", query), destination, identity)
|
|
205
|
+
|
|
206
|
+
def _download(
|
|
207
|
+
self,
|
|
208
|
+
path: str,
|
|
209
|
+
destination: PathLike,
|
|
210
|
+
identity: Optional[Identity],
|
|
211
|
+
extra: Optional[Dict[str, str]] = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
request = urllib.request.Request(
|
|
214
|
+
self.base_url + path,
|
|
215
|
+
headers=self._headers(identity, extra),
|
|
216
|
+
method="GET",
|
|
217
|
+
)
|
|
218
|
+
try:
|
|
219
|
+
response = urllib.request.urlopen(request, timeout=self.timeout)
|
|
220
|
+
except urllib.error.HTTPError as error:
|
|
221
|
+
raise _error(error.code, error.read()) from None
|
|
222
|
+
with response, open(destination, "wb") as output:
|
|
223
|
+
shutil.copyfileobj(response, output, length=1 << 20)
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _url_path(path: str, query: Any = None) -> str:
|
|
227
|
+
if not query:
|
|
228
|
+
return path
|
|
229
|
+
return path + "?" + urllib.parse.urlencode(query, doseq=True)
|
|
230
|
+
|
|
231
|
+
def _multipart(
|
|
232
|
+
self,
|
|
233
|
+
path: str,
|
|
234
|
+
fields: List[Tuple[str, str]],
|
|
235
|
+
files: List[FileUpload],
|
|
236
|
+
identity: Optional[Identity],
|
|
237
|
+
timeout: float,
|
|
238
|
+
) -> Any:
|
|
239
|
+
boundary = secrets.token_hex(16)
|
|
240
|
+
parts: List[_Part] = []
|
|
241
|
+
|
|
242
|
+
for name, value in fields:
|
|
243
|
+
body = value.encode()
|
|
244
|
+
header = (
|
|
245
|
+
f"--{boundary}\r\n"
|
|
246
|
+
f'Content-Disposition: form-data; name="{_escape(name)}"\r\n\r\n'
|
|
247
|
+
).encode()
|
|
248
|
+
parts.append(_Part(header=header, length=len(body), value=body))
|
|
249
|
+
|
|
250
|
+
for file in files:
|
|
251
|
+
safe_name = _escape(file.name)
|
|
252
|
+
header = (
|
|
253
|
+
f"--{boundary}\r\n"
|
|
254
|
+
f'Content-Disposition: form-data; name="files"; filename="{safe_name}"\r\n'
|
|
255
|
+
"Content-Type: application/octet-stream\r\n\r\n"
|
|
256
|
+
).encode()
|
|
257
|
+
parts.append(
|
|
258
|
+
_Part(
|
|
259
|
+
header=header,
|
|
260
|
+
length=int(file.size or 0),
|
|
261
|
+
value=b"",
|
|
262
|
+
path=file.path,
|
|
263
|
+
)
|
|
264
|
+
if file.path is not None
|
|
265
|
+
else _Part(header=header, length=len(file.data or b""), value=file.data or b"")
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
closing = f"\r\n--{boundary}--\r\n".encode()
|
|
269
|
+
total = sum(part.length + len(part.header) for part in parts) + len(closing)
|
|
270
|
+
total += sum(2 for _ in parts) # CRLF after each part body
|
|
271
|
+
|
|
272
|
+
connection = self._connection()
|
|
273
|
+
try:
|
|
274
|
+
connection.putrequest("POST", self._prefix + path)
|
|
275
|
+
headers = self._headers(
|
|
276
|
+
identity,
|
|
277
|
+
{"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
|
278
|
+
)
|
|
279
|
+
for key, value in headers.items():
|
|
280
|
+
connection.putheader(key, value)
|
|
281
|
+
connection.putheader("Content-Length", str(total))
|
|
282
|
+
connection.endheaders()
|
|
283
|
+
for part in parts:
|
|
284
|
+
connection.send(part.header)
|
|
285
|
+
if part.path is not None:
|
|
286
|
+
with open(part.path, "rb") as handle:
|
|
287
|
+
while True:
|
|
288
|
+
chunk = handle.read(1 << 20)
|
|
289
|
+
if not chunk:
|
|
290
|
+
break
|
|
291
|
+
connection.send(chunk)
|
|
292
|
+
else:
|
|
293
|
+
connection.send(part.value)
|
|
294
|
+
connection.send(b"\r\n")
|
|
295
|
+
connection.send(closing)
|
|
296
|
+
response = connection.getresponse()
|
|
297
|
+
body = response.read()
|
|
298
|
+
finally:
|
|
299
|
+
connection.close()
|
|
300
|
+
|
|
301
|
+
if response.status < 200 or response.status >= 300:
|
|
302
|
+
raise _error(response.status, body)
|
|
303
|
+
return json.loads(body) if body else None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _escape(value: str) -> str:
|
|
307
|
+
return value.replace("\\", "_").replace('"', "_").replace("\r", "_").replace("\n", "_")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _error(status: int, body: bytes) -> RunboxError:
|
|
311
|
+
code = f"http_{status}"
|
|
312
|
+
message = body.decode("utf-8", "replace").strip()
|
|
313
|
+
try:
|
|
314
|
+
payload = json.loads(body)
|
|
315
|
+
code = str(payload.get("code") or code)
|
|
316
|
+
message = str(payload.get("message") or message)
|
|
317
|
+
except (ValueError, TypeError):
|
|
318
|
+
pass
|
|
319
|
+
if not message or message.startswith("<"):
|
|
320
|
+
message = f"HTTP {status}"
|
|
321
|
+
return RunboxError(status, code, message[:2048])
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Errors raised by the Runbox client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RunboxError(Exception):
|
|
7
|
+
"""An HTTP error returned by the runner."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, status: int, code: str, message: str) -> None:
|
|
10
|
+
super().__init__(f"{status} {code}: {message}")
|
|
11
|
+
self.status = status
|
|
12
|
+
self.code = code
|
|
13
|
+
self.message = message
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IdentityError(ValueError):
|
|
17
|
+
"""An identity is missing, ambiguous or invalid."""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Runbox identity headers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from .errors import IdentityError
|
|
9
|
+
|
|
10
|
+
_SCOPED = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Identity:
|
|
15
|
+
"""Either a single token or a namespace + principal pair.
|
|
16
|
+
|
|
17
|
+
Supplying both forms, or only half of the scoped form, is an error.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
token: str = ""
|
|
21
|
+
namespace: str = ""
|
|
22
|
+
principal: str = ""
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
token = self.token.strip()
|
|
26
|
+
namespace = self.namespace.strip()
|
|
27
|
+
principal = self.principal.strip()
|
|
28
|
+
object.__setattr__(self, "token", token)
|
|
29
|
+
object.__setattr__(self, "namespace", namespace)
|
|
30
|
+
object.__setattr__(self, "principal", principal)
|
|
31
|
+
|
|
32
|
+
if token and (namespace or principal):
|
|
33
|
+
raise IdentityError("use either token or namespace+principal, not both")
|
|
34
|
+
if token:
|
|
35
|
+
if not (1 <= len(token) <= 256):
|
|
36
|
+
raise IdentityError("token must be 1-256 characters")
|
|
37
|
+
if any(char < "!" or char > "~" for char in token):
|
|
38
|
+
raise IdentityError("token must be visible ASCII without spaces")
|
|
39
|
+
return
|
|
40
|
+
if not namespace or not principal:
|
|
41
|
+
raise IdentityError("token or namespace+principal is required")
|
|
42
|
+
if not _SCOPED.fullmatch(namespace):
|
|
43
|
+
raise IdentityError("namespace must match [A-Za-z0-9._-]{1,128}")
|
|
44
|
+
if not _SCOPED.fullmatch(principal):
|
|
45
|
+
raise IdentityError("principal must match [A-Za-z0-9._-]{1,128}")
|
|
46
|
+
|
|
47
|
+
def headers(self) -> dict[str, str]:
|
|
48
|
+
if self.token:
|
|
49
|
+
return {"X-Runbox-Identity": self.token}
|
|
50
|
+
return {
|
|
51
|
+
"X-Runbox-Namespace": self.namespace,
|
|
52
|
+
"X-Runbox-Principal": self.principal,
|
|
53
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runbox-sdk
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Python client for Runbox, a lightweight sandbox for agent commands and files
|
|
5
|
+
Author: Runbox contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://runbox.xyun.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/xy200303/runbox
|
|
9
|
+
Project-URL: Documentation, https://github.com/xy200303/runbox/blob/main/docs/api.md
|
|
10
|
+
Keywords: runbox,sandbox,agent,code-execution
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Runbox Python SDK
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install runbox-sdk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from runbox import Client, Identity
|
|
27
|
+
|
|
28
|
+
client = Client(
|
|
29
|
+
"http://localhost:8099",
|
|
30
|
+
api_key="...",
|
|
31
|
+
identity=Identity(namespace="team-a", principal="user-42"),
|
|
32
|
+
)
|
|
33
|
+
result = client.exec('python3 -c "print(6 * 7)"')
|
|
34
|
+
print(result["stdout"])
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The client uses only the Python standard library. Uploads stream from disk;
|
|
38
|
+
downloads and tar archives stream to a destination path.
|
|
39
|
+
|
|
40
|
+
Full API reference: [docs/api.md](../../docs/api.md).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/runbox/__init__.py
|
|
5
|
+
src/runbox/client.py
|
|
6
|
+
src/runbox/errors.py
|
|
7
|
+
src/runbox/identity.py
|
|
8
|
+
src/runbox/py.typed
|
|
9
|
+
src/runbox_sdk.egg-info/PKG-INFO
|
|
10
|
+
src/runbox_sdk.egg-info/SOURCES.txt
|
|
11
|
+
src/runbox_sdk.egg-info/dependency_links.txt
|
|
12
|
+
src/runbox_sdk.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
14
|
+
tests/test_identity.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
runbox
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import tempfile
|
|
3
|
+
import threading
|
|
4
|
+
import unittest
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from runbox import Client, FileUpload, Identity, RunboxError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Handler(BaseHTTPRequestHandler):
|
|
12
|
+
requests = []
|
|
13
|
+
|
|
14
|
+
def log_message(self, *_args):
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
def _record(self):
|
|
18
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
19
|
+
body = self.rfile.read(length)
|
|
20
|
+
Handler.requests.append(
|
|
21
|
+
{
|
|
22
|
+
"method": self.command,
|
|
23
|
+
"path": self.path,
|
|
24
|
+
"headers": dict(self.headers),
|
|
25
|
+
"body": body,
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
return body
|
|
29
|
+
|
|
30
|
+
def do_POST(self):
|
|
31
|
+
body = self._record()
|
|
32
|
+
if self.path == "/v1/exec":
|
|
33
|
+
payload = json.loads(body)
|
|
34
|
+
self._json(200, {"exit_code": 0, "stdout": payload["command"], "files": []})
|
|
35
|
+
return
|
|
36
|
+
if self.path == "/v1/files/batch":
|
|
37
|
+
self._json(
|
|
38
|
+
200,
|
|
39
|
+
{
|
|
40
|
+
"files": [
|
|
41
|
+
{"path": "files/a.txt", "name": "a.txt", "size": 3},
|
|
42
|
+
{"path": "files/b.txt", "name": "b.txt", "size": 3},
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
return
|
|
47
|
+
self._json(404, {"code": "not_found", "message": "not found"})
|
|
48
|
+
|
|
49
|
+
def do_GET(self):
|
|
50
|
+
self._record()
|
|
51
|
+
if self.path.startswith("/v1/path"):
|
|
52
|
+
if "missing" in self.path:
|
|
53
|
+
self._json(404, {"code": "not_found", "message": "not found"})
|
|
54
|
+
return
|
|
55
|
+
self._bytes(200, b"path-data")
|
|
56
|
+
return
|
|
57
|
+
if self.path.startswith("/v1/archive"):
|
|
58
|
+
self._bytes(200, b"tar-data")
|
|
59
|
+
return
|
|
60
|
+
if self.path.startswith("/v1/list"):
|
|
61
|
+
self._json(200, {"entries": [{"name": "out", "is_dir": True}]})
|
|
62
|
+
return
|
|
63
|
+
if self.path == "/health":
|
|
64
|
+
self._json(200, {"status": "healthy"})
|
|
65
|
+
return
|
|
66
|
+
self._json(404, {"code": "not_found", "message": "not found"})
|
|
67
|
+
|
|
68
|
+
def do_DELETE(self):
|
|
69
|
+
self._record()
|
|
70
|
+
self._json(200, {"deleted": True})
|
|
71
|
+
|
|
72
|
+
def _json(self, status, payload):
|
|
73
|
+
body = json.dumps(payload).encode()
|
|
74
|
+
self.send_response(status)
|
|
75
|
+
self.send_header("Content-Type", "application/json")
|
|
76
|
+
self.send_header("Content-Length", str(len(body)))
|
|
77
|
+
self.end_headers()
|
|
78
|
+
self.wfile.write(body)
|
|
79
|
+
|
|
80
|
+
def _bytes(self, status, body):
|
|
81
|
+
self.send_response(status)
|
|
82
|
+
self.send_header("Content-Type", "application/octet-stream")
|
|
83
|
+
self.send_header("Content-Length", str(len(body)))
|
|
84
|
+
self.end_headers()
|
|
85
|
+
self.wfile.write(body)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ClientTest(unittest.TestCase):
|
|
89
|
+
@classmethod
|
|
90
|
+
def setUpClass(cls):
|
|
91
|
+
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
92
|
+
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
|
93
|
+
cls.thread.start()
|
|
94
|
+
host, port = cls.server.server_address
|
|
95
|
+
cls.client = Client(
|
|
96
|
+
f"http://{host}:{port}",
|
|
97
|
+
api_key="test-key",
|
|
98
|
+
identity=Identity(namespace="team-a", principal="user-42"),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def tearDownClass(cls):
|
|
103
|
+
cls.server.shutdown()
|
|
104
|
+
cls.server.server_close()
|
|
105
|
+
|
|
106
|
+
def setUp(self):
|
|
107
|
+
Handler.requests.clear()
|
|
108
|
+
|
|
109
|
+
def test_exec_sends_auth_and_identity(self):
|
|
110
|
+
result = self.client.exec("echo hello")
|
|
111
|
+
self.assertEqual(result["stdout"], "echo hello")
|
|
112
|
+
request = Handler.requests[0]
|
|
113
|
+
self.assertEqual(request["headers"]["Authorization"], "Bearer test-key")
|
|
114
|
+
self.assertEqual(request["headers"]["X-Runbox-Namespace"], "team-a")
|
|
115
|
+
self.assertEqual(request["headers"]["X-Runbox-Principal"], "user-42")
|
|
116
|
+
|
|
117
|
+
def test_batch_upload_streams_files(self):
|
|
118
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
119
|
+
first = Path(directory, "a.txt")
|
|
120
|
+
second = Path(directory, "b.txt")
|
|
121
|
+
first.write_bytes(b"aaa")
|
|
122
|
+
second.write_bytes(b"bbb")
|
|
123
|
+
uploaded = self.client.upload_batch(
|
|
124
|
+
[FileUpload("a.txt", path=first), FileUpload("b.txt", path=second)]
|
|
125
|
+
)
|
|
126
|
+
self.assertEqual([item["path"] for item in uploaded], ["files/a.txt", "files/b.txt"])
|
|
127
|
+
request = Handler.requests[0]
|
|
128
|
+
self.assertIn(b"aaa", request["body"])
|
|
129
|
+
self.assertIn(b"bbb", request["body"])
|
|
130
|
+
self.assertEqual(int(request["headers"]["Content-Length"]), len(request["body"]))
|
|
131
|
+
|
|
132
|
+
def test_download_path_and_archive(self):
|
|
133
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
134
|
+
path_file = Path(directory, "path.bin")
|
|
135
|
+
archive_file = Path(directory, "archive.tar")
|
|
136
|
+
self.client.download_path("out/report.txt", path_file)
|
|
137
|
+
self.client.download_archive(["out", "tmp"], archive_file)
|
|
138
|
+
self.assertEqual(path_file.read_bytes(), b"path-data")
|
|
139
|
+
self.assertEqual(archive_file.read_bytes(), b"tar-data")
|
|
140
|
+
|
|
141
|
+
def test_server_error_is_typed(self):
|
|
142
|
+
with self.assertRaises(RunboxError) as caught:
|
|
143
|
+
self.client.download_path("missing", Path(tempfile.gettempdir(), "missing.bin"))
|
|
144
|
+
self.assertEqual(caught.exception.status, 404)
|
|
145
|
+
self.assertEqual(caught.exception.code, "not_found")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
unittest.main()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from runbox import Identity, IdentityError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IdentityTest(unittest.TestCase):
|
|
7
|
+
def test_token_headers(self):
|
|
8
|
+
identity = Identity(token="01J-token")
|
|
9
|
+
self.assertEqual(identity.headers(), {"X-Runbox-Identity": "01J-token"})
|
|
10
|
+
|
|
11
|
+
def test_scoped_headers(self):
|
|
12
|
+
identity = Identity(namespace="team-a", principal="user-42")
|
|
13
|
+
self.assertEqual(
|
|
14
|
+
identity.headers(),
|
|
15
|
+
{
|
|
16
|
+
"X-Runbox-Namespace": "team-a",
|
|
17
|
+
"X-Runbox-Principal": "user-42",
|
|
18
|
+
},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def test_ambiguous_is_rejected(self):
|
|
22
|
+
with self.assertRaises(IdentityError):
|
|
23
|
+
Identity(token="alice", namespace="team-a", principal="alice")
|
|
24
|
+
|
|
25
|
+
def test_partial_scoped_is_rejected(self):
|
|
26
|
+
with self.assertRaises(IdentityError):
|
|
27
|
+
Identity(namespace="team-a")
|
|
28
|
+
|
|
29
|
+
def test_invalid_characters_are_rejected(self):
|
|
30
|
+
with self.assertRaises(IdentityError):
|
|
31
|
+
Identity(namespace="team a", principal="alice")
|
|
32
|
+
with self.assertRaises(IdentityError):
|
|
33
|
+
Identity(token="a b")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
unittest.main()
|