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.
Files changed (28) hide show
  1. impulse_compute-0.1.0/.gitignore +6 -0
  2. impulse_compute-0.1.0/LICENSE +21 -0
  3. impulse_compute-0.1.0/PKG-INFO +247 -0
  4. impulse_compute-0.1.0/README.md +228 -0
  5. impulse_compute-0.1.0/pyproject.toml +46 -0
  6. impulse_compute-0.1.0/scripts/unasync.py +90 -0
  7. impulse_compute-0.1.0/src/impulse_compute/__init__.py +73 -0
  8. impulse_compute-0.1.0/src/impulse_compute/_client_async.py +943 -0
  9. impulse_compute-0.1.0/src/impulse_compute/_client_sync.py +943 -0
  10. impulse_compute-0.1.0/src/impulse_compute/_core.py +238 -0
  11. impulse_compute-0.1.0/src/impulse_compute/_errors.py +166 -0
  12. impulse_compute-0.1.0/src/impulse_compute/_generated.py +6568 -0
  13. impulse_compute-0.1.0/src/impulse_compute/_models.py +100 -0
  14. impulse_compute-0.1.0/src/impulse_compute/_sse.py +69 -0
  15. impulse_compute-0.1.0/src/impulse_compute/_support_async.py +61 -0
  16. impulse_compute-0.1.0/src/impulse_compute/_support_sync.py +52 -0
  17. impulse_compute-0.1.0/src/impulse_compute/_transport_async.py +132 -0
  18. impulse_compute-0.1.0/src/impulse_compute/_transport_sync.py +132 -0
  19. impulse_compute-0.1.0/src/impulse_compute/_version.py +1 -0
  20. impulse_compute-0.1.0/src/impulse_compute/py.typed +0 -0
  21. impulse_compute-0.1.0/src/impulse_compute/types.py +4 -0
  22. impulse_compute-0.1.0/tests/__init__.py +0 -0
  23. impulse_compute-0.1.0/tests/conftest.py +25 -0
  24. impulse_compute-0.1.0/tests/support.py +183 -0
  25. impulse_compute-0.1.0/tests/test_client_async.py +981 -0
  26. impulse_compute-0.1.0/tests/test_client_sync.py +981 -0
  27. impulse_compute-0.1.0/tests/test_sse.py +35 -0
  28. impulse_compute-0.1.0/tests/test_sync_only.py +33 -0
@@ -0,0 +1,6 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .mypy_cache/
6
+ .pytest_cache/
@@ -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())