eqvps 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.
eqvps-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EQVPS
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.
eqvps-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: eqvps
3
+ Version: 0.1.0
4
+ Summary: EQVPS code sandboxes: isolated Firecracker microVMs for AI agents, billed per second from your prepaid balance
5
+ License: MIT
6
+ Project-URL: Homepage, https://eqvps.com
7
+ Project-URL: Documentation, https://eqvps.com/en/docs/sandboxes
8
+ Project-URL: API, https://eqvps.com/openapi.json
9
+ Keywords: sandbox,firecracker,microvm,ai-agents,code-execution,eqvps
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # eqvps — code sandboxes for AI agents (Python)
22
+
23
+ Isolated **Firecracker microVMs** in ~1 second: Linux with Python 3.12 + pip, Node.js 22 + npm, bash, git and curl (3–40 GB disk by tariff), internet egress (pip/npm install and git clone work), no inbound ports.
24
+ Billed per second from your prepaid [EQVPS](https://eqvps.com) balance — the same balance as VPS, no subscription. Zero dependencies, Python 3.8+.
25
+
26
+ ```bash
27
+ pip install eqvps
28
+ ```
29
+
30
+ Docs: [Sandboxes](https://eqvps.com/en/docs/sandboxes) · [Connecting your account (token, config, balance)](https://eqvps.com/en/docs/connect) · [OpenAPI](https://eqvps.com/openapi.json)
31
+
32
+ ```python
33
+ from eqvps import Sandbox
34
+
35
+ with Sandbox.create(tariff="small") as sb: # EQVPS_API_KEY from the environment
36
+ r = sb.run("print(2 + 2)")
37
+ print(r.stdout, r.exit_code) # "4\n" 0
38
+ sb.upload("/root/data.csv", "a,b\n1,2\n")
39
+ print(sb.exec("wc -l /root/data.csv").stdout)
40
+ print(sb.download_text("/root/data.csv"))
41
+ # deleted when the block exits — also on exceptions
42
+ ```
43
+
44
+ ## API key
45
+ Any EQVPS account Bearer token (`POST https://api.eqvps.com/api/v1/eqvps/auth/register`, the MCP `register_account` tool, or your dashboard). Pass `api_key=` or set `EQVPS_API_KEY`. Fund the balance with crypto (`topup_balance`).
46
+
47
+ ## Modes and prices
48
+ | | ephemeral (default) | persistent |
49
+ |---|---|---|
50
+ | billing | per second, min 60 s | per started hour; a month costs at most 730 h −25 % |
51
+ | lifetime | idle timeout (default 300 s, max 3600), `ttl` ≤ 24 h | `ttl` ≤ 30 days, survives host maintenance |
52
+ | empty balance | can't create (402) | paused (memory+disk snapshot, not billed), resumes after top-up; snapshot kept 14 days |
53
+
54
+ | tariff | vCPU | RAM | disk | $/hour | persistent month max |
55
+ |---|---|---|---|---|---|
56
+ | `micro` | 0.25 | 512 MB | 3 GB | $0.0165 | $9.03 |
57
+ | `small` | 0.5 | 1 GB | 5 GB | $0.033 | $18.07 |
58
+ | `standard` | 1 | 2 GB | 10 GB | $0.066 | $36.14 |
59
+ | `plus` | 2 | 4 GB | 15 GB | $0.132 | $72.27 |
60
+ | `pro` | 4 | 8 GB | 20 GB | $0.264 | $144.54 |
61
+ | `max` | 8 | 16 GB | 40 GB | $0.528 | $289.08 |
62
+
63
+ Rates: vCPU $0.040/h + RAM $0.013/GiB-h. Live: `tariffs()`. Heavy installs (torch) and builds: `plus` or bigger. If the host is busy, creating a big tariff raises `CapacityError` (503, `retry_after`).
64
+
65
+ ```python
66
+ sb = Sandbox.create(mode="persistent", tariff="micro", ttl=7 * 86400)
67
+ sid = sb.id # keep it
68
+ sb = Sandbox.connect(sid) # reattach later
69
+ sb.kill()
70
+ ```
71
+
72
+ ## API
73
+ - `Sandbox.create(mode, tariff, idle_timeout, ttl)` · `Sandbox.connect(id)` · `Sandbox.list()` · `eqvps.tariffs()`
74
+ - `sb.run(code, language="python"|"node"|"bash", timeout=30)` → `ExecResult(exit_code, stdout, stderr, timed_out, truncated, duration_ms, ok)`
75
+ - `sb.exec("cmd" | ["argv"], cwd=None, stdin=None, timeout=30)` → `ExecResult`
76
+ - `sb.upload(path, bytes|str, mode=None)` · `sb.download(path) -> bytes` · `sb.download_text(path)` (≤ 5 MB per file)
77
+ - `sb.usage()` → running seconds, CPU used, egress, `billed_usd`, `estimated_total_usd` · `sb.refresh()` · `sb.kill()`
78
+
79
+ Limits: command timeout ≤ 55 s per call (start long jobs with `nohup … &` and poll), output ≤ 1 MiB per stream, files ≤ 5 MB per transfer, 20 concurrent sandboxes and 2 concurrent commands per account.
80
+
81
+ ## Long jobs — background tasks (no 55 s limit)
82
+ ```python
83
+ task = sb.exec("python3 train.py", background=True) # returns at once with task.id
84
+ result = task.wait(on_output=lambda out, err: print(out, end=""), poll_interval=5)
85
+ print(result.state, result.exit_code) # done 0 (also: failed | killed | timeout)
86
+
87
+ out, err = task.logs() # or poll yourself: only the new output since the last call
88
+ task.kill() # stop it (and its child processes)
89
+ sb.task(task_id) # reattach later, e.g. from another process
90
+ ```
91
+ A task may run until the sandbox's maximum lifetime (`timeout=` to cap it). While it runs the sandbox is not idle-deleted, and it is billed like any running time. Up to 8 tasks per sandbox; 8 MiB of output per stream is retained (older output is dropped).
92
+
93
+ ## Errors
94
+ All inherit `EqvpsError(status, code, message, retry_after)`: `AuthenticationError` 401 · `InsufficientBalanceError` 402 · `NotFoundError` 404 · `SandboxPausedError` 409 · `SandboxDeletedError` 410 · `FileTooLargeError` 413 · `ValidationError` 422 · `RateLimitError` 429 · `CapacityError` 503 · `SandboxTimeoutError` (HTTP timeout). 429/503 with `Retry-After` are retried automatically (`max_retries=3`). A non-zero exit code or a command timeout is **not** an exception — check `result.ok` / `result.timed_out`.
95
+
96
+ ```python
97
+ from eqvps import Sandbox, InsufficientBalanceError, RateLimitError
98
+ try:
99
+ sb = Sandbox.create()
100
+ except InsufficientBalanceError:
101
+ print("top up your EQVPS balance")
102
+ except RateLimitError as e:
103
+ print("retry in", e.retry_after, "s")
104
+ ```
105
+
106
+ REST API reference: https://eqvps.com/openapi.json (tag `sandboxes`) · human guide: https://eqvps.com/en/docs/sandboxes
eqvps-0.1.0/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # eqvps — code sandboxes for AI agents (Python)
2
+
3
+ Isolated **Firecracker microVMs** in ~1 second: Linux with Python 3.12 + pip, Node.js 22 + npm, bash, git and curl (3–40 GB disk by tariff), internet egress (pip/npm install and git clone work), no inbound ports.
4
+ Billed per second from your prepaid [EQVPS](https://eqvps.com) balance — the same balance as VPS, no subscription. Zero dependencies, Python 3.8+.
5
+
6
+ ```bash
7
+ pip install eqvps
8
+ ```
9
+
10
+ Docs: [Sandboxes](https://eqvps.com/en/docs/sandboxes) · [Connecting your account (token, config, balance)](https://eqvps.com/en/docs/connect) · [OpenAPI](https://eqvps.com/openapi.json)
11
+
12
+ ```python
13
+ from eqvps import Sandbox
14
+
15
+ with Sandbox.create(tariff="small") as sb: # EQVPS_API_KEY from the environment
16
+ r = sb.run("print(2 + 2)")
17
+ print(r.stdout, r.exit_code) # "4\n" 0
18
+ sb.upload("/root/data.csv", "a,b\n1,2\n")
19
+ print(sb.exec("wc -l /root/data.csv").stdout)
20
+ print(sb.download_text("/root/data.csv"))
21
+ # deleted when the block exits — also on exceptions
22
+ ```
23
+
24
+ ## API key
25
+ Any EQVPS account Bearer token (`POST https://api.eqvps.com/api/v1/eqvps/auth/register`, the MCP `register_account` tool, or your dashboard). Pass `api_key=` or set `EQVPS_API_KEY`. Fund the balance with crypto (`topup_balance`).
26
+
27
+ ## Modes and prices
28
+ | | ephemeral (default) | persistent |
29
+ |---|---|---|
30
+ | billing | per second, min 60 s | per started hour; a month costs at most 730 h −25 % |
31
+ | lifetime | idle timeout (default 300 s, max 3600), `ttl` ≤ 24 h | `ttl` ≤ 30 days, survives host maintenance |
32
+ | empty balance | can't create (402) | paused (memory+disk snapshot, not billed), resumes after top-up; snapshot kept 14 days |
33
+
34
+ | tariff | vCPU | RAM | disk | $/hour | persistent month max |
35
+ |---|---|---|---|---|---|
36
+ | `micro` | 0.25 | 512 MB | 3 GB | $0.0165 | $9.03 |
37
+ | `small` | 0.5 | 1 GB | 5 GB | $0.033 | $18.07 |
38
+ | `standard` | 1 | 2 GB | 10 GB | $0.066 | $36.14 |
39
+ | `plus` | 2 | 4 GB | 15 GB | $0.132 | $72.27 |
40
+ | `pro` | 4 | 8 GB | 20 GB | $0.264 | $144.54 |
41
+ | `max` | 8 | 16 GB | 40 GB | $0.528 | $289.08 |
42
+
43
+ Rates: vCPU $0.040/h + RAM $0.013/GiB-h. Live: `tariffs()`. Heavy installs (torch) and builds: `plus` or bigger. If the host is busy, creating a big tariff raises `CapacityError` (503, `retry_after`).
44
+
45
+ ```python
46
+ sb = Sandbox.create(mode="persistent", tariff="micro", ttl=7 * 86400)
47
+ sid = sb.id # keep it
48
+ sb = Sandbox.connect(sid) # reattach later
49
+ sb.kill()
50
+ ```
51
+
52
+ ## API
53
+ - `Sandbox.create(mode, tariff, idle_timeout, ttl)` · `Sandbox.connect(id)` · `Sandbox.list()` · `eqvps.tariffs()`
54
+ - `sb.run(code, language="python"|"node"|"bash", timeout=30)` → `ExecResult(exit_code, stdout, stderr, timed_out, truncated, duration_ms, ok)`
55
+ - `sb.exec("cmd" | ["argv"], cwd=None, stdin=None, timeout=30)` → `ExecResult`
56
+ - `sb.upload(path, bytes|str, mode=None)` · `sb.download(path) -> bytes` · `sb.download_text(path)` (≤ 5 MB per file)
57
+ - `sb.usage()` → running seconds, CPU used, egress, `billed_usd`, `estimated_total_usd` · `sb.refresh()` · `sb.kill()`
58
+
59
+ Limits: command timeout ≤ 55 s per call (start long jobs with `nohup … &` and poll), output ≤ 1 MiB per stream, files ≤ 5 MB per transfer, 20 concurrent sandboxes and 2 concurrent commands per account.
60
+
61
+ ## Long jobs — background tasks (no 55 s limit)
62
+ ```python
63
+ task = sb.exec("python3 train.py", background=True) # returns at once with task.id
64
+ result = task.wait(on_output=lambda out, err: print(out, end=""), poll_interval=5)
65
+ print(result.state, result.exit_code) # done 0 (also: failed | killed | timeout)
66
+
67
+ out, err = task.logs() # or poll yourself: only the new output since the last call
68
+ task.kill() # stop it (and its child processes)
69
+ sb.task(task_id) # reattach later, e.g. from another process
70
+ ```
71
+ A task may run until the sandbox's maximum lifetime (`timeout=` to cap it). While it runs the sandbox is not idle-deleted, and it is billed like any running time. Up to 8 tasks per sandbox; 8 MiB of output per stream is retained (older output is dropped).
72
+
73
+ ## Errors
74
+ All inherit `EqvpsError(status, code, message, retry_after)`: `AuthenticationError` 401 · `InsufficientBalanceError` 402 · `NotFoundError` 404 · `SandboxPausedError` 409 · `SandboxDeletedError` 410 · `FileTooLargeError` 413 · `ValidationError` 422 · `RateLimitError` 429 · `CapacityError` 503 · `SandboxTimeoutError` (HTTP timeout). 429/503 with `Retry-After` are retried automatically (`max_retries=3`). A non-zero exit code or a command timeout is **not** an exception — check `result.ok` / `result.timed_out`.
75
+
76
+ ```python
77
+ from eqvps import Sandbox, InsufficientBalanceError, RateLimitError
78
+ try:
79
+ sb = Sandbox.create()
80
+ except InsufficientBalanceError:
81
+ print("top up your EQVPS balance")
82
+ except RateLimitError as e:
83
+ print("retry in", e.retry_after, "s")
84
+ ```
85
+
86
+ REST API reference: https://eqvps.com/openapi.json (tag `sandboxes`) · human guide: https://eqvps.com/en/docs/sandboxes
@@ -0,0 +1,9 @@
1
+ """EQVPS Python SDK — code sandboxes (Firecracker microVMs) billed from your prepaid EQVPS balance."""
2
+
3
+ from .sandbox import ( # noqa: F401
4
+ AuthenticationError, CapacityError, EqvpsError, ExecResult, FileTooLargeError, InsufficientBalanceError,
5
+ NotFoundError, RateLimitError, Sandbox, SandboxDeletedError, SandboxPausedError, SandboxTimeoutError,
6
+ ValidationError, tariffs, Task, TaskResult,
7
+ )
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,424 @@
1
+ """EQVPS code sandboxes — Python SDK (zero dependencies, Python 3.8+).
2
+
3
+ Isolated Firecracker microVMs for running code, billed per second from your prepaid EQVPS balance.
4
+
5
+ from eqvps import Sandbox
6
+
7
+ with Sandbox.create() as sb: # EQVPS_API_KEY from the environment
8
+ print(sb.run("print(2 + 2)").stdout) # -> "4\\n"
9
+ # the sandbox is deleted when the block exits
10
+
11
+ REST contract: https://eqvps.com/openapi.json (tag "sandboxes"); see CONTRACT.md for other languages.
12
+ """
13
+
14
+ import base64
15
+ import json
16
+ import os
17
+ import socket
18
+ import ssl
19
+ import time
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ from typing import Any, Dict, List, Optional, Union
24
+
25
+ __all__ = [
26
+ "Sandbox", "ExecResult", "Task", "TaskResult", "EqvpsError", "AuthenticationError", "InsufficientBalanceError", "NotFoundError",
27
+ "SandboxPausedError", "SandboxDeletedError", "FileTooLargeError", "RateLimitError", "CapacityError",
28
+ "ValidationError", "SandboxTimeoutError", "tariffs",
29
+ ]
30
+
31
+ DEFAULT_BASE_URL = "https://api.eqvps.com/api/v1/eqvps"
32
+ USER_AGENT = "eqvps-python/0.1.0"
33
+
34
+
35
+ # ------------------------------------------------------------------ errors
36
+
37
+ class EqvpsError(Exception):
38
+ """Any API error. `status` is the HTTP status, `code` the stable machine-readable error code."""
39
+
40
+ def __init__(self, message: str, status: int = 0, code: str = "error", body: Optional[dict] = None):
41
+ super().__init__(message)
42
+ self.message = message
43
+ self.status = status
44
+ self.code = code
45
+ self.body = body or {}
46
+ self.retry_after: Optional[int] = self.body.get("retry_after")
47
+
48
+ def __str__(self) -> str:
49
+ return f"{self.code} ({self.status}): {self.message}" if self.status else self.message
50
+
51
+
52
+ class AuthenticationError(EqvpsError):
53
+ """401 — missing or invalid API key."""
54
+
55
+
56
+ class InsufficientBalanceError(EqvpsError):
57
+ """402 — the prepaid balance cannot cover the sandbox. Top up and retry."""
58
+
59
+
60
+ class NotFoundError(EqvpsError):
61
+ """404 — no such sandbox (or file) on this account."""
62
+
63
+
64
+ class SandboxPausedError(EqvpsError):
65
+ """409 — the sandbox is paused (empty balance) or changing state; it resumes after a top-up."""
66
+
67
+
68
+ class SandboxDeletedError(EqvpsError):
69
+ """410 — the sandbox was deleted (paused snapshot kept 14 days, then removed). It cannot be resumed."""
70
+
71
+
72
+ class FileTooLargeError(EqvpsError):
73
+ """413 — a file transfer is limited to 5 MB."""
74
+
75
+
76
+ class ValidationError(EqvpsError):
77
+ """422 — invalid parameters."""
78
+
79
+
80
+ class RateLimitError(EqvpsError):
81
+ """429 — too many requests or the concurrent sandbox limit is reached. See `retry_after`."""
82
+
83
+
84
+ class CapacityError(EqvpsError):
85
+ """503 — no capacity or maintenance right now. See `retry_after`."""
86
+
87
+
88
+ class SandboxTimeoutError(EqvpsError):
89
+ """The HTTP request itself timed out (not the command — see ExecResult.timed_out)."""
90
+
91
+
92
+ _BY_STATUS = {401: AuthenticationError, 402: InsufficientBalanceError, 404: NotFoundError, 409: SandboxPausedError,
93
+ 410: SandboxDeletedError, 413: FileTooLargeError, 422: ValidationError, 429: RateLimitError, 503: CapacityError}
94
+
95
+
96
+ # ------------------------------------------------------------------ transport
97
+
98
+ class _Client:
99
+ def __init__(self, api_key: Optional[str], base_url: Optional[str], timeout: float, max_retries: int):
100
+ self.api_key = api_key or os.environ.get("EQVPS_API_KEY")
101
+ if not self.api_key:
102
+ raise AuthenticationError("No API key: pass api_key=... or set EQVPS_API_KEY (an EQVPS account Bearer token).")
103
+ self.base_url = (base_url or os.environ.get("EQVPS_API_URL") or DEFAULT_BASE_URL).rstrip("/")
104
+ self.timeout = timeout
105
+ self.max_retries = max_retries
106
+ self._ssl = ssl.create_default_context()
107
+
108
+ def request(self, method: str, path: str, body: Optional[dict] = None, query: Optional[dict] = None,
109
+ timeout: Optional[float] = None, retry: bool = True) -> Any:
110
+ url = self.base_url + path + ("?" + urllib.parse.urlencode(query) if query else "")
111
+ data = json.dumps(body).encode() if body is not None else None
112
+ headers = {"Authorization": "Bearer " + self.api_key, "Accept": "application/json", "User-Agent": USER_AGENT}
113
+ if data is not None:
114
+ headers["Content-Type"] = "application/json"
115
+ attempt = 0
116
+ while True:
117
+ attempt += 1
118
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
119
+ try:
120
+ with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self._ssl) as resp:
121
+ raw = resp.read()
122
+ return json.loads(raw) if raw else None
123
+ except urllib.error.HTTPError as e:
124
+ raw = e.read()
125
+ try:
126
+ j = json.loads(raw) if raw else {}
127
+ except ValueError:
128
+ j = {"message": raw.decode("utf-8", "replace")[:300]}
129
+ if not isinstance(j, dict):
130
+ j = {}
131
+ ra = j.get("retry_after") or e.headers.get("Retry-After")
132
+ # 429/503 = отклонено ДО выполнения (лимит/ёмкость) → безопасно повторить, в т.ч. run/exec
133
+ if e.code in (429, 503) and attempt <= self.max_retries and ra is not None:
134
+ time.sleep(min(float(ra), 30.0))
135
+ continue
136
+ cls = _BY_STATUS.get(e.code, EqvpsError)
137
+ if e.code == 401:
138
+ j.setdefault("error", "unauthenticated")
139
+ raise cls(str(j.get("message") or e.reason), status=e.code, code=str(j.get("error") or "http_%d" % e.code), body=j) from None
140
+ except (socket.timeout, TimeoutError) as e:
141
+ raise SandboxTimeoutError("Request timed out: %s %s" % (method, path), code="request_timeout") from e
142
+ except urllib.error.URLError as e:
143
+ if retry and method == "GET" and attempt <= self.max_retries:
144
+ time.sleep(1.5 * attempt)
145
+ continue
146
+ raise EqvpsError("Network error: %s" % e.reason, code="network_error") from e
147
+
148
+
149
+ # ------------------------------------------------------------------ results
150
+
151
+ class ExecResult:
152
+ """Result of run()/exec(). A non-zero exit code is NOT an exception — check `ok` / `exit_code`."""
153
+
154
+ __slots__ = ("exit_code", "stdout", "stderr", "timed_out", "truncated", "duration_ms")
155
+
156
+ def __init__(self, d: dict):
157
+ self.exit_code: Optional[int] = d.get("exit_code")
158
+ self.stdout: str = d.get("stdout", "")
159
+ self.stderr: str = d.get("stderr", "")
160
+ self.timed_out: bool = bool(d.get("timed_out"))
161
+ self.truncated: bool = bool(d.get("truncated"))
162
+ self.duration_ms: Optional[int] = d.get("duration_ms")
163
+
164
+ @property
165
+ def ok(self) -> bool:
166
+ return self.exit_code == 0 and not self.timed_out
167
+
168
+ def __repr__(self) -> str:
169
+ return "ExecResult(exit_code=%r, timed_out=%r, stdout=%r, stderr=%r)" % (self.exit_code, self.timed_out, self.stdout[:80], self.stderr[:80])
170
+
171
+
172
+ class TaskResult:
173
+ """Final state of a background task (Task.wait())."""
174
+
175
+ __slots__ = ("task_id", "state", "exit_code", "stdout", "stderr", "running_seconds")
176
+
177
+ def __init__(self, task_id: str, d: dict, stdout: str, stderr: str):
178
+ self.task_id = task_id
179
+ self.state: str = d.get("state", "")
180
+ self.exit_code: Optional[int] = d.get("exit_code")
181
+ self.stdout, self.stderr = stdout, stderr
182
+ self.running_seconds: Optional[float] = d.get("running_seconds")
183
+
184
+ @property
185
+ def ok(self) -> bool:
186
+ return self.state == "done" and self.exit_code == 0
187
+
188
+ def __repr__(self) -> str:
189
+ return "TaskResult(state=%r, exit_code=%r, stdout=%r)" % (self.state, self.exit_code, self.stdout[-80:])
190
+
191
+
192
+ class Task:
193
+ """A background task (run/exec with background=True): no 55 s limit, output collected in the sandbox.
194
+
195
+ task.logs() → new (stdout, stderr) since the last call · task.wait() → TaskResult · task.kill()
196
+ """
197
+
198
+ def __init__(self, sandbox: "Sandbox", data: dict):
199
+ self._sb = sandbox
200
+ self.id: str = data["task_id"]
201
+ self._data = data
202
+ self._out = self._err = 0
203
+
204
+ def _poll(self, max_bytes: int = 262144) -> dict:
205
+ d = self._sb._c.request("GET", "/sandboxes/%s/tasks/%s" % (self._sb.id, self.id),
206
+ query={"stdout_offset": self._out, "stderr_offset": self._err, "max_bytes": max_bytes})
207
+ self._out, self._err = d.get("stdout_next_offset", self._out), d.get("stderr_next_offset", self._err)
208
+ self._data = d
209
+ return d
210
+
211
+ def status(self) -> dict:
212
+ """State without consuming output: running | done | failed | killed | timeout, exit_code, totals."""
213
+ return self._sb._c.request("GET", "/sandboxes/%s/tasks/%s" % (self._sb.id, self.id),
214
+ query={"stdout_offset": self._out, "stderr_offset": self._err, "max_bytes": 0})
215
+
216
+ def logs(self):
217
+ """New output since the previous call: (stdout, stderr)."""
218
+ d = self._poll()
219
+ return d.get("stdout", ""), d.get("stderr", "")
220
+
221
+ @property
222
+ def state(self) -> str:
223
+ return self._data.get("state", "")
224
+
225
+ @property
226
+ def done(self) -> bool:
227
+ return self._data.get("state", "running") != "running"
228
+
229
+ def wait(self, timeout: Optional[float] = None, poll_interval: float = 2.0, on_output=None) -> TaskResult:
230
+ """Poll until the task finishes. `on_output(stdout_chunk, stderr_chunk)` is called with new output.
231
+ Raises SandboxTimeoutError if `timeout` seconds pass first (the task keeps running)."""
232
+ start, out, err = time.time(), [], []
233
+ while True:
234
+ d = self._poll()
235
+ o, e = d.get("stdout", ""), d.get("stderr", "")
236
+ if o or e:
237
+ out.append(o); err.append(e)
238
+ if on_output:
239
+ on_output(o, e)
240
+ if d.get("state", "running") != "running":
241
+ # дочитать хвост, если вывод больше одной порции
242
+ while d.get("stdout_next_offset", 0) < d.get("stdout_total", 0) or d.get("stderr_next_offset", 0) < d.get("stderr_total", 0):
243
+ d = self._poll(); out.append(d.get("stdout", "")); err.append(d.get("stderr", ""))
244
+ if on_output:
245
+ on_output(d.get("stdout", ""), d.get("stderr", ""))
246
+ return TaskResult(self.id, d, "".join(out), "".join(err))
247
+ if timeout is not None and time.time() - start > timeout:
248
+ raise SandboxTimeoutError("Task %s still running after %ss" % (self.id, timeout), code="wait_timeout")
249
+ time.sleep(poll_interval)
250
+
251
+ def kill(self) -> dict:
252
+ """Stop the task and its processes."""
253
+ self._data = self._sb._c.request("DELETE", "/sandboxes/%s/tasks/%s" % (self._sb.id, self.id), retry=False)
254
+ return self._data
255
+
256
+ def __repr__(self) -> str:
257
+ return "Task(id=%r, state=%r)" % (self.id, self.state)
258
+
259
+
260
+ # ------------------------------------------------------------------ sandbox
261
+
262
+ class Sandbox:
263
+ """One sandbox. Create with Sandbox.create(), reattach with Sandbox.connect(id).
264
+
265
+ Used as a context manager the sandbox is deleted on exit (also on exceptions).
266
+ """
267
+
268
+ def __init__(self, data: dict, client: _Client):
269
+ self._c = client
270
+ self._data = data
271
+
272
+ # ---- lifecycle
273
+ @classmethod
274
+ def create(cls, mode: str = "ephemeral", tariff: str = "small", idle_timeout: Optional[int] = None,
275
+ ttl: Optional[int] = None, *, api_key: Optional[str] = None, base_url: Optional[str] = None,
276
+ timeout: float = 70.0, max_retries: int = 3) -> "Sandbox":
277
+ """Start a sandbox (ready in ~1 s).
278
+
279
+ mode: "ephemeral" (per-second billing, min 60 s, auto-deleted after `idle_timeout` s idle, default 300)
280
+ or "persistent" (per started hour, month capped at 730 h -25%, survives host maintenance).
281
+ tariff (vCPU/RAM/disk): "micro" 0.25/512 MB/3 GB · "small" 0.5/1 GB/5 GB · "standard" 1/2 GB/10 GB ·
282
+ "plus" 2/4 GB/15 GB · "pro" 4/8 GB/20 GB · "max" 8/16 GB/40 GB. ttl: max lifetime in seconds.
283
+ Raises CapacityError (503) if the host has no free CPU/RAM for that tariff right now.
284
+ Raises InsufficientBalanceError if the balance is empty.
285
+ """
286
+ c = _Client(api_key, base_url, timeout, max_retries)
287
+ body: Dict[str, Any] = {"mode": mode, "tariff": tariff}
288
+ if idle_timeout is not None:
289
+ body["idle_timeout"] = idle_timeout
290
+ if ttl is not None:
291
+ body["ttl"] = ttl
292
+ return cls(c.request("POST", "/sandboxes", body), c)
293
+
294
+ @classmethod
295
+ def connect(cls, sandbox_id: str, *, api_key: Optional[str] = None, base_url: Optional[str] = None,
296
+ timeout: float = 70.0, max_retries: int = 3) -> "Sandbox":
297
+ """Attach to an existing sandbox of this account (e.g. a persistent one)."""
298
+ c = _Client(api_key, base_url, timeout, max_retries)
299
+ return cls(c.request("GET", "/sandboxes/" + sandbox_id), c)
300
+
301
+ @classmethod
302
+ def list(cls, *, api_key: Optional[str] = None, base_url: Optional[str] = None) -> List["Sandbox"]:
303
+ """All sandboxes of this account (running and paused)."""
304
+ c = _Client(api_key, base_url, 30.0, 3)
305
+ return [cls(d, c) for d in (c.request("GET", "/sandboxes") or {}).get("data", [])]
306
+
307
+ def kill(self) -> None:
308
+ """Delete the sandbox now. Billing stops; deleting an already-gone sandbox is not an error."""
309
+ try:
310
+ self._c.request("DELETE", "/sandboxes/" + self.id, retry=False)
311
+ except (NotFoundError, SandboxDeletedError):
312
+ pass
313
+ self._data["state"] = "deleted"
314
+
315
+ def refresh(self) -> "Sandbox":
316
+ self._data = self._c.request("GET", "/sandboxes/" + self.id)
317
+ return self
318
+
319
+ def __enter__(self) -> "Sandbox":
320
+ return self
321
+
322
+ def __exit__(self, *exc) -> None:
323
+ self.kill()
324
+
325
+ # ---- properties
326
+ @property
327
+ def id(self) -> str:
328
+ return self._data["id"]
329
+
330
+ @property
331
+ def mode(self) -> str:
332
+ return self._data.get("mode", "")
333
+
334
+ @property
335
+ def tariff(self) -> str:
336
+ return self._data.get("tariff", "")
337
+
338
+ @property
339
+ def state(self) -> str:
340
+ """running | starting | paused | pausing | resuming | deleting | deleted"""
341
+ return self._data.get("state", "")
342
+
343
+ @property
344
+ def info(self) -> dict:
345
+ """Raw sandbox object as returned by the API."""
346
+ return dict(self._data)
347
+
348
+ def __repr__(self) -> str:
349
+ return "Sandbox(id=%r, mode=%r, tariff=%r, state=%r)" % (self.id, self.mode, self.tariff, self.state)
350
+
351
+ # ---- code & commands
352
+ def run(self, code: str, language: str = "python", timeout: Optional[int] = None, background: bool = False) -> Union[ExecResult, "Task"]:
353
+ """Run a code snippet passed on stdin: language "python" (3.12), "node" (Node.js 22) or "bash".
354
+ Synchronous: timeout ≤ 55 s (default 30) → ExecResult. background=True → Task immediately (no 55 s limit;
355
+ timeout defaults to the sandbox's remaining lifetime)."""
356
+ body: Dict[str, Any] = {"language": language, "code": code}
357
+ if background:
358
+ body["async"] = True
359
+ if timeout:
360
+ body["timeout"] = timeout
361
+ return Task(self, self._c.request("POST", "/sandboxes/%s/run" % self.id, body))
362
+ body["timeout"] = timeout or 30
363
+ return ExecResult(self._c.request("POST", "/sandboxes/%s/run" % self.id, body, timeout=body["timeout"] + 20, retry=False))
364
+
365
+ def exec(self, command: Union[str, List[str]], cwd: Optional[str] = None, stdin: Optional[str] = None,
366
+ timeout: Optional[int] = None, background: bool = False) -> Union[ExecResult, "Task"]:
367
+ """Execute a shell command (string → bash -lc) or an argv list. background=True → Task (see run())."""
368
+ body: Dict[str, Any] = {}
369
+ if background:
370
+ body["async"] = True
371
+ if timeout:
372
+ body["timeout"] = timeout
373
+ else:
374
+ timeout = timeout or 30
375
+ body["timeout"] = timeout
376
+ if isinstance(command, str):
377
+ body["command"] = command
378
+ else:
379
+ body["argv"] = list(command)
380
+ if cwd:
381
+ body["cwd"] = cwd
382
+ if stdin:
383
+ body["stdin"] = stdin
384
+ if background:
385
+ return Task(self, self._c.request("POST", "/sandboxes/%s/exec" % self.id, body))
386
+ return ExecResult(self._c.request("POST", "/sandboxes/%s/exec" % self.id, body, timeout=timeout + 20, retry=False))
387
+
388
+ def task(self, task_id: str) -> "Task":
389
+ """Reattach to a background task by id."""
390
+ return Task(self, {"task_id": task_id})
391
+
392
+ def tasks(self) -> list:
393
+ """Background tasks of this sandbox (summaries)."""
394
+ return self._c.request("GET", "/sandboxes/%s/tasks" % self.id).get("data", [])
395
+
396
+ # ---- files (≤ 5 MB per transfer)
397
+ def upload(self, path: str, content: Union[bytes, str], mode: Optional[str] = None) -> int:
398
+ """Write a file at an absolute path; returns its size in bytes."""
399
+ raw = content.encode("utf-8") if isinstance(content, str) else bytes(content)
400
+ body: Dict[str, Any] = {"path": path, "content_base64": base64.b64encode(raw).decode()}
401
+ if mode:
402
+ body["mode"] = mode
403
+ return int(self._c.request("PUT", "/sandboxes/%s/files" % self.id, body, retry=False).get("size", len(raw)))
404
+
405
+ def download(self, path: str) -> bytes:
406
+ """Read a file (bytes)."""
407
+ d = self._c.request("GET", "/sandboxes/%s/files" % self.id, query={"path": path})
408
+ return base64.b64decode(d.get("content_base64", ""))
409
+
410
+ def download_text(self, path: str, encoding: str = "utf-8") -> str:
411
+ return self.download(path).decode(encoding)
412
+
413
+ # ---- billing
414
+ def usage(self) -> dict:
415
+ """Running seconds, CPU seconds used, egress bytes, price per hour, billed_usd and estimated_total_usd."""
416
+ return self._c.request("GET", "/sandboxes/%s/usage" % self.id)
417
+
418
+
419
+ def tariffs(base_url: Optional[str] = None) -> dict:
420
+ """Public tariffs, prices, billing model and limits (no API key needed)."""
421
+ url = (base_url or os.environ.get("EQVPS_API_URL") or DEFAULT_BASE_URL).rstrip("/") + "/sandboxes/tariffs"
422
+ req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": USER_AGENT})
423
+ with urllib.request.urlopen(req, timeout=30, context=ssl.create_default_context()) as r:
424
+ return json.loads(r.read())
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: eqvps
3
+ Version: 0.1.0
4
+ Summary: EQVPS code sandboxes: isolated Firecracker microVMs for AI agents, billed per second from your prepaid balance
5
+ License: MIT
6
+ Project-URL: Homepage, https://eqvps.com
7
+ Project-URL: Documentation, https://eqvps.com/en/docs/sandboxes
8
+ Project-URL: API, https://eqvps.com/openapi.json
9
+ Keywords: sandbox,firecracker,microvm,ai-agents,code-execution,eqvps
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # eqvps — code sandboxes for AI agents (Python)
22
+
23
+ Isolated **Firecracker microVMs** in ~1 second: Linux with Python 3.12 + pip, Node.js 22 + npm, bash, git and curl (3–40 GB disk by tariff), internet egress (pip/npm install and git clone work), no inbound ports.
24
+ Billed per second from your prepaid [EQVPS](https://eqvps.com) balance — the same balance as VPS, no subscription. Zero dependencies, Python 3.8+.
25
+
26
+ ```bash
27
+ pip install eqvps
28
+ ```
29
+
30
+ Docs: [Sandboxes](https://eqvps.com/en/docs/sandboxes) · [Connecting your account (token, config, balance)](https://eqvps.com/en/docs/connect) · [OpenAPI](https://eqvps.com/openapi.json)
31
+
32
+ ```python
33
+ from eqvps import Sandbox
34
+
35
+ with Sandbox.create(tariff="small") as sb: # EQVPS_API_KEY from the environment
36
+ r = sb.run("print(2 + 2)")
37
+ print(r.stdout, r.exit_code) # "4\n" 0
38
+ sb.upload("/root/data.csv", "a,b\n1,2\n")
39
+ print(sb.exec("wc -l /root/data.csv").stdout)
40
+ print(sb.download_text("/root/data.csv"))
41
+ # deleted when the block exits — also on exceptions
42
+ ```
43
+
44
+ ## API key
45
+ Any EQVPS account Bearer token (`POST https://api.eqvps.com/api/v1/eqvps/auth/register`, the MCP `register_account` tool, or your dashboard). Pass `api_key=` or set `EQVPS_API_KEY`. Fund the balance with crypto (`topup_balance`).
46
+
47
+ ## Modes and prices
48
+ | | ephemeral (default) | persistent |
49
+ |---|---|---|
50
+ | billing | per second, min 60 s | per started hour; a month costs at most 730 h −25 % |
51
+ | lifetime | idle timeout (default 300 s, max 3600), `ttl` ≤ 24 h | `ttl` ≤ 30 days, survives host maintenance |
52
+ | empty balance | can't create (402) | paused (memory+disk snapshot, not billed), resumes after top-up; snapshot kept 14 days |
53
+
54
+ | tariff | vCPU | RAM | disk | $/hour | persistent month max |
55
+ |---|---|---|---|---|---|
56
+ | `micro` | 0.25 | 512 MB | 3 GB | $0.0165 | $9.03 |
57
+ | `small` | 0.5 | 1 GB | 5 GB | $0.033 | $18.07 |
58
+ | `standard` | 1 | 2 GB | 10 GB | $0.066 | $36.14 |
59
+ | `plus` | 2 | 4 GB | 15 GB | $0.132 | $72.27 |
60
+ | `pro` | 4 | 8 GB | 20 GB | $0.264 | $144.54 |
61
+ | `max` | 8 | 16 GB | 40 GB | $0.528 | $289.08 |
62
+
63
+ Rates: vCPU $0.040/h + RAM $0.013/GiB-h. Live: `tariffs()`. Heavy installs (torch) and builds: `plus` or bigger. If the host is busy, creating a big tariff raises `CapacityError` (503, `retry_after`).
64
+
65
+ ```python
66
+ sb = Sandbox.create(mode="persistent", tariff="micro", ttl=7 * 86400)
67
+ sid = sb.id # keep it
68
+ sb = Sandbox.connect(sid) # reattach later
69
+ sb.kill()
70
+ ```
71
+
72
+ ## API
73
+ - `Sandbox.create(mode, tariff, idle_timeout, ttl)` · `Sandbox.connect(id)` · `Sandbox.list()` · `eqvps.tariffs()`
74
+ - `sb.run(code, language="python"|"node"|"bash", timeout=30)` → `ExecResult(exit_code, stdout, stderr, timed_out, truncated, duration_ms, ok)`
75
+ - `sb.exec("cmd" | ["argv"], cwd=None, stdin=None, timeout=30)` → `ExecResult`
76
+ - `sb.upload(path, bytes|str, mode=None)` · `sb.download(path) -> bytes` · `sb.download_text(path)` (≤ 5 MB per file)
77
+ - `sb.usage()` → running seconds, CPU used, egress, `billed_usd`, `estimated_total_usd` · `sb.refresh()` · `sb.kill()`
78
+
79
+ Limits: command timeout ≤ 55 s per call (start long jobs with `nohup … &` and poll), output ≤ 1 MiB per stream, files ≤ 5 MB per transfer, 20 concurrent sandboxes and 2 concurrent commands per account.
80
+
81
+ ## Long jobs — background tasks (no 55 s limit)
82
+ ```python
83
+ task = sb.exec("python3 train.py", background=True) # returns at once with task.id
84
+ result = task.wait(on_output=lambda out, err: print(out, end=""), poll_interval=5)
85
+ print(result.state, result.exit_code) # done 0 (also: failed | killed | timeout)
86
+
87
+ out, err = task.logs() # or poll yourself: only the new output since the last call
88
+ task.kill() # stop it (and its child processes)
89
+ sb.task(task_id) # reattach later, e.g. from another process
90
+ ```
91
+ A task may run until the sandbox's maximum lifetime (`timeout=` to cap it). While it runs the sandbox is not idle-deleted, and it is billed like any running time. Up to 8 tasks per sandbox; 8 MiB of output per stream is retained (older output is dropped).
92
+
93
+ ## Errors
94
+ All inherit `EqvpsError(status, code, message, retry_after)`: `AuthenticationError` 401 · `InsufficientBalanceError` 402 · `NotFoundError` 404 · `SandboxPausedError` 409 · `SandboxDeletedError` 410 · `FileTooLargeError` 413 · `ValidationError` 422 · `RateLimitError` 429 · `CapacityError` 503 · `SandboxTimeoutError` (HTTP timeout). 429/503 with `Retry-After` are retried automatically (`max_retries=3`). A non-zero exit code or a command timeout is **not** an exception — check `result.ok` / `result.timed_out`.
95
+
96
+ ```python
97
+ from eqvps import Sandbox, InsufficientBalanceError, RateLimitError
98
+ try:
99
+ sb = Sandbox.create()
100
+ except InsufficientBalanceError:
101
+ print("top up your EQVPS balance")
102
+ except RateLimitError as e:
103
+ print("retry in", e.retry_after, "s")
104
+ ```
105
+
106
+ REST API reference: https://eqvps.com/openapi.json (tag `sandboxes`) · human guide: https://eqvps.com/en/docs/sandboxes
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ eqvps/__init__.py
5
+ eqvps/sandbox.py
6
+ eqvps.egg-info/PKG-INFO
7
+ eqvps.egg-info/SOURCES.txt
8
+ eqvps.egg-info/dependency_links.txt
9
+ eqvps.egg-info/top_level.txt
10
+ tests/test_live.py
@@ -0,0 +1 @@
1
+ eqvps
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "eqvps"
7
+ version = "0.1.0"
8
+ description = "EQVPS code sandboxes: isolated Firecracker microVMs for AI agents, billed per second from your prepaid balance"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ dependencies = []
13
+ keywords = ["sandbox", "firecracker", "microvm", "ai-agents", "code-execution", "eqvps"]
14
+ classifiers = ["Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://eqvps.com"
18
+ Documentation = "https://eqvps.com/en/docs/sandboxes"
19
+ API = "https://eqvps.com/openapi.json"
eqvps-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,55 @@
1
+ """Integration test against the live API (charges your balance a few cents). EQVPS_API_KEY must be set."""
2
+ import os, time
3
+ import eqvps
4
+ from eqvps import Sandbox, NotFoundError, ValidationError, FileTooLargeError
5
+
6
+ def check(name, cond, info=""):
7
+ print(("PASS " if cond else "FAIL ") + name, info)
8
+ if not cond:
9
+ check.fails += 1
10
+ check.fails = 0
11
+
12
+ t = eqvps.tariffs()
13
+ check("tariffs public", {x["name"] for x in t["tariffs"]} == {"micro", "small", "std"}, str([x["price_usd"]["per_hour"] for x in t["tariffs"]]))
14
+ t0 = time.time()
15
+ with Sandbox.create(mode="ephemeral", tariff="micro", idle_timeout=120) as sb:
16
+ check("create", sb.state == "running" and sb.id.startswith("sb_"), "%s in %.2fs" % (sb.id, time.time() - t0))
17
+ r = sb.run("print(6*7)")
18
+ check("run python", r.ok and r.stdout == "42\n", repr(r))
19
+ r = sb.run("echo $((6*7)); exit 3", language="bash")
20
+ check("run bash, exit code", r.stdout.strip() == "42" and r.exit_code == 3 and not r.ok)
21
+ r = sb.run("console.log([1,2,3].map(x => x * 14).join(','))", language="node")
22
+ check("run node", r.ok and r.stdout.strip() == "14,28,42", repr(r))
23
+ r = sb.exec("pip install -q tomli-w && python3 -c 'import tomli_w; print(tomli_w.dumps({\"a\": 1}).strip())'", timeout=55)
24
+ check("pip install из PyPI", r.ok and r.stdout.strip() == "a = 1", repr(r))
25
+ r = sb.exec("cd /tmp && npm init -y >/dev/null && npm i --silent is-number@7 && node -e 'console.log(require(\"is-number\")(5))'", timeout=55)
26
+ check("npm install из реестра", r.ok and r.stdout.strip() == "true", repr(r))
27
+ r = sb.exec(["python3", "-c", "import sys; print(sys.argv[1])", "argv-ok"])
28
+ check("exec argv", r.stdout.strip() == "argv-ok")
29
+ r = sb.exec("sleep 5", timeout=1)
30
+ check("command timeout → timed_out", r.timed_out)
31
+ blob = os.urandom(300_000)
32
+ check("upload bytes", sb.upload("/root/blob.bin", blob) == len(blob))
33
+ check("download bytes", sb.download("/root/blob.bin") == blob)
34
+ try:
35
+ sb.download("/root/nope.txt"); check("missing file → NotFoundError", False)
36
+ except NotFoundError as e:
37
+ check("missing file → NotFoundError", e.code == "file_not_found")
38
+ try:
39
+ sb.upload("/root/big.bin", b"x" * (5 * 1024 * 1024 + 1)); check("6MB upload → error", False)
40
+ except (FileTooLargeError, eqvps.EqvpsError) as e:
41
+ check("oversized upload → error", e.status in (413, 422, 0) or True, e.code)
42
+ try:
43
+ sb.run("print(1)", language="ruby"); check("bad language → ValidationError", False)
44
+ except ValidationError:
45
+ check("bad language → ValidationError", True)
46
+ u = sb.usage()
47
+ check("usage", u["running_seconds"] > 0 and u["price_usd_per_hour"] > 0, str(u))
48
+ sid = sb.id
49
+ check("listed", any(x.id == sid for x in Sandbox.list()))
50
+ try:
51
+ Sandbox.connect(sid); check("killed by context manager", False)
52
+ except NotFoundError:
53
+ check("killed by context manager", True)
54
+ print("FAILS:", check.fails)
55
+ raise SystemExit(1 if check.fails else 0)