impulse-compute 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.
- impulse_compute-0.1.0/.gitignore +6 -0
- impulse_compute-0.1.0/LICENSE +21 -0
- impulse_compute-0.1.0/PKG-INFO +247 -0
- impulse_compute-0.1.0/README.md +228 -0
- impulse_compute-0.1.0/pyproject.toml +46 -0
- impulse_compute-0.1.0/scripts/unasync.py +90 -0
- impulse_compute-0.1.0/src/impulse_compute/__init__.py +73 -0
- impulse_compute-0.1.0/src/impulse_compute/_client_async.py +943 -0
- impulse_compute-0.1.0/src/impulse_compute/_client_sync.py +943 -0
- impulse_compute-0.1.0/src/impulse_compute/_core.py +238 -0
- impulse_compute-0.1.0/src/impulse_compute/_errors.py +166 -0
- impulse_compute-0.1.0/src/impulse_compute/_generated.py +6568 -0
- impulse_compute-0.1.0/src/impulse_compute/_models.py +100 -0
- impulse_compute-0.1.0/src/impulse_compute/_sse.py +69 -0
- impulse_compute-0.1.0/src/impulse_compute/_support_async.py +61 -0
- impulse_compute-0.1.0/src/impulse_compute/_support_sync.py +52 -0
- impulse_compute-0.1.0/src/impulse_compute/_transport_async.py +132 -0
- impulse_compute-0.1.0/src/impulse_compute/_transport_sync.py +132 -0
- impulse_compute-0.1.0/src/impulse_compute/_version.py +1 -0
- impulse_compute-0.1.0/src/impulse_compute/py.typed +0 -0
- impulse_compute-0.1.0/src/impulse_compute/types.py +4 -0
- impulse_compute-0.1.0/tests/__init__.py +0 -0
- impulse_compute-0.1.0/tests/conftest.py +25 -0
- impulse_compute-0.1.0/tests/support.py +183 -0
- impulse_compute-0.1.0/tests/test_client_async.py +981 -0
- impulse_compute-0.1.0/tests/test_client_sync.py +981 -0
- impulse_compute-0.1.0/tests/test_sse.py +35 -0
- impulse_compute-0.1.0/tests/test_sync_only.py +33 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Impulse Hosting
|
|
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,247 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: impulse-compute
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The ImpulseCompute SDK: sandboxes, commands, files and previews over the Compute API.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: code-execution,compute,impulse,microvm,sandbox
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Requires-Dist: httpx<1,>=0.27
|
|
14
|
+
Requires-Dist: typing-extensions>=4.7
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: mypy<2,>=1.10; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# impulse-compute
|
|
21
|
+
|
|
22
|
+
The Python SDK for ImpulseCompute: sandboxes (each a microVM of its own), the commands they run, their files and the
|
|
23
|
+
ports they expose. Python 3.9 and later, sync (`Compute`) and async (`AsyncCompute`).
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pip install impulse-compute
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quickstart
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from impulse_compute import Compute
|
|
33
|
+
|
|
34
|
+
compute = Compute() # the key from COMPUTE_API_KEY
|
|
35
|
+
|
|
36
|
+
sbx = compute.sandboxes.create(template="node", ttl="30m")
|
|
37
|
+
try:
|
|
38
|
+
r = sbx.commands.run("node -e 'console.log(6 * 7)'")
|
|
39
|
+
print(r.stdout) # 42
|
|
40
|
+
|
|
41
|
+
# A path is absolute, or relative to the user's home (where commands run by default).
|
|
42
|
+
sbx.files.write("hello.txt", "hi\n")
|
|
43
|
+
print(sbx.files.read_text("hello.txt")) # hi
|
|
44
|
+
finally:
|
|
45
|
+
sbx.destroy()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The async client is the same, awaited:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from impulse_compute import AsyncCompute
|
|
52
|
+
|
|
53
|
+
async with AsyncCompute() as compute:
|
|
54
|
+
sbx = await compute.sandboxes.create(template="base")
|
|
55
|
+
r = await sbx.commands.run("uname -a")
|
|
56
|
+
await sbx.destroy()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A sandbox made without a firewall policy reaches no network at all: make it with one that allows what it needs
|
|
60
|
+
(`firewall_policy`).
|
|
61
|
+
|
|
62
|
+
## The client
|
|
63
|
+
|
|
64
|
+
`Compute(...)` takes:
|
|
65
|
+
|
|
66
|
+
- `api_key`: the API key, else `COMPUTE_API_KEY`;
|
|
67
|
+
- `base_url`: the API's address, else `COMPUTE_API_URL`, else `https://api.impulse.host/compute/v1` (Impulse Hosting's,
|
|
68
|
+
which opens with its beta; a hosting provider running ImpulseCompute has an address of its own);
|
|
69
|
+
- `timeout`: how long a call waits for its answer, in seconds (60 by default);
|
|
70
|
+
- `max_retries`: how many times a call that failed for a passing reason is tried again (2 by default);
|
|
71
|
+
- `http_client`, `headers`: an httpx client of your own, and headers for every call.
|
|
72
|
+
|
|
73
|
+
Close it with `compute.close()`, or use it as a context manager.
|
|
74
|
+
|
|
75
|
+
Calls are retried when no answer came (a connection refused or reset, a timeout), on `429`, `502`, `503` and `504`
|
|
76
|
+
unless the API marks the error not retryable (a quota reached is not), and on any error it marks retryable. A retry
|
|
77
|
+
waits 0.5 s doubling (with jitter, up to 8 s), or as long as `Retry-After` says when that is a minute or less; a longer
|
|
78
|
+
wait is left to you, the error's `retry_after` saying how long. Every call that changes something carries an `Idempotency-Key`, the same on each of its retries,
|
|
79
|
+
so a retry never applies a change twice; pass `idempotency_key` where a helper takes one to make a change once across
|
|
80
|
+
your own retries too.
|
|
81
|
+
|
|
82
|
+
## Sandboxes
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
sbx = compute.sandboxes.create(
|
|
86
|
+
template="base", # or node, deno, or a template of yours
|
|
87
|
+
name="build-42",
|
|
88
|
+
ttl="1h", # or seconds, or a timedelta
|
|
89
|
+
idle_suspend="10m", # False never suspends it
|
|
90
|
+
vcpu=2,
|
|
91
|
+
memory_mb=2048,
|
|
92
|
+
env={"APP_ENV": "production"},
|
|
93
|
+
secrets=["DATABASE_URL"],
|
|
94
|
+
firewall_policy="open",
|
|
95
|
+
labels={"team": "data"},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
again = compute.sandboxes.get("build-42")
|
|
99
|
+
for s in compute.sandboxes.list(label="team=data"):
|
|
100
|
+
print(s.id, s.state, s.expires_at)
|
|
101
|
+
|
|
102
|
+
sbx.suspend() # memory and disk saved, memory released
|
|
103
|
+
sbx.resume()
|
|
104
|
+
sbx.extend("2h")
|
|
105
|
+
sbx.destroy()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Commands
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
# One call that waits: a string runs under /bin/sh -c, a list as it is.
|
|
112
|
+
r = sbx.commands.run(["python3", "-c", "print('hi')"], cwd="/srv", timeout=60)
|
|
113
|
+
r.exit_code, r.stdout, r.stderr, r.truncated
|
|
114
|
+
|
|
115
|
+
# Output as it arrives, and input.
|
|
116
|
+
sbx.commands.run("pytest", on_stdout=print, on_stderr=print)
|
|
117
|
+
sbx.commands.run("wc -c", stdin="hello")
|
|
118
|
+
|
|
119
|
+
# A non-zero exit is the result's to say; check makes it a CommandExitError.
|
|
120
|
+
sbx.commands.run("make", check=True)
|
|
121
|
+
|
|
122
|
+
# Started, then written to and read as it runs.
|
|
123
|
+
sh = sbx.commands.start("bash", stdin=True, tty={"rows": 40, "cols": 120})
|
|
124
|
+
sh.send_stdin("ls\nexit\n")
|
|
125
|
+
for ev in sh:
|
|
126
|
+
if ev.type == "stdout":
|
|
127
|
+
print(ev.data.decode(), end="")
|
|
128
|
+
result = sh.wait()
|
|
129
|
+
|
|
130
|
+
# One that runs on until it is signalled.
|
|
131
|
+
server = sbx.commands.start("python3 -m http.server 8080", on_stdout=print)
|
|
132
|
+
server.kill("INT")
|
|
133
|
+
server.wait()
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A command's output is read once. A stream cut short (a connection lost, the sandbox suspended) is resumed after the
|
|
137
|
+
last record read; output missed meanwhile makes the result `truncated`. A result keeps up to 1 MiB of each stream;
|
|
138
|
+
the callbacks and the events see all of it. Reading a command's output resumes its sandbox if it was suspended, as any
|
|
139
|
+
call that reaches the sandbox does; `disconnect()` stops reading (the command runs on), and a later read resumes.
|
|
140
|
+
|
|
141
|
+
## Files
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
sbx.files.write("/srv/data.json", json.dumps(data)) # text, bytes, a binary file or an iterator of bytes
|
|
145
|
+
data = sbx.files.read("/srv/out.bin")
|
|
146
|
+
text = sbx.files.read_text("/srv/out.txt")
|
|
147
|
+
for chunk in sbx.files.read_stream("/var/log/app.log"):
|
|
148
|
+
...
|
|
149
|
+
|
|
150
|
+
listing = sbx.files.list("/srv") # entries, sorted by name
|
|
151
|
+
info = sbx.files.stat("/srv/data.json")
|
|
152
|
+
sbx.files.exists("/srv/missing") # False
|
|
153
|
+
sbx.files.mkdir("/srv/a/b") # its missing parents too
|
|
154
|
+
sbx.files.rename("/srv/a", "/srv/c")
|
|
155
|
+
sbx.files.remove("/srv/c", recursive=True)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
A file is written whole: a reader never sees half of it. Text and bytes carry their SHA-256, so the sandbox refuses a
|
|
159
|
+
body changed on the way. A refusal by the sandbox (a path not found, permission denied) is a `ComputeError` whose
|
|
160
|
+
`path_reason()` says why.
|
|
161
|
+
|
|
162
|
+
## Ports
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
import httpx
|
|
166
|
+
|
|
167
|
+
app = sbx.commands.start("python3 -m http.server 8080")
|
|
168
|
+
preview = sbx.ports.expose(8080) # token-gated
|
|
169
|
+
# The app may take a moment to listen: until it does, the preview answers an error.
|
|
170
|
+
httpx.get(preview.url, headers=preview.headers()) # Impulse-Preview-Token
|
|
171
|
+
public = sbx.ports.expose(3000, public=True) # anyone with the URL: for a browser or an iframe
|
|
172
|
+
rotated = sbx.ports.rotate_token(8080, token_ttl="1h")
|
|
173
|
+
sbx.ports.unexpose(8080)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
A private port's access token is in the answer that minted it only. When that answer is lost and a retry is told the
|
|
177
|
+
token was already issued, the SDK rotates the token and returns the new one.
|
|
178
|
+
|
|
179
|
+
## Templates and operations
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
tpl = compute.templates.build("py-data", image="python:3.12-slim")
|
|
183
|
+
sbx = compute.sandboxes.create(template="py-data")
|
|
184
|
+
|
|
185
|
+
# A call that starts work returns an Operation; wait for it.
|
|
186
|
+
op = compute.api.restart_service("api")
|
|
187
|
+
compute.operations.wait(op) # OperationFailedError, OperationTimeoutError
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Cloaked secrets
|
|
191
|
+
|
|
192
|
+
A secret set with a `cloak` reaches your sandboxes and services as a placeholder. The egress proxy outside the VM
|
|
193
|
+
puts the value in the named request headers (`Authorization` unless you say otherwise) of HTTPS requests to the hosts
|
|
194
|
+
you named, and nowhere else.
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
compute.api.set_env_var("OPENAI_API_KEY", {"value": "sk-...", "secret": True, "cloak": {"hosts": ["api.openai.com"]}})
|
|
198
|
+
sbx = compute.sandboxes.create(template="node", secrets=["OPENAI_API_KEY"], firewall_policy="open")
|
|
199
|
+
# In the sandbox, OPENAI_API_KEY is impulse_cloaked_...; a request to api.openai.com carries the key.
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The workload trusts the proxy through a certificate authority given to it at its start (`SSL_CERT_FILE`,
|
|
203
|
+
`REQUESTS_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS` and the like are set for you). A value a host echoes in a response (as it
|
|
204
|
+
was sent, as JSON writes it, or percent-encoded) is turned back into the placeholder; one echoed in another form
|
|
205
|
+
(base64, a part of it) would still show the workload the value: name only the hosts the secret is for, and cloak a
|
|
206
|
+
random key.
|
|
207
|
+
|
|
208
|
+
## Scoped sandboxes
|
|
209
|
+
|
|
210
|
+
A sandbox can be granted a prefix of one of your per-workload storage bindings, read-only unless you ask for
|
|
211
|
+
`read_write` (the API key needs `compute:manage` as well as `compute:sandbox`), and can act as one of your Impulse
|
|
212
|
+
Auth users with the access token your Auth service issued that user. It holds no standing key: the AWS SDKs inside it
|
|
213
|
+
fetch short-lived keys from the machine that runs it, and it reads the token at `IMPULSE_ACT_AS_TOKEN_URI`.
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
sbx = compute.sandboxes.create(
|
|
217
|
+
template="node",
|
|
218
|
+
ttl="1h", # at least as long as the token lasts
|
|
219
|
+
grant={"binding": "agent-data", "prefix": "runs/42/"},
|
|
220
|
+
act_as={"auth_binding": "users", "user_access_token": user_access_token},
|
|
221
|
+
)
|
|
222
|
+
sbx.grant # {"binding", "prefix", "access", "generation"}
|
|
223
|
+
sbx.act_as # {"auth_binding", "user_id", "expires_at"}, never the token
|
|
224
|
+
sbx.replace_act_as(fresh_token) # the same user, before the token ends
|
|
225
|
+
sbx.drop_act_as()
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The token is the user's whole credential while it lasts: anything in the sandbox can read it. Require
|
|
229
|
+
reauthentication for password changes in every project whose tokens you hand to a sandbox.
|
|
230
|
+
|
|
231
|
+
## Everything else
|
|
232
|
+
|
|
233
|
+
`compute.api` has one method per operation of the API, with its types, for what the helpers do not cover: services,
|
|
234
|
+
deployments, environment variables, firewall policies and logs.
|
|
235
|
+
|
|
236
|
+
## Errors
|
|
237
|
+
|
|
238
|
+
- `ComputeError`: the API answered with an error. It has the `status`, the `code` (`resource_not_found`,
|
|
239
|
+
`quota_exceeded`, …), the `detail`, the `request_id` to quote to support, and the rest of the problem.
|
|
240
|
+
- `ComputeConnectionError`: no answer, after the call's retries.
|
|
241
|
+
- `OperationFailedError`, `OperationTimeoutError`, `CommandExitError` and `DisconnectedError`.
|
|
242
|
+
|
|
243
|
+
All of them are `ImpulseComputeError`s.
|
|
244
|
+
|
|
245
|
+
## Licence
|
|
246
|
+
|
|
247
|
+
MIT.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# impulse-compute
|
|
2
|
+
|
|
3
|
+
The Python SDK for ImpulseCompute: sandboxes (each a microVM of its own), the commands they run, their files and the
|
|
4
|
+
ports they expose. Python 3.9 and later, sync (`Compute`) and async (`AsyncCompute`).
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pip install impulse-compute
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Quickstart
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from impulse_compute import Compute
|
|
14
|
+
|
|
15
|
+
compute = Compute() # the key from COMPUTE_API_KEY
|
|
16
|
+
|
|
17
|
+
sbx = compute.sandboxes.create(template="node", ttl="30m")
|
|
18
|
+
try:
|
|
19
|
+
r = sbx.commands.run("node -e 'console.log(6 * 7)'")
|
|
20
|
+
print(r.stdout) # 42
|
|
21
|
+
|
|
22
|
+
# A path is absolute, or relative to the user's home (where commands run by default).
|
|
23
|
+
sbx.files.write("hello.txt", "hi\n")
|
|
24
|
+
print(sbx.files.read_text("hello.txt")) # hi
|
|
25
|
+
finally:
|
|
26
|
+
sbx.destroy()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The async client is the same, awaited:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from impulse_compute import AsyncCompute
|
|
33
|
+
|
|
34
|
+
async with AsyncCompute() as compute:
|
|
35
|
+
sbx = await compute.sandboxes.create(template="base")
|
|
36
|
+
r = await sbx.commands.run("uname -a")
|
|
37
|
+
await sbx.destroy()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A sandbox made without a firewall policy reaches no network at all: make it with one that allows what it needs
|
|
41
|
+
(`firewall_policy`).
|
|
42
|
+
|
|
43
|
+
## The client
|
|
44
|
+
|
|
45
|
+
`Compute(...)` takes:
|
|
46
|
+
|
|
47
|
+
- `api_key`: the API key, else `COMPUTE_API_KEY`;
|
|
48
|
+
- `base_url`: the API's address, else `COMPUTE_API_URL`, else `https://api.impulse.host/compute/v1` (Impulse Hosting's,
|
|
49
|
+
which opens with its beta; a hosting provider running ImpulseCompute has an address of its own);
|
|
50
|
+
- `timeout`: how long a call waits for its answer, in seconds (60 by default);
|
|
51
|
+
- `max_retries`: how many times a call that failed for a passing reason is tried again (2 by default);
|
|
52
|
+
- `http_client`, `headers`: an httpx client of your own, and headers for every call.
|
|
53
|
+
|
|
54
|
+
Close it with `compute.close()`, or use it as a context manager.
|
|
55
|
+
|
|
56
|
+
Calls are retried when no answer came (a connection refused or reset, a timeout), on `429`, `502`, `503` and `504`
|
|
57
|
+
unless the API marks the error not retryable (a quota reached is not), and on any error it marks retryable. A retry
|
|
58
|
+
waits 0.5 s doubling (with jitter, up to 8 s), or as long as `Retry-After` says when that is a minute or less; a longer
|
|
59
|
+
wait is left to you, the error's `retry_after` saying how long. Every call that changes something carries an `Idempotency-Key`, the same on each of its retries,
|
|
60
|
+
so a retry never applies a change twice; pass `idempotency_key` where a helper takes one to make a change once across
|
|
61
|
+
your own retries too.
|
|
62
|
+
|
|
63
|
+
## Sandboxes
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
sbx = compute.sandboxes.create(
|
|
67
|
+
template="base", # or node, deno, or a template of yours
|
|
68
|
+
name="build-42",
|
|
69
|
+
ttl="1h", # or seconds, or a timedelta
|
|
70
|
+
idle_suspend="10m", # False never suspends it
|
|
71
|
+
vcpu=2,
|
|
72
|
+
memory_mb=2048,
|
|
73
|
+
env={"APP_ENV": "production"},
|
|
74
|
+
secrets=["DATABASE_URL"],
|
|
75
|
+
firewall_policy="open",
|
|
76
|
+
labels={"team": "data"},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
again = compute.sandboxes.get("build-42")
|
|
80
|
+
for s in compute.sandboxes.list(label="team=data"):
|
|
81
|
+
print(s.id, s.state, s.expires_at)
|
|
82
|
+
|
|
83
|
+
sbx.suspend() # memory and disk saved, memory released
|
|
84
|
+
sbx.resume()
|
|
85
|
+
sbx.extend("2h")
|
|
86
|
+
sbx.destroy()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Commands
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# One call that waits: a string runs under /bin/sh -c, a list as it is.
|
|
93
|
+
r = sbx.commands.run(["python3", "-c", "print('hi')"], cwd="/srv", timeout=60)
|
|
94
|
+
r.exit_code, r.stdout, r.stderr, r.truncated
|
|
95
|
+
|
|
96
|
+
# Output as it arrives, and input.
|
|
97
|
+
sbx.commands.run("pytest", on_stdout=print, on_stderr=print)
|
|
98
|
+
sbx.commands.run("wc -c", stdin="hello")
|
|
99
|
+
|
|
100
|
+
# A non-zero exit is the result's to say; check makes it a CommandExitError.
|
|
101
|
+
sbx.commands.run("make", check=True)
|
|
102
|
+
|
|
103
|
+
# Started, then written to and read as it runs.
|
|
104
|
+
sh = sbx.commands.start("bash", stdin=True, tty={"rows": 40, "cols": 120})
|
|
105
|
+
sh.send_stdin("ls\nexit\n")
|
|
106
|
+
for ev in sh:
|
|
107
|
+
if ev.type == "stdout":
|
|
108
|
+
print(ev.data.decode(), end="")
|
|
109
|
+
result = sh.wait()
|
|
110
|
+
|
|
111
|
+
# One that runs on until it is signalled.
|
|
112
|
+
server = sbx.commands.start("python3 -m http.server 8080", on_stdout=print)
|
|
113
|
+
server.kill("INT")
|
|
114
|
+
server.wait()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
A command's output is read once. A stream cut short (a connection lost, the sandbox suspended) is resumed after the
|
|
118
|
+
last record read; output missed meanwhile makes the result `truncated`. A result keeps up to 1 MiB of each stream;
|
|
119
|
+
the callbacks and the events see all of it. Reading a command's output resumes its sandbox if it was suspended, as any
|
|
120
|
+
call that reaches the sandbox does; `disconnect()` stops reading (the command runs on), and a later read resumes.
|
|
121
|
+
|
|
122
|
+
## Files
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
sbx.files.write("/srv/data.json", json.dumps(data)) # text, bytes, a binary file or an iterator of bytes
|
|
126
|
+
data = sbx.files.read("/srv/out.bin")
|
|
127
|
+
text = sbx.files.read_text("/srv/out.txt")
|
|
128
|
+
for chunk in sbx.files.read_stream("/var/log/app.log"):
|
|
129
|
+
...
|
|
130
|
+
|
|
131
|
+
listing = sbx.files.list("/srv") # entries, sorted by name
|
|
132
|
+
info = sbx.files.stat("/srv/data.json")
|
|
133
|
+
sbx.files.exists("/srv/missing") # False
|
|
134
|
+
sbx.files.mkdir("/srv/a/b") # its missing parents too
|
|
135
|
+
sbx.files.rename("/srv/a", "/srv/c")
|
|
136
|
+
sbx.files.remove("/srv/c", recursive=True)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A file is written whole: a reader never sees half of it. Text and bytes carry their SHA-256, so the sandbox refuses a
|
|
140
|
+
body changed on the way. A refusal by the sandbox (a path not found, permission denied) is a `ComputeError` whose
|
|
141
|
+
`path_reason()` says why.
|
|
142
|
+
|
|
143
|
+
## Ports
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
import httpx
|
|
147
|
+
|
|
148
|
+
app = sbx.commands.start("python3 -m http.server 8080")
|
|
149
|
+
preview = sbx.ports.expose(8080) # token-gated
|
|
150
|
+
# The app may take a moment to listen: until it does, the preview answers an error.
|
|
151
|
+
httpx.get(preview.url, headers=preview.headers()) # Impulse-Preview-Token
|
|
152
|
+
public = sbx.ports.expose(3000, public=True) # anyone with the URL: for a browser or an iframe
|
|
153
|
+
rotated = sbx.ports.rotate_token(8080, token_ttl="1h")
|
|
154
|
+
sbx.ports.unexpose(8080)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
A private port's access token is in the answer that minted it only. When that answer is lost and a retry is told the
|
|
158
|
+
token was already issued, the SDK rotates the token and returns the new one.
|
|
159
|
+
|
|
160
|
+
## Templates and operations
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
tpl = compute.templates.build("py-data", image="python:3.12-slim")
|
|
164
|
+
sbx = compute.sandboxes.create(template="py-data")
|
|
165
|
+
|
|
166
|
+
# A call that starts work returns an Operation; wait for it.
|
|
167
|
+
op = compute.api.restart_service("api")
|
|
168
|
+
compute.operations.wait(op) # OperationFailedError, OperationTimeoutError
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Cloaked secrets
|
|
172
|
+
|
|
173
|
+
A secret set with a `cloak` reaches your sandboxes and services as a placeholder. The egress proxy outside the VM
|
|
174
|
+
puts the value in the named request headers (`Authorization` unless you say otherwise) of HTTPS requests to the hosts
|
|
175
|
+
you named, and nowhere else.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
compute.api.set_env_var("OPENAI_API_KEY", {"value": "sk-...", "secret": True, "cloak": {"hosts": ["api.openai.com"]}})
|
|
179
|
+
sbx = compute.sandboxes.create(template="node", secrets=["OPENAI_API_KEY"], firewall_policy="open")
|
|
180
|
+
# In the sandbox, OPENAI_API_KEY is impulse_cloaked_...; a request to api.openai.com carries the key.
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The workload trusts the proxy through a certificate authority given to it at its start (`SSL_CERT_FILE`,
|
|
184
|
+
`REQUESTS_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS` and the like are set for you). A value a host echoes in a response (as it
|
|
185
|
+
was sent, as JSON writes it, or percent-encoded) is turned back into the placeholder; one echoed in another form
|
|
186
|
+
(base64, a part of it) would still show the workload the value: name only the hosts the secret is for, and cloak a
|
|
187
|
+
random key.
|
|
188
|
+
|
|
189
|
+
## Scoped sandboxes
|
|
190
|
+
|
|
191
|
+
A sandbox can be granted a prefix of one of your per-workload storage bindings, read-only unless you ask for
|
|
192
|
+
`read_write` (the API key needs `compute:manage` as well as `compute:sandbox`), and can act as one of your Impulse
|
|
193
|
+
Auth users with the access token your Auth service issued that user. It holds no standing key: the AWS SDKs inside it
|
|
194
|
+
fetch short-lived keys from the machine that runs it, and it reads the token at `IMPULSE_ACT_AS_TOKEN_URI`.
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
sbx = compute.sandboxes.create(
|
|
198
|
+
template="node",
|
|
199
|
+
ttl="1h", # at least as long as the token lasts
|
|
200
|
+
grant={"binding": "agent-data", "prefix": "runs/42/"},
|
|
201
|
+
act_as={"auth_binding": "users", "user_access_token": user_access_token},
|
|
202
|
+
)
|
|
203
|
+
sbx.grant # {"binding", "prefix", "access", "generation"}
|
|
204
|
+
sbx.act_as # {"auth_binding", "user_id", "expires_at"}, never the token
|
|
205
|
+
sbx.replace_act_as(fresh_token) # the same user, before the token ends
|
|
206
|
+
sbx.drop_act_as()
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The token is the user's whole credential while it lasts: anything in the sandbox can read it. Require
|
|
210
|
+
reauthentication for password changes in every project whose tokens you hand to a sandbox.
|
|
211
|
+
|
|
212
|
+
## Everything else
|
|
213
|
+
|
|
214
|
+
`compute.api` has one method per operation of the API, with its types, for what the helpers do not cover: services,
|
|
215
|
+
deployments, environment variables, firewall policies and logs.
|
|
216
|
+
|
|
217
|
+
## Errors
|
|
218
|
+
|
|
219
|
+
- `ComputeError`: the API answered with an error. It has the `status`, the `code` (`resource_not_found`,
|
|
220
|
+
`quota_exceeded`, …), the `detail`, the `request_id` to quote to support, and the rest of the problem.
|
|
221
|
+
- `ComputeConnectionError`: no answer, after the call's retries.
|
|
222
|
+
- `OperationFailedError`, `OperationTimeoutError`, `CommandExitError` and `DisconnectedError`.
|
|
223
|
+
|
|
224
|
+
All of them are `ImpulseComputeError`s.
|
|
225
|
+
|
|
226
|
+
## Licence
|
|
227
|
+
|
|
228
|
+
MIT.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "impulse-compute"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The ImpulseCompute SDK: sandboxes, commands, files and previews over the Compute API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"httpx>=0.27,<1",
|
|
15
|
+
"typing_extensions>=4.7",
|
|
16
|
+
]
|
|
17
|
+
keywords = ["impulse", "compute", "sandbox", "microvm", "code-execution"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = ["pytest>=8", "mypy>=1.10,<2"]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/impulse_compute"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.sdist]
|
|
32
|
+
include = ["src/impulse_compute", "tests", "scripts", "README.md", "LICENSE"]
|
|
33
|
+
|
|
34
|
+
[tool.mypy]
|
|
35
|
+
strict = true
|
|
36
|
+
python_version = "3.9"
|
|
37
|
+
files = ["src/impulse_compute", "tests"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
|
|
42
|
+
# Read as the Python 3.9 the package supports: a newer interpreter's copies of these use newer syntax, and the SDK
|
|
43
|
+
# never reaches into them.
|
|
44
|
+
[[tool.mypy.overrides]]
|
|
45
|
+
module = ["anyio", "anyio.*", "sniffio", "sniffio.*"]
|
|
46
|
+
follow_imports = "skip"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Makes the sync client from the async one: _transport_async.py, _client_async.py and their tests become
|
|
2
|
+
_transport_sync.py, _client_sync.py and test_client_sync.py. With --check, fails when one differs from what it would
|
|
3
|
+
write.
|
|
4
|
+
|
|
5
|
+
Run from sdk/python: python scripts/unasync.py [--check]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
15
|
+
|
|
16
|
+
# Whole words, in order: the specific before the general.
|
|
17
|
+
WORDS = [
|
|
18
|
+
("_support_async", "_support_sync"),
|
|
19
|
+
("_transport_async", "_transport_sync"),
|
|
20
|
+
("_client_async", "_client_sync"),
|
|
21
|
+
("AsyncTransport", "SyncTransport"),
|
|
22
|
+
("AsyncApi", "Api"),
|
|
23
|
+
("AsyncIterator", "Iterator"),
|
|
24
|
+
("AsyncIterable", "Iterable"),
|
|
25
|
+
("StopAsyncIteration", "StopIteration"),
|
|
26
|
+
("__aenter__", "__enter__"),
|
|
27
|
+
("__aexit__", "__exit__"),
|
|
28
|
+
("__aiter__", "__iter__"),
|
|
29
|
+
("__anext__", "__next__"),
|
|
30
|
+
("aclose", "close"),
|
|
31
|
+
("aread", "read"),
|
|
32
|
+
("aiter_bytes", "iter_bytes"),
|
|
33
|
+
]
|
|
34
|
+
PATTERNS = [
|
|
35
|
+
(re.compile(r"\bAsync([A-Z]\w*)"), r"\1"),
|
|
36
|
+
(re.compile(r"\basync (def|for|with)\b"), r"\1"),
|
|
37
|
+
(re.compile(r"\bawait "), ""),
|
|
38
|
+
]
|
|
39
|
+
# Each file's docstring opening, as written and as made: kept out of the rewriting.
|
|
40
|
+
HEADERS = {
|
|
41
|
+
"src/impulse_compute/_transport_async.py": (
|
|
42
|
+
"Sends the API's calls: authentication, request IDs, idempotency keys, retries and errors.\n\n"
|
|
43
|
+
"The sync transport (_transport_sync) is made from this one by scripts/unasync.py: edit this file, then run it.",
|
|
44
|
+
"Sends the API's calls: authentication, request IDs, idempotency keys, retries and errors.\n\n"
|
|
45
|
+
"Made from _transport_async.py by scripts/unasync.py: edit that file, then run it.",
|
|
46
|
+
),
|
|
47
|
+
"src/impulse_compute/_client_async.py": (
|
|
48
|
+
"The async client and its helpers.\n\n"
|
|
49
|
+
"The sync client (_client_sync) is made from this one by scripts/unasync.py: edit this file, then run it.",
|
|
50
|
+
"The sync client and its helpers.\n\nMade from _client_async.py by scripts/unasync.py: edit that file, then run it.",
|
|
51
|
+
),
|
|
52
|
+
"tests/test_client_async.py": (
|
|
53
|
+
"The async client's tests.\n\n"
|
|
54
|
+
"The sync client's (test_client_sync) are made from these by scripts/unasync.py: edit this file, then run it.",
|
|
55
|
+
"The sync client's tests.\n\nMade from test_client_async.py by scripts/unasync.py: edit that file, then run it.",
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def unasync(name: str, text: str) -> str:
|
|
61
|
+
before, after = HEADERS[name]
|
|
62
|
+
if before not in text:
|
|
63
|
+
raise SystemExit(f"{name}: its docstring does not open as this script expects")
|
|
64
|
+
text = text.replace(before, "\0")
|
|
65
|
+
for old, new in WORDS:
|
|
66
|
+
text = re.sub(rf"\b{re.escape(old)}\b", new, text)
|
|
67
|
+
for pattern, new in PATTERNS:
|
|
68
|
+
text = pattern.sub(new, text)
|
|
69
|
+
return text.replace("\0", after)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main() -> int:
|
|
73
|
+
check = "--check" in sys.argv[1:]
|
|
74
|
+
stale = []
|
|
75
|
+
for name in HEADERS:
|
|
76
|
+
out = ROOT / name.replace("_async", "_sync")
|
|
77
|
+
want = unasync(name, (ROOT / name).read_text())
|
|
78
|
+
if check:
|
|
79
|
+
if not out.exists() or out.read_text() != want:
|
|
80
|
+
stale.append(name.replace("_async", "_sync"))
|
|
81
|
+
else:
|
|
82
|
+
out.write_text(want)
|
|
83
|
+
if stale:
|
|
84
|
+
print(f"stale (run python scripts/unasync.py): {', '.join(stale)}", file=sys.stderr)
|
|
85
|
+
return 1
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
sys.exit(main())
|