runbox-sdk 0.2.2__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.
runbox/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Python client for Runbox."""
2
+
3
+ from .client import Client, FileUpload
4
+ from .errors import IdentityError, RunboxError
5
+ from .identity import Identity
6
+
7
+ __all__ = ["Client", "FileUpload", "Identity", "IdentityError", "RunboxError"]
8
+ __version__ = "0.2.2"
runbox/client.py ADDED
@@ -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])
runbox/errors.py ADDED
@@ -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."""
runbox/identity.py ADDED
@@ -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
+ }
runbox/py.typed ADDED
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,10 @@
1
+ runbox/__init__.py,sha256=T2xEojg0fFtcdjux8ig8PWeH1Hgm7A0m_HPSd_CFqoI,252
2
+ runbox/client.py,sha256=qtTm4rNTaULgJOlA85nXwzl45N6XmNTir-5auLCl7Cw,11057
3
+ runbox/errors.py,sha256=2pC-t8AJIFVDvn79MK_7ile3_95jS3EesNkLvOmMr14,461
4
+ runbox/identity.py,sha256=Bqp4q1oQO9O8HJY1B_BreY24xNOMpou4HLcyA06CuX0,1814
5
+ runbox/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ runbox_sdk-0.2.2.dist-info/licenses/LICENSE,sha256=_hj85qwwjIVdzYTjaYl37wYK9X5bQb9BnhOCuBTYy_8,1076
7
+ runbox_sdk-0.2.2.dist-info/METADATA,sha256=IyftWhFjNl1oY3mLxACPunEBtsOFat-6ZTo3z_SrP3k,1175
8
+ runbox_sdk-0.2.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ runbox_sdk-0.2.2.dist-info/top_level.txt,sha256=EMLOV2WtQLy3IjanBZhhZ9k2LLVESOUzbq6jGG082LM,7
10
+ runbox_sdk-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ runbox