orkestr 0.1.0a1__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.
- orkestr-0.1.0a1/LICENSE +21 -0
- orkestr-0.1.0a1/PKG-INFO +201 -0
- orkestr-0.1.0a1/README.md +174 -0
- orkestr-0.1.0a1/orkestr/__init__.py +59 -0
- orkestr-0.1.0a1/orkestr/_client.py +239 -0
- orkestr-0.1.0a1/orkestr/_streaming.py +147 -0
- orkestr-0.1.0a1/orkestr/errors.py +175 -0
- orkestr-0.1.0a1/orkestr/sandbox.py +501 -0
- orkestr-0.1.0a1/orkestr.egg-info/PKG-INFO +201 -0
- orkestr-0.1.0a1/orkestr.egg-info/SOURCES.txt +16 -0
- orkestr-0.1.0a1/orkestr.egg-info/dependency_links.txt +1 -0
- orkestr-0.1.0a1/orkestr.egg-info/requires.txt +6 -0
- orkestr-0.1.0a1/orkestr.egg-info/top_level.txt +1 -0
- orkestr-0.1.0a1/pyproject.toml +55 -0
- orkestr-0.1.0a1/setup.cfg +4 -0
- orkestr-0.1.0a1/tests/test_client.py +182 -0
- orkestr-0.1.0a1/tests/test_sandbox.py +433 -0
- orkestr-0.1.0a1/tests/test_streaming.py +157 -0
orkestr-0.1.0a1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Orkestr
|
|
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.
|
orkestr-0.1.0a1/PKG-INFO
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: orkestr
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: EU-hosted sandbox VMs for AI agents - official Python SDK
|
|
5
|
+
Author-email: Orkestr <hello@orkestr.eu>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://orkestr.eu/sandboxes
|
|
8
|
+
Project-URL: Documentation, https://orkestr.eu/docs
|
|
9
|
+
Keywords: orkestr,sandbox,ai,agents,code-execution,eu
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
24
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# orkestr Python SDK
|
|
29
|
+
|
|
30
|
+
EU-hosted sandbox VMs for AI agents. Type-safe Python client for the
|
|
31
|
+
`api.orkestr.eu/v1/sandboxes` REST API.
|
|
32
|
+
|
|
33
|
+
> **Status: private beta pre-release (`0.1.0a1`).** The client surface is
|
|
34
|
+
> stable and tested, but the `api.orkestr.eu/v1/sandboxes` backend is not
|
|
35
|
+
> live yet - every endpoint returns `501 Not Implemented` until the public
|
|
36
|
+
> beta. This release exists so you can pin the package and review the API.
|
|
37
|
+
> See `docs/sandbox-mvp-plan.md` in the orkestr repo for the timeline.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install --pre orkestr
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Requires Python 3.10+. The `--pre` flag is needed while the SDK is a
|
|
46
|
+
pre-release; it is dropped once `0.1.0` ships with the public beta.
|
|
47
|
+
|
|
48
|
+
## Authenticate
|
|
49
|
+
|
|
50
|
+
Mint an API token in the orkestr console with the `sandboxes:write` scope.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export ORKESTR_API_KEY="sp_live_..."
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The SDK picks the key up from `ORKESTR_API_KEY` or you pass it explicitly.
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
One-shot execution inside a fresh sandbox. The `with` block auto-terminates
|
|
61
|
+
the sandbox on exit, preventing runaway costs if the caller crashes.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from orkestr import Sandbox
|
|
65
|
+
|
|
66
|
+
with Sandbox.create(template="python-3.12") as sbx:
|
|
67
|
+
sbx.files.write("/workspace/main.py", "print(sum(range(1_000_000)))")
|
|
68
|
+
result = sbx.exec("python /workspace/main.py")
|
|
69
|
+
print(result.stdout) # 499999500000
|
|
70
|
+
print(result.duration_ms) # ~120
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
### Create a sandbox
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
sbx = Sandbox.create(
|
|
79
|
+
template="python-3.12", # one of the templates listed below
|
|
80
|
+
cpu=1.0, # vCPU (default 1, plan-capped)
|
|
81
|
+
memory_mb=2048, # RAM (default 2048, plan-capped)
|
|
82
|
+
network="off", # "off" | "restricted" | "open"
|
|
83
|
+
timeout_seconds=600, # auto-terminate after this many seconds
|
|
84
|
+
env={"OPENAI_API_KEY": "sk-..."},
|
|
85
|
+
metadata={"agent_run": "r_123"},
|
|
86
|
+
region="fsn1", # "fsn1" (DE) | "hel1" (FI) | None for auto
|
|
87
|
+
api_key=None, # falls back to ORKESTR_API_KEY
|
|
88
|
+
)
|
|
89
|
+
print(sbx.id) # "sbx_01HXYZ..."
|
|
90
|
+
print(sbx.status) # "running"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Templates
|
|
94
|
+
|
|
95
|
+
| Template | Description |
|
|
96
|
+
|---------------------|------------------------------------------|
|
|
97
|
+
| `python-3.12` | CPython 3.12 with pip and common libs |
|
|
98
|
+
| `python-3.12-bare` | CPython 3.12 only, faster start |
|
|
99
|
+
| `node-22` | Node 22 with npm |
|
|
100
|
+
| `ubuntu-24.04` | Minimal Ubuntu shell environment |
|
|
101
|
+
|
|
102
|
+
### Run a command
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
result = sbx.exec("python /workspace/main.py", timeout_seconds=60)
|
|
106
|
+
result.stdout # str
|
|
107
|
+
result.stderr # str
|
|
108
|
+
result.exit_code # int
|
|
109
|
+
result.duration_ms # int
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Stream a long command
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
for chunk in sbx.exec_stream("python long_task.py"):
|
|
116
|
+
if chunk.stream == "stdout":
|
|
117
|
+
print(chunk.data, end="", flush=True)
|
|
118
|
+
else:
|
|
119
|
+
print(chunk.data, end="", flush=True, file=sys.stderr)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Files
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
sbx.files.write("/workspace/data.json", '{"x": 1}')
|
|
126
|
+
sbx.files.write_bytes("/workspace/blob.bin", b"\x00\x01\x02")
|
|
127
|
+
|
|
128
|
+
content = sbx.files.read("/workspace/out.txt") # returns str
|
|
129
|
+
raw = sbx.files.read_bytes("/workspace/blob.bin") # returns bytes
|
|
130
|
+
|
|
131
|
+
for entry in sbx.files.list("/workspace"):
|
|
132
|
+
print(entry.name, entry.is_dir, entry.size)
|
|
133
|
+
|
|
134
|
+
sbx.files.delete("/workspace/out.txt")
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Pause + resume
|
|
138
|
+
|
|
139
|
+
Pausing snapshots the sandbox memory + disk and stops the compute meter.
|
|
140
|
+
Resume restores it on the same or a different host. `pause()` returns
|
|
141
|
+
the sandbox id; persist it across processes and pass to `Sandbox.resume`.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
sbx = Sandbox.create(template="node-22", network="restricted")
|
|
145
|
+
sandbox_id = sbx.pause()
|
|
146
|
+
# ... minutes or hours later, from any worker:
|
|
147
|
+
sbx = Sandbox.resume(sandbox_id)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Snapshot retention is plan-capped (free: 1, pro: 3, team: 10). Calling
|
|
151
|
+
`pause()` over the cap raises `SnapshotCapReached`.
|
|
152
|
+
|
|
153
|
+
### Terminate
|
|
154
|
+
|
|
155
|
+
Context-manager exit calls `terminate()`. Outside `with`:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
sbx.terminate()
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
After `terminate()` the sandbox row stays in your account history but
|
|
162
|
+
the VM and any in-memory state are gone.
|
|
163
|
+
|
|
164
|
+
### List your sandboxes
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
for sbx in Sandbox.list(status="running"):
|
|
168
|
+
print(sbx.id, sbx.template, sbx.created_at)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Errors
|
|
172
|
+
|
|
173
|
+
All SDK errors inherit from `orkestr.OrkestrError`.
|
|
174
|
+
|
|
175
|
+
| Exception | When |
|
|
176
|
+
|--------------------------|---------------------------------------------------|
|
|
177
|
+
| `AuthError` | Missing / invalid / expired API key, or scope mismatch |
|
|
178
|
+
| `RateLimitError` | Plan rate limit hit |
|
|
179
|
+
| `PlanLimitError` | Sandbox limit, concurrent limit, snapshot cap |
|
|
180
|
+
| `SandboxNotFound` | `sandbox_id` doesn't exist or isn't yours |
|
|
181
|
+
| `SandboxNotReady` | Operation called on a paused / terminated sandbox |
|
|
182
|
+
| `ExecTimeout` | `exec()` exceeded `timeout_seconds` |
|
|
183
|
+
| `NetworkPolicyError` | Command tried to reach a host the policy blocks |
|
|
184
|
+
| `OrkestrError` | Any other API-level error |
|
|
185
|
+
|
|
186
|
+
## Async support
|
|
187
|
+
|
|
188
|
+
The MVP SDK is sync only. Async (`AsyncSandbox`) lands in v0.2.0 if a
|
|
189
|
+
design partner blocks on it; the wire format is identical.
|
|
190
|
+
|
|
191
|
+
## Versioning
|
|
192
|
+
|
|
193
|
+
Follows [Semantic Versioning](https://semver.org). The SDK targets the
|
|
194
|
+
`/v1/sandboxes` API; bumps to v1 of the API are non-breaking for SDK
|
|
195
|
+
callers. v2 of the API will require an SDK major.
|
|
196
|
+
|
|
197
|
+
## Links
|
|
198
|
+
|
|
199
|
+
- [Sandbox waitlist](https://orkestr.eu/sandboxes)
|
|
200
|
+
- [Public API docs](https://orkestr.eu/docs)
|
|
201
|
+
- Questions and bug reports during the private beta: <hello@orkestr.eu>
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# orkestr Python SDK
|
|
2
|
+
|
|
3
|
+
EU-hosted sandbox VMs for AI agents. Type-safe Python client for the
|
|
4
|
+
`api.orkestr.eu/v1/sandboxes` REST API.
|
|
5
|
+
|
|
6
|
+
> **Status: private beta pre-release (`0.1.0a1`).** The client surface is
|
|
7
|
+
> stable and tested, but the `api.orkestr.eu/v1/sandboxes` backend is not
|
|
8
|
+
> live yet - every endpoint returns `501 Not Implemented` until the public
|
|
9
|
+
> beta. This release exists so you can pin the package and review the API.
|
|
10
|
+
> See `docs/sandbox-mvp-plan.md` in the orkestr repo for the timeline.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install --pre orkestr
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Requires Python 3.10+. The `--pre` flag is needed while the SDK is a
|
|
19
|
+
pre-release; it is dropped once `0.1.0` ships with the public beta.
|
|
20
|
+
|
|
21
|
+
## Authenticate
|
|
22
|
+
|
|
23
|
+
Mint an API token in the orkestr console with the `sandboxes:write` scope.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export ORKESTR_API_KEY="sp_live_..."
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The SDK picks the key up from `ORKESTR_API_KEY` or you pass it explicitly.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
One-shot execution inside a fresh sandbox. The `with` block auto-terminates
|
|
34
|
+
the sandbox on exit, preventing runaway costs if the caller crashes.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from orkestr import Sandbox
|
|
38
|
+
|
|
39
|
+
with Sandbox.create(template="python-3.12") as sbx:
|
|
40
|
+
sbx.files.write("/workspace/main.py", "print(sum(range(1_000_000)))")
|
|
41
|
+
result = sbx.exec("python /workspace/main.py")
|
|
42
|
+
print(result.stdout) # 499999500000
|
|
43
|
+
print(result.duration_ms) # ~120
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## API
|
|
47
|
+
|
|
48
|
+
### Create a sandbox
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
sbx = Sandbox.create(
|
|
52
|
+
template="python-3.12", # one of the templates listed below
|
|
53
|
+
cpu=1.0, # vCPU (default 1, plan-capped)
|
|
54
|
+
memory_mb=2048, # RAM (default 2048, plan-capped)
|
|
55
|
+
network="off", # "off" | "restricted" | "open"
|
|
56
|
+
timeout_seconds=600, # auto-terminate after this many seconds
|
|
57
|
+
env={"OPENAI_API_KEY": "sk-..."},
|
|
58
|
+
metadata={"agent_run": "r_123"},
|
|
59
|
+
region="fsn1", # "fsn1" (DE) | "hel1" (FI) | None for auto
|
|
60
|
+
api_key=None, # falls back to ORKESTR_API_KEY
|
|
61
|
+
)
|
|
62
|
+
print(sbx.id) # "sbx_01HXYZ..."
|
|
63
|
+
print(sbx.status) # "running"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Templates
|
|
67
|
+
|
|
68
|
+
| Template | Description |
|
|
69
|
+
|---------------------|------------------------------------------|
|
|
70
|
+
| `python-3.12` | CPython 3.12 with pip and common libs |
|
|
71
|
+
| `python-3.12-bare` | CPython 3.12 only, faster start |
|
|
72
|
+
| `node-22` | Node 22 with npm |
|
|
73
|
+
| `ubuntu-24.04` | Minimal Ubuntu shell environment |
|
|
74
|
+
|
|
75
|
+
### Run a command
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
result = sbx.exec("python /workspace/main.py", timeout_seconds=60)
|
|
79
|
+
result.stdout # str
|
|
80
|
+
result.stderr # str
|
|
81
|
+
result.exit_code # int
|
|
82
|
+
result.duration_ms # int
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Stream a long command
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
for chunk in sbx.exec_stream("python long_task.py"):
|
|
89
|
+
if chunk.stream == "stdout":
|
|
90
|
+
print(chunk.data, end="", flush=True)
|
|
91
|
+
else:
|
|
92
|
+
print(chunk.data, end="", flush=True, file=sys.stderr)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Files
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
sbx.files.write("/workspace/data.json", '{"x": 1}')
|
|
99
|
+
sbx.files.write_bytes("/workspace/blob.bin", b"\x00\x01\x02")
|
|
100
|
+
|
|
101
|
+
content = sbx.files.read("/workspace/out.txt") # returns str
|
|
102
|
+
raw = sbx.files.read_bytes("/workspace/blob.bin") # returns bytes
|
|
103
|
+
|
|
104
|
+
for entry in sbx.files.list("/workspace"):
|
|
105
|
+
print(entry.name, entry.is_dir, entry.size)
|
|
106
|
+
|
|
107
|
+
sbx.files.delete("/workspace/out.txt")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Pause + resume
|
|
111
|
+
|
|
112
|
+
Pausing snapshots the sandbox memory + disk and stops the compute meter.
|
|
113
|
+
Resume restores it on the same or a different host. `pause()` returns
|
|
114
|
+
the sandbox id; persist it across processes and pass to `Sandbox.resume`.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
sbx = Sandbox.create(template="node-22", network="restricted")
|
|
118
|
+
sandbox_id = sbx.pause()
|
|
119
|
+
# ... minutes or hours later, from any worker:
|
|
120
|
+
sbx = Sandbox.resume(sandbox_id)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Snapshot retention is plan-capped (free: 1, pro: 3, team: 10). Calling
|
|
124
|
+
`pause()` over the cap raises `SnapshotCapReached`.
|
|
125
|
+
|
|
126
|
+
### Terminate
|
|
127
|
+
|
|
128
|
+
Context-manager exit calls `terminate()`. Outside `with`:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
sbx.terminate()
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
After `terminate()` the sandbox row stays in your account history but
|
|
135
|
+
the VM and any in-memory state are gone.
|
|
136
|
+
|
|
137
|
+
### List your sandboxes
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
for sbx in Sandbox.list(status="running"):
|
|
141
|
+
print(sbx.id, sbx.template, sbx.created_at)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Errors
|
|
145
|
+
|
|
146
|
+
All SDK errors inherit from `orkestr.OrkestrError`.
|
|
147
|
+
|
|
148
|
+
| Exception | When |
|
|
149
|
+
|--------------------------|---------------------------------------------------|
|
|
150
|
+
| `AuthError` | Missing / invalid / expired API key, or scope mismatch |
|
|
151
|
+
| `RateLimitError` | Plan rate limit hit |
|
|
152
|
+
| `PlanLimitError` | Sandbox limit, concurrent limit, snapshot cap |
|
|
153
|
+
| `SandboxNotFound` | `sandbox_id` doesn't exist or isn't yours |
|
|
154
|
+
| `SandboxNotReady` | Operation called on a paused / terminated sandbox |
|
|
155
|
+
| `ExecTimeout` | `exec()` exceeded `timeout_seconds` |
|
|
156
|
+
| `NetworkPolicyError` | Command tried to reach a host the policy blocks |
|
|
157
|
+
| `OrkestrError` | Any other API-level error |
|
|
158
|
+
|
|
159
|
+
## Async support
|
|
160
|
+
|
|
161
|
+
The MVP SDK is sync only. Async (`AsyncSandbox`) lands in v0.2.0 if a
|
|
162
|
+
design partner blocks on it; the wire format is identical.
|
|
163
|
+
|
|
164
|
+
## Versioning
|
|
165
|
+
|
|
166
|
+
Follows [Semantic Versioning](https://semver.org). The SDK targets the
|
|
167
|
+
`/v1/sandboxes` API; bumps to v1 of the API are non-breaking for SDK
|
|
168
|
+
callers. v2 of the API will require an SDK major.
|
|
169
|
+
|
|
170
|
+
## Links
|
|
171
|
+
|
|
172
|
+
- [Sandbox waitlist](https://orkestr.eu/sandboxes)
|
|
173
|
+
- [Public API docs](https://orkestr.eu/docs)
|
|
174
|
+
- Questions and bug reports during the private beta: <hello@orkestr.eu>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""orkestr Python SDK.
|
|
2
|
+
|
|
3
|
+
EU-hosted sandbox VMs for AI agents. See README.md or
|
|
4
|
+
https://orkestr.eu/sandboxes for the full API.
|
|
5
|
+
|
|
6
|
+
from orkestr import Sandbox
|
|
7
|
+
|
|
8
|
+
with Sandbox.create(template="python-3.12") as sbx:
|
|
9
|
+
sbx.files.write("/workspace/main.py", "print(2+2)")
|
|
10
|
+
result = sbx.exec("python /workspace/main.py")
|
|
11
|
+
print(result.stdout)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from ._client import OrkestrClient, get_default_client, set_default_client
|
|
17
|
+
from .errors import (
|
|
18
|
+
APIError,
|
|
19
|
+
AuthError,
|
|
20
|
+
ConnectionError,
|
|
21
|
+
ExecKilled,
|
|
22
|
+
ExecTimeout,
|
|
23
|
+
NetworkPolicyError,
|
|
24
|
+
OrkestrError,
|
|
25
|
+
PlanLimitError,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
SandboxNotFound,
|
|
28
|
+
SandboxNotReady,
|
|
29
|
+
SnapshotCapReached,
|
|
30
|
+
)
|
|
31
|
+
from .sandbox import ExecChunk, ExecResult, FileEntry, Sandbox
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0a1"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
# Core
|
|
37
|
+
"Sandbox",
|
|
38
|
+
"ExecResult",
|
|
39
|
+
"ExecChunk",
|
|
40
|
+
"FileEntry",
|
|
41
|
+
# Client (advanced)
|
|
42
|
+
"OrkestrClient",
|
|
43
|
+
"get_default_client",
|
|
44
|
+
"set_default_client",
|
|
45
|
+
# Errors
|
|
46
|
+
"OrkestrError",
|
|
47
|
+
"AuthError",
|
|
48
|
+
"RateLimitError",
|
|
49
|
+
"PlanLimitError",
|
|
50
|
+
"SnapshotCapReached",
|
|
51
|
+
"SandboxNotFound",
|
|
52
|
+
"SandboxNotReady",
|
|
53
|
+
"ExecTimeout",
|
|
54
|
+
"ExecKilled",
|
|
55
|
+
"NetworkPolicyError",
|
|
56
|
+
"APIError",
|
|
57
|
+
"ConnectionError",
|
|
58
|
+
"__version__",
|
|
59
|
+
]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""HTTP client for the orkestr API.
|
|
2
|
+
|
|
3
|
+
Wraps httpx.Client with bearer-token auth, error mapping, and a
|
|
4
|
+
configurable base URL. Most users go through Sandbox / Sandbox.create
|
|
5
|
+
rather than constructing this directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .errors import (
|
|
18
|
+
AuthError,
|
|
19
|
+
OrkestrError,
|
|
20
|
+
from_response,
|
|
21
|
+
)
|
|
22
|
+
from .errors import (
|
|
23
|
+
ConnectionError as OrkestrConnectionError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://api.orkestr.eu"
|
|
27
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
28
|
+
_VERSION = "0.1.0"
|
|
29
|
+
_USER_AGENT = f"orkestr-python/{_VERSION}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OrkestrClient:
|
|
33
|
+
"""Thin HTTPX wrapper with auth and error mapping.
|
|
34
|
+
|
|
35
|
+
Constructed lazily by the SDK; users usually only see it through
|
|
36
|
+
``Sandbox.create(api_key=...)``. Holding a single client and reusing
|
|
37
|
+
it lets httpx keep its connection pool warm across calls.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
api_key: str | None = None,
|
|
43
|
+
base_url: str | None = None,
|
|
44
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
45
|
+
) -> None:
|
|
46
|
+
resolved_key = api_key or os.environ.get("ORKESTR_API_KEY")
|
|
47
|
+
if not resolved_key:
|
|
48
|
+
raise AuthError(
|
|
49
|
+
"ORKESTR_API_KEY is not set. Pass api_key=... or export "
|
|
50
|
+
"ORKESTR_API_KEY before calling the SDK."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
resolved_base = (
|
|
54
|
+
base_url
|
|
55
|
+
or os.environ.get("ORKESTR_BASE_URL")
|
|
56
|
+
or DEFAULT_BASE_URL
|
|
57
|
+
).rstrip("/")
|
|
58
|
+
|
|
59
|
+
self._api_key = resolved_key
|
|
60
|
+
self._base_url = resolved_base
|
|
61
|
+
self._http = httpx.Client(
|
|
62
|
+
base_url=resolved_base,
|
|
63
|
+
timeout=timeout,
|
|
64
|
+
headers={
|
|
65
|
+
"Authorization": f"Bearer {resolved_key}",
|
|
66
|
+
"User-Agent": _USER_AGENT,
|
|
67
|
+
"Accept": "application/json",
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
# Lifecycle
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
self._http.close()
|
|
77
|
+
|
|
78
|
+
def __enter__(self) -> OrkestrClient:
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def __exit__(self, *args: object) -> None:
|
|
82
|
+
self.close()
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# Request helpers
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def request(
|
|
89
|
+
self,
|
|
90
|
+
method: str,
|
|
91
|
+
path: str,
|
|
92
|
+
*,
|
|
93
|
+
json_body: dict[str, Any] | None = None,
|
|
94
|
+
params: dict[str, Any] | None = None,
|
|
95
|
+
) -> dict[str, Any] | None:
|
|
96
|
+
"""Issue a request and return the parsed JSON body (or None for 204).
|
|
97
|
+
|
|
98
|
+
Non-2xx responses are mapped to the right SDK exception via
|
|
99
|
+
errors.from_response. Transport-level failures (DNS, connection
|
|
100
|
+
refused, timeout before bytes arrive) become OrkestrConnectionError.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
response = self._http.request(
|
|
104
|
+
method, path, json=json_body, params=params
|
|
105
|
+
)
|
|
106
|
+
except httpx.TimeoutException as exc:
|
|
107
|
+
raise OrkestrConnectionError(
|
|
108
|
+
f"Timeout connecting to {self._base_url}{path}"
|
|
109
|
+
) from exc
|
|
110
|
+
except httpx.TransportError as exc:
|
|
111
|
+
raise OrkestrConnectionError(
|
|
112
|
+
f"Transport error reaching {self._base_url}: {exc}"
|
|
113
|
+
) from exc
|
|
114
|
+
|
|
115
|
+
return self._handle_response(response)
|
|
116
|
+
|
|
117
|
+
def get(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
|
118
|
+
return self.request("GET", path, params=params)
|
|
119
|
+
|
|
120
|
+
def post(
|
|
121
|
+
self,
|
|
122
|
+
path: str,
|
|
123
|
+
*,
|
|
124
|
+
json_body: dict[str, Any] | None = None,
|
|
125
|
+
params: dict[str, Any] | None = None,
|
|
126
|
+
) -> dict[str, Any] | None:
|
|
127
|
+
return self.request("POST", path, json_body=json_body, params=params)
|
|
128
|
+
|
|
129
|
+
def delete(
|
|
130
|
+
self,
|
|
131
|
+
path: str,
|
|
132
|
+
*,
|
|
133
|
+
params: dict[str, Any] | None = None,
|
|
134
|
+
) -> dict[str, Any] | None:
|
|
135
|
+
return self.request("DELETE", path, params=params)
|
|
136
|
+
|
|
137
|
+
def stream(
|
|
138
|
+
self,
|
|
139
|
+
method: str,
|
|
140
|
+
path: str,
|
|
141
|
+
*,
|
|
142
|
+
json_body: dict[str, Any] | None = None,
|
|
143
|
+
) -> Iterator[str]:
|
|
144
|
+
"""Open a streaming response and yield raw decoded text lines.
|
|
145
|
+
|
|
146
|
+
Used by the streaming-exec path to parse SSE. Caller is responsible
|
|
147
|
+
for closing iteration; the underlying response is closed when the
|
|
148
|
+
generator exits.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
with self._http.stream(
|
|
152
|
+
method, path, json=json_body
|
|
153
|
+
) as response:
|
|
154
|
+
if response.status_code >= 400:
|
|
155
|
+
# Drain the body so we can map the error. Streaming
|
|
156
|
+
# responses with an error usually carry a small JSON
|
|
157
|
+
# body, so the cost is negligible.
|
|
158
|
+
body_text = response.read().decode(errors="replace")
|
|
159
|
+
self._raise_for_status(response, body_text=body_text)
|
|
160
|
+
yield from response.iter_lines()
|
|
161
|
+
except httpx.TimeoutException as exc:
|
|
162
|
+
raise OrkestrConnectionError(
|
|
163
|
+
f"Timeout streaming from {self._base_url}{path}"
|
|
164
|
+
) from exc
|
|
165
|
+
except httpx.TransportError as exc:
|
|
166
|
+
raise OrkestrConnectionError(
|
|
167
|
+
f"Transport error reaching {self._base_url}: {exc}"
|
|
168
|
+
) from exc
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Internals
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def _handle_response(self, response: httpx.Response) -> dict[str, Any] | None:
|
|
175
|
+
if response.status_code == 204:
|
|
176
|
+
return None
|
|
177
|
+
if 200 <= response.status_code < 300:
|
|
178
|
+
if not response.content:
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
return response.json()
|
|
182
|
+
except json.JSONDecodeError as exc:
|
|
183
|
+
raise OrkestrError(
|
|
184
|
+
f"Non-JSON 2xx response from {response.url}: "
|
|
185
|
+
f"{response.text[:200]}",
|
|
186
|
+
status_code=response.status_code,
|
|
187
|
+
) from exc
|
|
188
|
+
|
|
189
|
+
self._raise_for_status(response)
|
|
190
|
+
return None # unreachable
|
|
191
|
+
|
|
192
|
+
def _raise_for_status(
|
|
193
|
+
self,
|
|
194
|
+
response: httpx.Response,
|
|
195
|
+
*,
|
|
196
|
+
body_text: str | None = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
request_id = response.headers.get("x-request-id")
|
|
199
|
+
text = body_text if body_text is not None else response.text
|
|
200
|
+
body: dict[str, Any] | None
|
|
201
|
+
try:
|
|
202
|
+
body = json.loads(text) if text else None
|
|
203
|
+
except json.JSONDecodeError:
|
|
204
|
+
body = None
|
|
205
|
+
raise from_response(response.status_code, body, request_id=request_id)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# Default client management
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
_default_client: OrkestrClient | None = None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def get_default_client() -> OrkestrClient:
|
|
216
|
+
"""Lazily create the module-level client used by Sandbox classmethods.
|
|
217
|
+
|
|
218
|
+
Constructed on first use from ``ORKESTR_API_KEY`` and ``ORKESTR_BASE_URL``.
|
|
219
|
+
Tests can override via ``set_default_client``.
|
|
220
|
+
"""
|
|
221
|
+
global _default_client
|
|
222
|
+
if _default_client is None:
|
|
223
|
+
_default_client = OrkestrClient()
|
|
224
|
+
return _default_client
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def set_default_client(client: OrkestrClient | None) -> None:
|
|
228
|
+
"""Override (or clear) the module-level client. Test seam."""
|
|
229
|
+
global _default_client
|
|
230
|
+
_default_client = client
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
__all__ = [
|
|
234
|
+
"OrkestrClient",
|
|
235
|
+
"DEFAULT_BASE_URL",
|
|
236
|
+
"DEFAULT_TIMEOUT_SECONDS",
|
|
237
|
+
"get_default_client",
|
|
238
|
+
"set_default_client",
|
|
239
|
+
]
|