numinous 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.
numinous-0.1.0/LICENSE ADDED
@@ -0,0 +1,6 @@
1
+ Apache License 2.0 — Copyright 2026 Numinous
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not
4
+ use this software except in compliance with the License. You may obtain a
5
+ copy at http://www.apache.org/licenses/LICENSE-2.0. Distributed on an
6
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.5
2
+ Name: numinous
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Numinous Cloud sandboxes
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: httpx>=0.24
@@ -0,0 +1,51 @@
1
+ # numinous
2
+
3
+ Python SDK for [Numinous Cloud](https://cloud.numinous.technology) — build &
4
+ test environments with hard TTLs, typed failure causes,
5
+ export-that-survives-teardown, and per-second metering. Everything
6
+ non-preemptible.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ curl -fsSL https://cloud.numinous.technology/install.sh | sh
12
+ ```
13
+
14
+ Installs the `numinous` CLI + Python SDK (via pipx/uv/pip, whichever exists).
15
+
16
+ ## Use
17
+
18
+ ```python
19
+ from numinous import Numinous
20
+
21
+ nc = Numinous() # NUMINOUS_API_URL / NUMINOUS_API_KEY from env
22
+
23
+ tpl = nc.templates.pack("build-env", image="ubuntu:24.04",
24
+ warm_cmd="apt-get update && apt-get install -y build-essential")
25
+
26
+ sb = nc.sandboxes.create(template_id=tpl["id"], vcpu=4, mem_gib=8,
27
+ ttl_seconds=7200, launch_token="job-1-attempt-1",
28
+ labels={"trial_id": "tr_1"})
29
+
30
+ nc.sandboxes.exec(sb["id"], "make -j test")
31
+ nc.sandboxes.export(sb["id"], "/logs", to="s3://bucket/tr_1/") # works after death too
32
+ out = nc.sandboxes.destroy(sb["id"])
33
+ out["teardown_proof"] # {"verified_absent": true, ...}
34
+ nc.usage.query(label="trial_id:tr_1") # per-second spans, typed unbilled faults
35
+ ```
36
+
37
+ ## Typed errors
38
+
39
+ ```python
40
+ from numinous import Numinous, NuminousError
41
+
42
+ try:
43
+ nc.sandboxes.create(...)
44
+ except NuminousError as e:
45
+ e.cause # "provider_capacity" | "user_image_build_failed" | ...
46
+ e.is_provider_fault # provider_* causes are never billed
47
+ e.retryable
48
+ ```
49
+
50
+ API reference: the control plane serves its own OpenAPI spec at
51
+ `GET /openapi.json`.
@@ -0,0 +1,33 @@
1
+ #!/bin/sh
2
+ # Numinous Cloud CLI installer
3
+ # curl -fsSL https://cloud.numinous.technology/install.sh | sh
4
+ set -eu
5
+
6
+ REPO="https://github.com/numinous-technology/numinous-python"
7
+ BIN_DIR="${NUMINOUS_BIN:-$HOME/.local/bin}"
8
+
9
+ say() { printf '\033[1;34mnuminous\033[0m %s\n' "$*"; }
10
+
11
+ command -v python3 >/dev/null 2>&1 || {
12
+ echo "python3 is required" >&2; exit 1; }
13
+
14
+ say "installing CLI from $REPO"
15
+
16
+ if command -v pipx >/dev/null 2>&1; then
17
+ pipx install --force "numinous @ git+$REPO" >/dev/null
18
+ elif command -v uv >/dev/null 2>&1; then
19
+ uv tool install --force "numinous @ git+$REPO" >/dev/null
20
+ else
21
+ python3 -m pip install --user --upgrade "numinous @ git+$REPO" >/dev/null
22
+ fi
23
+
24
+ mkdir -p "$BIN_DIR"
25
+ case ":$PATH:" in
26
+ *":$BIN_DIR:"*) ;;
27
+ *) say "add $BIN_DIR to your PATH" ;;
28
+ esac
29
+
30
+ say "installed. next:"
31
+ say ' export NUMINOUS_API_URL=... NUMINOUS_API_KEY=...'
32
+ say ' numinous capacity'
33
+ say ' numinous template pack --name build-env --image ubuntu:24.04'
@@ -0,0 +1,17 @@
1
+ """numinous — Python SDK for the Numinous Cloud sandbox API.
2
+
3
+ >>> from numinous import Numinous
4
+ >>> nc = Numinous() # NUMINOUS_API_URL / NUMINOUS_API_KEY from env
5
+ >>> tpl = nc.templates.pack("build-env", image="ubuntu:24.04",
6
+ ... warm_cmd="apt-get update && apt-get install -y build-essential")
7
+ >>> sb = nc.sandboxes.create(template_id=tpl["id"], vcpu=4, mem_gib=8,
8
+ ... ttl_seconds=7200, labels={"trial_id": "tr_1"})
9
+ >>> nc.sandboxes.exec(sb["id"], "make -j test")
10
+ >>> nc.sandboxes.export(sb["id"], "/logs", to="s3://bucket/tr_1/")
11
+ >>> nc.sandboxes.destroy(sb["id"])
12
+ """
13
+
14
+ from .client import Numinous, NuminousError
15
+
16
+ __all__ = ["Numinous", "NuminousError"]
17
+ __version__ = "0.1.0"
@@ -0,0 +1,110 @@
1
+ """numinous — CLI for Numinous Cloud.
2
+
3
+ Install: curl -fsSL https://cloud.numinous.technology/install.sh | sh
4
+ Auth: export NUMINOUS_API_URL=... NUMINOUS_API_KEY=...
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+
13
+ from .client import Numinous, NuminousError
14
+
15
+
16
+ def _out(obj) -> None:
17
+ print(json.dumps(obj, indent=2, default=str))
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ p = argparse.ArgumentParser(prog="numinous", description=__doc__)
22
+ sub = p.add_subparsers(dest="cmd", required=True)
23
+
24
+ tp = sub.add_parser("template", help="manage templates")
25
+ tps = tp.add_subparsers(dest="tcmd", required=True)
26
+ pack = tps.add_parser("pack", help="pack an environment into a template")
27
+ pack.add_argument("--name", required=True)
28
+ pack.add_argument("--image", help="base docker image")
29
+ pack.add_argument("--warm", help="command to run before snapshotting")
30
+ tps.add_parser("list", help="list templates")
31
+
32
+ boot = sub.add_parser("boot", help="boot sandboxes from a template")
33
+ boot.add_argument("template_id")
34
+ boot.add_argument("--vcpu", type=int, default=2)
35
+ boot.add_argument("--mem", type=float, default=4.0, help="GiB")
36
+ boot.add_argument("--ttl", type=int, default=0, help="seconds; 0=default")
37
+ boot.add_argument("--count", type=int, default=1)
38
+ boot.add_argument("--label", action="append", default=[], help="k=v")
39
+ boot.add_argument("--token", help="launch token (idempotency)")
40
+
41
+ ex = sub.add_parser("exec", help="run a command in a sandbox")
42
+ ex.add_argument("sandbox_id")
43
+ ex.add_argument("command")
44
+ ex.add_argument("--timeout", type=float, default=300)
45
+
46
+ for name in ("suspend", "resume", "destroy"):
47
+ c = sub.add_parser(name, help=f"{name} a sandbox")
48
+ c.add_argument("sandbox_id")
49
+
50
+ exp = sub.add_parser("export", help="export a path (works after death)")
51
+ exp.add_argument("sandbox_id")
52
+ exp.add_argument("path")
53
+ exp.add_argument("--to", help="s3://bucket/prefix")
54
+
55
+ ls = sub.add_parser("ls", help="list sandboxes")
56
+ ls.add_argument("--label")
57
+ ls.add_argument("--state")
58
+
59
+ us = sub.add_parser("usage", help="usage spans by label")
60
+ us.add_argument("--label")
61
+
62
+ sub.add_parser("capacity", help="free capacity")
63
+ sub.add_parser("pricing", help="current rates")
64
+
65
+ a = p.parse_args(argv)
66
+ nc = Numinous()
67
+ try:
68
+ if a.cmd == "template" and a.tcmd == "pack":
69
+ _out(nc.templates.pack(a.name, image=a.image, warm_cmd=a.warm))
70
+ elif a.cmd == "template" and a.tcmd == "list":
71
+ _out(nc.templates.list())
72
+ elif a.cmd == "boot":
73
+ labels = dict(kv.split("=", 1) for kv in a.label)
74
+ out = []
75
+ for i in range(a.count):
76
+ tok = f"{a.token}-{i}" if a.token and a.count > 1 else a.token
77
+ out.append(nc.sandboxes.create(
78
+ template_id=a.template_id, vcpu=a.vcpu, mem_gib=a.mem,
79
+ ttl_seconds=a.ttl, labels=labels, launch_token=tok))
80
+ _out(out if a.count > 1 else out[0])
81
+ elif a.cmd == "exec":
82
+ r = nc.sandboxes.exec(a.sandbox_id, a.command, timeout_sec=a.timeout)
83
+ sys.stdout.write(r["stdout"])
84
+ sys.stderr.write(r["stderr"])
85
+ return r["exit_code"]
86
+ elif a.cmd == "suspend":
87
+ _out(nc.sandboxes.suspend(a.sandbox_id))
88
+ elif a.cmd == "resume":
89
+ _out(nc.sandboxes.resume(a.sandbox_id))
90
+ elif a.cmd == "destroy":
91
+ _out(nc.sandboxes.destroy(a.sandbox_id))
92
+ elif a.cmd == "export":
93
+ _out(nc.sandboxes.export(a.sandbox_id, a.path, to=a.to))
94
+ elif a.cmd == "ls":
95
+ _out(nc.sandboxes.list(label=a.label, state=a.state))
96
+ elif a.cmd == "usage":
97
+ _out(nc.usage.query(label=a.label))
98
+ elif a.cmd == "capacity":
99
+ _out(nc.capacity.get())
100
+ elif a.cmd == "pricing":
101
+ _out(nc.pricing())
102
+ return 0
103
+ except NuminousError as e:
104
+ print(f"error [{e.cause}]: {e.message}", file=sys.stderr)
105
+ # provider faults are retryable and unbilled; exit codes reflect class
106
+ return 75 if e.is_provider_fault else 1
107
+
108
+
109
+ if __name__ == "__main__":
110
+ raise SystemExit(main())
@@ -0,0 +1,214 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from typing import Any, Optional
6
+
7
+ import httpx
8
+
9
+
10
+ class NuminousError(RuntimeError):
11
+ """API error carrying the typed cause.
12
+
13
+ err.cause is one of: user_image_build_failed, user_oom, user_timeout,
14
+ provider_capacity, provider_infra, policy_killed_ttl, policy_killed_quota,
15
+ auth, state, not_found.
16
+ """
17
+
18
+ def __init__(self, cause: str, message: str, status: int):
19
+ super().__init__(f"[{cause}] {message}")
20
+ self.cause = cause
21
+ self.message = message
22
+ self.status = status
23
+
24
+ @property
25
+ def is_provider_fault(self) -> bool:
26
+ return self.cause.startswith("provider_")
27
+
28
+ @property
29
+ def retryable(self) -> bool:
30
+ # capacity clears (or use reservations); infra may clear; user_* will not.
31
+ return self.cause.startswith("provider_")
32
+
33
+
34
+ class _Resource:
35
+ def __init__(self, c: "Numinous"):
36
+ self._c = c
37
+
38
+
39
+ class Templates(_Resource):
40
+ def pack(self, name: str, *, image: str | None = None,
41
+ dockerfile: str | None = None, context: str | None = None,
42
+ warm_cmd: str | None = None) -> dict:
43
+ source: dict[str, Any] = {}
44
+ if image:
45
+ source = {"type": "image", "image": image, "warm_cmd": warm_cmd}
46
+ elif dockerfile:
47
+ source = {"type": "dockerfile", "dockerfile": dockerfile,
48
+ "context": context or ".", "warm_cmd": warm_cmd}
49
+ return self._c._post("/v1/templates", {"name": name, "source": source},
50
+ timeout=1800)
51
+
52
+ def list(self) -> list[dict]:
53
+ return self._c._get("/v1/templates")
54
+
55
+ def get(self, template_id: str) -> dict:
56
+ return self._c._get(f"/v1/templates/{template_id}")
57
+
58
+
59
+ class Roms(_Resource):
60
+ def create(self, name: str, files: dict[str, str]) -> dict:
61
+ return self._c._post("/v1/roms", {"name": name, "files": files})
62
+
63
+
64
+ class Sandboxes(_Resource):
65
+ def create(self, *, template_id: str | None = None, image: str | None = None,
66
+ rom_id: str | None = None, vcpu: int = 2, mem_gib: float = 4.0,
67
+ ttl_seconds: int = 0, launch_token: str | None = None,
68
+ labels: dict[str, str] | None = None,
69
+ egress: str = "allow", allow: list[str] | None = None,
70
+ env: dict[str, str] | None = None) -> dict:
71
+ return self._c._post("/v1/sandboxes", {
72
+ "template_id": template_id, "image": image, "rom_id": rom_id,
73
+ "vcpu": vcpu, "mem_gib": mem_gib, "ttl_seconds": ttl_seconds,
74
+ "launch_token": launch_token, "labels": labels or {},
75
+ "network": {"egress": egress, "allow": allow or []},
76
+ "env": env or {},
77
+ }, timeout=600)
78
+
79
+ def get(self, sandbox_id: str) -> dict:
80
+ return self._c._get(f"/v1/sandboxes/{sandbox_id}")
81
+
82
+ def list(self, *, label: str | None = None, state: str | None = None) -> list[dict]:
83
+ params = {}
84
+ if label:
85
+ params["label"] = label
86
+ if state:
87
+ params["state"] = state
88
+ return self._c._get("/v1/sandboxes", params=params)
89
+
90
+ def exec(self, sandbox_id: str, command: str, timeout_sec: float = 300,
91
+ *, cwd: str | None = None, env: dict[str, str] | None = None,
92
+ user: str | None = None) -> dict:
93
+ return self._c._post(f"/v1/sandboxes/{sandbox_id}/exec",
94
+ {"command": command, "timeout_sec": timeout_sec,
95
+ "cwd": cwd, "env": env or {}, "user": user},
96
+ timeout=timeout_sec + 30)
97
+
98
+ def logs(self, sandbox_id: str, tail: int = 500) -> str:
99
+ return self._c._get(f"/v1/sandboxes/{sandbox_id}/logs",
100
+ params={"tail": tail})["logs"]
101
+
102
+ def suspend(self, sandbox_id: str) -> dict:
103
+ return self._c._post(f"/v1/sandboxes/{sandbox_id}/suspend", {})
104
+
105
+ def resume(self, sandbox_id: str) -> dict:
106
+ return self._c._post(f"/v1/sandboxes/{sandbox_id}/resume", {})
107
+
108
+ def export(self, sandbox_id: str, path: str, to: str | None = None) -> dict:
109
+ """Works during the run and after the sandbox terminated."""
110
+ return self._c._post(f"/v1/sandboxes/{sandbox_id}/export",
111
+ {"path": path, "to": to}, timeout=1800)
112
+
113
+ def destroy(self, sandbox_id: str) -> dict:
114
+ """Returns the sandbox with teardown_proof attached."""
115
+ return self._c._delete(f"/v1/sandboxes/{sandbox_id}")
116
+
117
+
118
+ def put_file(self, sandbox_id: str, path: str, data: bytes) -> dict:
119
+ import base64
120
+ return self._c._put(f"/v1/sandboxes/{sandbox_id}/files",
121
+ {"path": path,
122
+ "content_b64": base64.b64encode(data).decode()})
123
+
124
+ def get_file(self, sandbox_id: str, path: str) -> bytes:
125
+ import base64
126
+ out = self._c._get(f"/v1/sandboxes/{sandbox_id}/files",
127
+ params={"path": path})
128
+ return base64.b64decode(out["content_b64"])
129
+
130
+ def events(self, sandbox_id: str) -> list[dict]:
131
+ return self._c._get(f"/v1/sandboxes/{sandbox_id}/events")
132
+
133
+ def wait(self, sandbox_id: str, *, until: str = "terminated",
134
+ timeout: float = 600, poll: float = 2.0) -> dict:
135
+ deadline = time.monotonic() + timeout
136
+ while time.monotonic() < deadline:
137
+ sb = self.get(sandbox_id)
138
+ if sb["state"] in (until, "failed", "terminated"):
139
+ return sb
140
+ time.sleep(poll)
141
+ raise TimeoutError(f"{sandbox_id} not {until} after {timeout}s")
142
+
143
+
144
+ class Usage(_Resource):
145
+ def query(self, *, label: str | None = None) -> dict:
146
+ params = {"label": label} if label else {}
147
+ return self._c._get("/v1/usage", params=params)
148
+
149
+
150
+ class Capacity(_Resource):
151
+ def get(self) -> dict:
152
+ return self._c._get("/v1/capacity")
153
+
154
+ def reserve(self, *, vcpu: int, mem_gib: float, count: int,
155
+ duration_minutes: int = 120,
156
+ labels: dict[str, str] | None = None) -> dict:
157
+ return self._c._post("/v1/reservations", {
158
+ "vcpu": vcpu, "mem_gib": mem_gib, "count": count,
159
+ "duration_minutes": duration_minutes, "labels": labels or {}})
160
+
161
+
162
+ class Numinous:
163
+ def __init__(self, api_url: str | None = None, api_key: str | None = None):
164
+ self.api_url = (api_url or os.environ.get(
165
+ "NUMINOUS_API_URL", "http://127.0.0.1:8400")).rstrip("/")
166
+ self.api_key = api_key or os.environ.get("NUMINOUS_API_KEY", "nk_local_dev")
167
+ self._http = httpx.Client(
168
+ base_url=self.api_url,
169
+ headers={"Authorization": f"Bearer {self.api_key}"},
170
+ timeout=60,
171
+ )
172
+ self.templates = Templates(self)
173
+ self.roms = Roms(self)
174
+ self.sandboxes = Sandboxes(self)
175
+ self.usage = Usage(self)
176
+ self.capacity = Capacity(self)
177
+
178
+ def pricing(self) -> dict:
179
+ return self._get("/v1/pricing")
180
+
181
+ def healthz(self) -> dict:
182
+ return self._get("/v1/healthz")
183
+
184
+ # -- transport ----------------------------------------------------------
185
+
186
+ def _raise_for(self, r: httpx.Response) -> None:
187
+ if r.status_code < 400:
188
+ return
189
+ try:
190
+ detail = r.json().get("detail", {})
191
+ except Exception:
192
+ detail = {}
193
+ raise NuminousError(detail.get("cause", "unknown"),
194
+ detail.get("message", r.text[:300]), r.status_code)
195
+
196
+ def _get(self, path: str, params: dict | None = None) -> Any:
197
+ r = self._http.get(path, params=params)
198
+ self._raise_for(r)
199
+ return r.json()
200
+
201
+ def _post(self, path: str, body: dict, timeout: float = 120) -> Any:
202
+ r = self._http.post(path, json=body, timeout=timeout)
203
+ self._raise_for(r)
204
+ return r.json()
205
+
206
+ def _put(self, path: str, body: dict, timeout: float = 600) -> Any:
207
+ r = self._http.put(path, json=body, timeout=timeout)
208
+ self._raise_for(r)
209
+ return r.json()
210
+
211
+ def _delete(self, path: str) -> Any:
212
+ r = self._http.delete(path)
213
+ self._raise_for(r)
214
+ return r.json()
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "numinous"
3
+ version = "0.1.0"
4
+ description = "Python SDK for Numinous Cloud sandboxes"
5
+ requires-python = ">=3.9"
6
+ dependencies = ["httpx>=0.24"]
7
+
8
+ [project.scripts]
9
+ numinous = "numinous.cli:main"
10
+
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [tool.hatch.build.targets.wheel]
16
+ packages = ["numinous"]