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.
- hiver_py-0.1.15/.gitignore +9 -0
- hiver_py-0.1.15/PKG-INFO +12 -0
- hiver_py-0.1.15/examples/python_exec_stream.py +50 -0
- hiver_py-0.1.15/examples/python_exec_tty.py +51 -0
- hiver_py-0.1.15/pyproject.toml +29 -0
- hiver_py-0.1.15/src/hiver/__init__.py +67 -0
- hiver_py-0.1.15/src/hiver/controller.py +237 -0
- hiver_py-0.1.15/src/hiver/sandbox.py +398 -0
- hiver_py-0.1.15/src/hiver/schemas.py +248 -0
- hiver_py-0.1.15/src/hiver/sse.py +56 -0
- hiver_py-0.1.15/src/hiver/utils.py +23 -0
- hiver_py-0.1.15/tests/test_controller.py +170 -0
- hiver_py-0.1.15/tests/test_sandbox.py +346 -0
- hiver_py-0.1.15/uv.lock +330 -0
hiver_py-0.1.15/PKG-INFO
ADDED
|
@@ -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
|