hiver-py 0.1.15__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.
@@ -0,0 +1,9 @@
1
+ node_modules
2
+ **/__pycache__
3
+ .venv
4
+ .pytest_cache
5
+ .env.local
6
+ .env.*.local
7
+ .DS_Store
8
+ bin
9
+ dist
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: hiver-py
3
+ Version: 0.1.15
4
+ Summary: Python client for the Hiver runtime.
5
+ Project-URL: Homepage, https://hiver.sh
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: httpx>=0.28
8
+ Requires-Dist: pydantic>=2.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest-asyncio>=0.25; extra == 'dev'
11
+ Requires-Dist: pytest>=8.0; extra == 'dev'
12
+ Requires-Dist: respx>=0.22; extra == 'dev'
@@ -0,0 +1,50 @@
1
+ # Run a Python function inside the sandbox and stream its output via SSE.
2
+ #
3
+ # Run with: python examples/python_exec_stream.py
4
+ import asyncio
5
+ import sys
6
+
7
+ import hiver
8
+
9
+ SCRIPT = """
10
+ import sys, time
11
+
12
+ def greet(name):
13
+ print(f"Hello, {name}!", flush=True)
14
+ time.sleep(0.5)
15
+ print(f"Hello, {name} again after 500ms!", flush=True)
16
+ time.sleep(0.5)
17
+ print("Bye!", file=sys.stderr, flush=True)
18
+
19
+ greet("world")
20
+ """.strip()
21
+
22
+
23
+ async def main() -> None:
24
+ sandbox = await hiver.get_or_create_sandbox(
25
+ "hive-python-exec-stream",
26
+ hiver.SandboxConfig(
27
+ image="hiversh/python:3.13-alpine",
28
+ entrypoint="tail -f /dev/null",
29
+ fs=[
30
+ hiver.LocalFileSystem(
31
+ backend="local",
32
+ mount="/workspace",
33
+ acls=[hiver.ACLRule(path="/workspace/**", access="rw")],
34
+ )
35
+ ],
36
+ ),
37
+ )
38
+
39
+ exec = await sandbox.exec_stream(f"python3 -c '{SCRIPT}'", cwd="/workspace")
40
+
41
+ async for pipe in exec.pipes:
42
+ if "stdout" in pipe:
43
+ sys.stdout.write("stdout: " + pipe["stdout"])
44
+ if "stderr" in pipe:
45
+ sys.stderr.write("stderr: " + pipe["stderr"])
46
+
47
+ print("exit code:", await exec.exit_code)
48
+
49
+
50
+ asyncio.run(main())
@@ -0,0 +1,51 @@
1
+ # Run an interactive Python REPL inside the sandbox with a TTY, writing to
2
+ # stdin to drive it programmatically.
3
+ #
4
+ # Run with: python examples/python_exec_tty.py
5
+ import asyncio
6
+ import sys
7
+
8
+ import hiver
9
+
10
+ LINES = [
11
+ "x = 6 * 7",
12
+ "print('the answer is', x)",
13
+ "exit()",
14
+ ]
15
+
16
+
17
+ async def main() -> None:
18
+ sandbox = await hiver.get_or_create_sandbox(
19
+ "hive-python-exec-tty",
20
+ hiver.SandboxConfig(
21
+ image="hiversh/python:3.13-alpine",
22
+ entrypoint="tail -f /dev/null",
23
+ fs=[
24
+ hiver.LocalFileSystem(
25
+ backend="local",
26
+ mount="/workspace",
27
+ acls=[hiver.ACLRule(path="/workspace/**", access="rw")],
28
+ )
29
+ ],
30
+ ttl=0,
31
+ ),
32
+ )
33
+
34
+ exec = await sandbox.exec_stream("python3", cwd="/workspace", tty=True)
35
+
36
+ async def feed_stdin() -> None:
37
+ for line in LINES:
38
+ await exec.write_stdin(line + "\r")
39
+
40
+ asyncio.create_task(feed_stdin())
41
+
42
+ async for pipe in exec.pipes:
43
+ if "stdout" in pipe:
44
+ sys.stdout.write(pipe["stdout"])
45
+ if "stderr" in pipe:
46
+ sys.stderr.write(pipe["stderr"])
47
+
48
+ print("\nexit code:", await exec.exit_code)
49
+
50
+
51
+ asyncio.run(main())
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "hiver-py"
3
+ version = "0.1.15"
4
+ description = "Python client for the Hiver runtime."
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "httpx>=0.28",
8
+ "pydantic>=2.0",
9
+ ]
10
+
11
+ [project.optional-dependencies]
12
+ dev = [
13
+ "pytest>=8.0",
14
+ "pytest-asyncio>=0.25",
15
+ "respx>=0.22",
16
+ ]
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/hiver"]
24
+
25
+ [tool.pytest.ini_options]
26
+ asyncio_mode = "auto"
27
+
28
+ [project.urls]
29
+ Homepage = "https://hiver.sh"
@@ -0,0 +1,67 @@
1
+ from .controller import DEFAULT_GATEWAY_URL, get_or_create_sandbox, list_sandboxes, shutdown
2
+ from .sandbox import ExecProcess, Sandbox, SandboxError
3
+ from .schemas import (
4
+ ACLRule,
5
+ ApplyResult,
6
+ Changes,
7
+ ConfigApplyEvent,
8
+ EgressChunkEvent,
9
+ EgressOverride,
10
+ EgressRequestEvent,
11
+ EgressResponseEvent,
12
+ EgressRule,
13
+ ExecRequestEvent,
14
+ ExecResponseEvent,
15
+ FSRequestEvent,
16
+ FSResponseEvent,
17
+ FileSystem,
18
+ GCSFileSystem,
19
+ GDriveFileSystem,
20
+ HttpMethod,
21
+ LocalFileSystem,
22
+ ResourceUsageEvent,
23
+ SandboxConfig,
24
+ SandboxEvent,
25
+ SandboxRef,
26
+ StdioEvent,
27
+ )
28
+ from .utils import allowed_npm_packages, allowed_python_packages
29
+
30
+ __all__ = [
31
+ # controller
32
+ "DEFAULT_GATEWAY_URL",
33
+ "get_or_create_sandbox",
34
+ "list_sandboxes",
35
+ "shutdown",
36
+ # sandbox
37
+ "ExecProcess",
38
+ "Sandbox",
39
+ "SandboxError",
40
+ # schemas
41
+ "ACLRule",
42
+ "ApplyResult",
43
+ "Changes",
44
+ "ConfigApplyEvent",
45
+ "EgressChunkEvent",
46
+ "EgressOverride",
47
+ "EgressRequestEvent",
48
+ "EgressResponseEvent",
49
+ "EgressRule",
50
+ "ExecRequestEvent",
51
+ "ExecResponseEvent",
52
+ "FSRequestEvent",
53
+ "FSResponseEvent",
54
+ "FileSystem",
55
+ "GCSFileSystem",
56
+ "GDriveFileSystem",
57
+ "HttpMethod",
58
+ "LocalFileSystem",
59
+ "ResourceUsageEvent",
60
+ "SandboxConfig",
61
+ "SandboxEvent",
62
+ "SandboxRef",
63
+ "StdioEvent",
64
+ # utils
65
+ "allowed_npm_packages",
66
+ "allowed_python_packages",
67
+ ]
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import re
6
+ from typing import AsyncGenerator, Optional
7
+
8
+ import httpx
9
+
10
+ from .sandbox import Sandbox, SandboxError, _to_error
11
+ from .schemas import SandboxConfig, SandboxRef
12
+ from .sse import parse_sse
13
+
14
+ DEFAULT_GATEWAY_URL = "http://localhost:10000"
15
+
16
+ _SANDBOX_KEY_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
17
+
18
+ _DEFAULT_TIMEOUT_S = 30.0
19
+ _READINESS_POLL_INTERVAL_S = 0.2
20
+
21
+
22
+ async def get_or_create_sandbox(
23
+ key: str,
24
+ config: SandboxConfig = SandboxConfig(),
25
+ gateway_url: str = DEFAULT_GATEWAY_URL,
26
+ client: Optional[httpx.AsyncClient] = None,
27
+ timeout_s: float = _DEFAULT_TIMEOUT_S,
28
+ ) -> Sandbox:
29
+ """
30
+ Idempotent provision against PUT /v1/sandboxes/{key}. If a sandbox
31
+ with `key` already exists the controller returns it unchanged and
32
+ the supplied `config` is ignored; otherwise the controller creates
33
+ a new sandbox from `config`.
34
+
35
+ `config` is validated before the request is sent — a bad config
36
+ fails fast on the caller side instead of producing a 400 from the
37
+ controller.
38
+ """
39
+ if not _SANDBOX_KEY_PATTERN.match(key):
40
+ raise ValueError(
41
+ f"get_or_create_sandbox: key {key!r} must match {_SANDBOX_KEY_PATTERN.pattern}"
42
+ )
43
+ data = {
44
+ "fs": [
45
+ {
46
+ "backend": "local",
47
+ "mount": "/workspace",
48
+ "acls": [{"path": "/workspace/**", "access": "rw"}],
49
+ }
50
+ ],
51
+ **config.model_dump(exclude_none=True),
52
+ }
53
+ validated = SandboxConfig.model_validate(data)
54
+ base = gateway_url.rstrip("/")
55
+ owns_client = client is None
56
+ http = client or httpx.AsyncClient()
57
+ req_timeout = timeout_s if timeout_s > 0 else None
58
+
59
+ try:
60
+ try:
61
+ res = await http.put(
62
+ f"{base}/controller/v1/sandboxes/{key}",
63
+ json=validated.model_dump(exclude_none=True),
64
+ timeout=req_timeout,
65
+ )
66
+ except httpx.ConnectError as err:
67
+ if _is_connection_refused(err):
68
+ raise SandboxError(
69
+ "get_or_create_sandbox",
70
+ 0,
71
+ f"gateway is not reachable at {base} (connection refused). Is it running?",
72
+ ) from err
73
+ raise
74
+
75
+ if res.status_code not in (200, 201):
76
+ text = res.text
77
+ body: Optional[dict] = None
78
+ try:
79
+ parsed = json.loads(text)
80
+ if isinstance(parsed, dict) and "error" in parsed:
81
+ body = parsed
82
+ except Exception:
83
+ pass
84
+ raise SandboxError(
85
+ "get_or_create_sandbox",
86
+ res.status_code,
87
+ (body or {}).get("error") or text or str(res.status_code),
88
+ body,
89
+ )
90
+
91
+ ref = SandboxRef.model_validate(res.json())
92
+ sandbox = Sandbox(ref, base, client=http if not owns_client else None)
93
+ if timeout_s > 0:
94
+ await _wait_until_reachable(sandbox, timeout_s)
95
+ return sandbox
96
+ except Exception:
97
+ if owns_client:
98
+ await http.aclose()
99
+ raise
100
+
101
+
102
+ async def list_sandboxes(
103
+ gateway_url: str = DEFAULT_GATEWAY_URL,
104
+ client: Optional[httpx.AsyncClient] = None,
105
+ timeout_s: float = _DEFAULT_TIMEOUT_S,
106
+ ) -> list[Sandbox]:
107
+ """Return all currently running sandboxes."""
108
+ base = gateway_url.rstrip("/")
109
+ owns_client = client is None
110
+ http = client or httpx.AsyncClient()
111
+ req_timeout = timeout_s if timeout_s > 0 else None
112
+
113
+ try:
114
+ try:
115
+ res = await http.get(f"{base}/controller/v1/sandboxes", timeout=req_timeout)
116
+ except httpx.ConnectError as err:
117
+ if _is_connection_refused(err):
118
+ raise SandboxError(
119
+ "list_sandboxes",
120
+ 0,
121
+ f"gateway is not reachable at {base} (connection refused). Is it running?",
122
+ ) from err
123
+ raise
124
+
125
+ if res.status_code != 200:
126
+ raise _to_error(res, "list_sandboxes")
127
+
128
+ refs = [SandboxRef.model_validate(r) for r in res.json()]
129
+ return [Sandbox(ref, base, client=http if not owns_client else None) for ref in refs]
130
+ except Exception:
131
+ if owns_client:
132
+ await http.aclose()
133
+ raise
134
+
135
+
136
+ async def shutdown(
137
+ sandbox: Sandbox,
138
+ gateway_url: str = DEFAULT_GATEWAY_URL,
139
+ client: Optional[httpx.AsyncClient] = None,
140
+ timeout_s: float = _DEFAULT_TIMEOUT_S,
141
+ ) -> None:
142
+ """Stop the sandbox container and remove it."""
143
+ base = gateway_url.rstrip("/")
144
+ owns_client = client is None
145
+ http = client or httpx.AsyncClient()
146
+ req_timeout = timeout_s if timeout_s > 0 else None
147
+ url = f"{base}/controller/v1/shutdown/{sandbox.key}"
148
+ try:
149
+ res = await http.post(url, timeout=req_timeout)
150
+ except httpx.ConnectError as err:
151
+ if _is_connection_refused(err):
152
+ raise SandboxError(
153
+ "shutdown",
154
+ 0,
155
+ f"gateway is not reachable at {base} (connection refused). Is it running?",
156
+ ) from err
157
+ raise
158
+ finally:
159
+ if owns_client:
160
+ await http.aclose()
161
+
162
+ if res.status_code == 204:
163
+ return
164
+
165
+ raise _to_error(res, "shutdown")
166
+
167
+
168
+ async def watch_sandbox_events(
169
+ gateway_url: str = DEFAULT_GATEWAY_URL,
170
+ client: Optional[httpx.AsyncClient] = None,
171
+ abort: Optional[asyncio.Event] = None,
172
+ ) -> AsyncGenerator[dict, None]:
173
+ """Stream sandbox lifecycle events via SSE from GET /v1/sandboxes/events.
174
+
175
+ Yields dicts of the form
176
+ ``{"id": "<uuid>", "key": "<key>", "status": "start|stop|die|destroy"}``
177
+ until *abort* is set or the server closes the stream.
178
+ """
179
+ base = gateway_url.rstrip("/")
180
+ owns_client = client is None
181
+ http = client or httpx.AsyncClient()
182
+
183
+ try:
184
+ async with http.stream(
185
+ "GET",
186
+ f"{base}/controller/v1/sandboxes/events",
187
+ headers={"Accept": "text/event-stream"},
188
+ ) as res:
189
+ if res.status_code != 200:
190
+ raise SandboxError(
191
+ "watch_sandbox_events",
192
+ res.status_code,
193
+ (await res.aread()).decode(),
194
+ )
195
+ async for frame in parse_sse(res, abort):
196
+ try:
197
+ yield json.loads(frame.data)
198
+ except Exception:
199
+ pass
200
+ except httpx.ConnectError as err:
201
+ if _is_connection_refused(err):
202
+ raise SandboxError(
203
+ "watch_sandbox_events",
204
+ 0,
205
+ f"gateway is not reachable at {base} (connection refused). Is it running?",
206
+ ) from err
207
+ raise
208
+ finally:
209
+ if owns_client:
210
+ await http.aclose()
211
+
212
+
213
+ async def _wait_until_reachable(sandbox: Sandbox, timeout_s: float) -> None:
214
+ loop = asyncio.get_event_loop()
215
+ deadline = loop.time() + timeout_s
216
+ last_err: Optional[Exception] = None
217
+
218
+ while loop.time() < deadline:
219
+ try:
220
+ await sandbox.ping()
221
+ return
222
+ except Exception as err:
223
+ last_err = err
224
+ await asyncio.sleep(_READINESS_POLL_INTERVAL_S)
225
+
226
+ detail = f": {last_err}" if last_err else ""
227
+ raise SandboxError(
228
+ "get_or_create_sandbox",
229
+ 0,
230
+ f"sandbox {sandbox.id} did not become reachable at {sandbox.api_server_url} "
231
+ f"within {timeout_s:.0f}s{detail}",
232
+ )
233
+
234
+
235
+ def _is_connection_refused(err: httpx.ConnectError) -> bool:
236
+ msg = str(err).lower()
237
+ return "connection refused" in msg or "econnrefused" in msg