solari-e2b-compat 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.
- solari_e2b_compat-0.1.0/.gitignore +71 -0
- solari_e2b_compat-0.1.0/COMPAT.md +98 -0
- solari_e2b_compat-0.1.0/LICENSE-E2B +9 -0
- solari_e2b_compat-0.1.0/NOTICE +13 -0
- solari_e2b_compat-0.1.0/PKG-INFO +89 -0
- solari_e2b_compat-0.1.0/README.md +71 -0
- solari_e2b_compat-0.1.0/pyproject.toml +32 -0
- solari_e2b_compat-0.1.0/solari_e2b/__init__.py +39 -0
- solari_e2b_compat-0.1.0/solari_e2b/_box.py +155 -0
- solari_e2b_compat-0.1.0/solari_e2b/_commands.py +307 -0
- solari_e2b_compat-0.1.0/solari_e2b/_config.py +128 -0
- solari_e2b_compat-0.1.0/solari_e2b/_filesystem.py +226 -0
- solari_e2b_compat-0.1.0/solari_e2b/_loop.py +49 -0
- solari_e2b_compat-0.1.0/solari_e2b/_sandbox.py +448 -0
- solari_e2b_compat-0.1.0/solari_e2b/_sync.py +160 -0
- solari_e2b_compat-0.1.0/solari_e2b/_template.py +445 -0
- solari_e2b_compat-0.1.0/solari_e2b/code_interpreter.py +374 -0
- solari_e2b_compat-0.1.0/solari_e2b/exceptions.py +168 -0
- solari_e2b_compat-0.1.0/solari_e2b/models.py +128 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# build artifacts
|
|
2
|
+
dist/
|
|
3
|
+
**/dist/
|
|
4
|
+
**/node_modules/
|
|
5
|
+
**/target/
|
|
6
|
+
*.log
|
|
7
|
+
|
|
8
|
+
# VM images / snapshots / kernels — large binaries, never committed
|
|
9
|
+
*.img
|
|
10
|
+
*.ext4
|
|
11
|
+
*.qcow2
|
|
12
|
+
*.raw
|
|
13
|
+
*.snapshot
|
|
14
|
+
/snapshots/build/
|
|
15
|
+
/guest/rootfs/build/
|
|
16
|
+
/guest/kernel/build/
|
|
17
|
+
vmlinux
|
|
18
|
+
bzImage
|
|
19
|
+
|
|
20
|
+
# terraform state
|
|
21
|
+
*.tfstate
|
|
22
|
+
*.tfstate.*
|
|
23
|
+
.terraform/
|
|
24
|
+
*.tfvars.local
|
|
25
|
+
|
|
26
|
+
# secrets / env
|
|
27
|
+
.env
|
|
28
|
+
.env.*
|
|
29
|
+
*.pem
|
|
30
|
+
|
|
31
|
+
# Python
|
|
32
|
+
__pycache__/
|
|
33
|
+
*.pyc
|
|
34
|
+
.venv/
|
|
35
|
+
|
|
36
|
+
# NOTE: tests/sdk/ts-request-fixtures.json is deliberately NOT ignored.
|
|
37
|
+
#
|
|
38
|
+
# It was, and that quietly disabled two gates at once:
|
|
39
|
+
#
|
|
40
|
+
# 1. ci.yml's "Committed fixture must match what the SDK emits today" step runs
|
|
41
|
+
# `git diff --exit-code tests/sdk/ts-request-fixtures.json`. On an ignored,
|
|
42
|
+
# never-tracked file that command can only ever succeed — it had never once
|
|
43
|
+
# been able to fail.
|
|
44
|
+
# 2. The Go/Rust/C++/Python contract suites now assert against that fixture from
|
|
45
|
+
# inside their OWN CI jobs, none of which run the TypeScript half. If the file
|
|
46
|
+
# is not committed, it is simply absent there.
|
|
47
|
+
#
|
|
48
|
+
# It is generated, but it is generated from the REFERENCE SDK and checked in as
|
|
49
|
+
# the baseline the other four bindings are held to — regenerating it and diffing
|
|
50
|
+
# is exactly how a wire change is made to announce itself.
|
|
51
|
+
|
|
52
|
+
# Built guest-agent binaries (produced by go build / build-rootfs staging)
|
|
53
|
+
guest/agent/agent
|
|
54
|
+
guest/rootfs/guest-agent
|
|
55
|
+
|
|
56
|
+
# build-rootfs.sh template staging + generated Dockerfile (regenerated each run)
|
|
57
|
+
guest/rootfs/guest-agent.service
|
|
58
|
+
guest/rootfs/template.packages
|
|
59
|
+
guest/rootfs/template-files/
|
|
60
|
+
guest/rootfs/.Dockerfile.gen
|
|
61
|
+
guest/rootfs/out/
|
|
62
|
+
|
|
63
|
+
# local test artifacts
|
|
64
|
+
staging-desktop.png
|
|
65
|
+
desktop.png
|
|
66
|
+
infra/terraform/snapshots-volumes/.build/
|
|
67
|
+
|
|
68
|
+
# deploy-e2e scratch state: holds a presigned REPO_URL with live STS creds
|
|
69
|
+
# (ASIA... + X-Amz-Security-Token). Never commit. Also stale PHASE_* here makes
|
|
70
|
+
# a bare re-run skip bake+verify and exit 0 with zero assertions.
|
|
71
|
+
infra/.deploy-e2e/
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# COMPAT.md: E2B Python SDK compatibility (target: e2b 2.59 and e2b-code-interpreter 2.11)
|
|
2
|
+
|
|
3
|
+
Legend: **OK** works and is tested; **DIFF** works with a behaviour difference (explained); **NO** raises
|
|
4
|
+
`SolariCompatUnsupported` naming the feature. "Live" means `tests/test_live_staging.py` runs it against staging.
|
|
5
|
+
The matrix is the same as the TypeScript package's (`sdk/packages/e2b-compat/COMPAT.md`); this file lists the Python names
|
|
6
|
+
and the differences that are specific to Python.
|
|
7
|
+
|
|
8
|
+
## At a glance
|
|
9
|
+
- **Works**: sandbox create / connect / list / info / kill / timeout / pause / resume / metrics, snapshots and fork,
|
|
10
|
+
commands (foreground, background, stdin, list, kill), PTY, the filesystem API (read as text, bytes or stream; write one or
|
|
11
|
+
many; list with depth; info; exists; make_dir; rename; remove; watch_dir; signed upload and download URLs), the code
|
|
12
|
+
interpreter (`run_code` with state, callbacks, errors, bash, contexts, rich results), the template builder and
|
|
13
|
+
`Template.build`. Blocking (`Sandbox`) and async (`AsyncSandbox`) flavours.
|
|
14
|
+
- **Not available** (raises): egress network rules, MCP gateway, IAM tokens, E2B Volumes and Secrets APIs, template tags,
|
|
15
|
+
`set_start_cmd` / `set_ready_cmd`, `set_user`, Dockerfile input, private registries, start-time and template filters on
|
|
16
|
+
`Sandbox.list`, `on_resume='reboot'`, JavaScript/TypeScript/R/Java in `run_code`.
|
|
17
|
+
|
|
18
|
+
## Setup and configuration
|
|
19
|
+
| E2B | Here |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `E2B_API_KEY`, `api_key=` | Only `slr_…` keys are used; see README. |
|
|
22
|
+
| `E2B_DOMAIN`, `E2B_API_URL`, `domain=`, `api_url=` | Honoured only when they point at Solari or loopback. |
|
|
23
|
+
| `headers=`, `api_headers=`, `proxy=` | NO. |
|
|
24
|
+
| `request_timeout=`, `debug=`, `logger=`, `validate_api_key=`, `retries=`, `http_version=`, `sandbox_url=` | Accepted, ignored (Solari's transport has its own retry policy). Every option that is accepted but ignored is marked "ignored" in this file; any other option either works or throws. |
|
|
25
|
+
| `timeout=` arguments | Seconds, as in E2B. |
|
|
26
|
+
| `E2B` client object, `Volume`, `Secret` | NO. `solari_e2b.Volume` / `Secret` raise on any call. |
|
|
27
|
+
| Context manager | `with Sandbox.create() as s:` / `async with await AsyncSandbox.create() as s:` kill the sandbox on exit. |
|
|
28
|
+
|
|
29
|
+
## Sandbox
|
|
30
|
+
| Call | Status | Notes |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `Sandbox.create(template, timeout, metadata, envs, lifecycle, volume_mounts, ...)` | OK, live | `template`: `base`, a name from `Template.build`, or a `tpl_…` id. Default `base` (`code-interpreter-v1` in `solari_e2b.code_interpreter`). |
|
|
33
|
+
| `timeout` | DIFF | E2B's is a lifetime; Solari's is a rolling idle window that resets on use. Default 300 s as in E2B. The per-plan deadline still bounds total life. |
|
|
34
|
+
| `lifecycle={'on_timeout': ..., 'auto_resume': ...}` | OK | `{'action': 'pause', 'mode': 'filesystem'}` pauses with RAM too (a superset). |
|
|
35
|
+
| `volume_mounts={path: 'vol_…' or name}` | DIFF | Solari persistent volumes by id or name. `Volume` objects: NO. |
|
|
36
|
+
| `secure`, `sandbox_url` | Accepted, ignored (sandboxes are always access-controlled). |
|
|
37
|
+
| `allow_internet_access=False`, `network`, `iam`, `mcp` | NO. |
|
|
38
|
+
| `Sandbox.connect(id, timeout)`, `sbx.connect()` | OK, live | Resumes a paused or stopped sandbox. `on_resume='reboot'`: NO. |
|
|
39
|
+
| `Sandbox.list(query=SandboxQuery(...), limit, next_token, order)` | DIFF | Paginator with `has_next`, `next_token`, `next_items()`. Filters: `metadata`, `state`. `started_after` and `template`: NO. `template_id` in each `SandboxInfo` is empty. Solari `starting` counts as running, `stopped` as paused. |
|
|
40
|
+
| `get_info`, `is_running`, `kill`, `set_timeout` | OK, live | Usable on the class with an id (`Sandbox.kill(id)`) or on an instance. `kill` returns `False` for a sandbox that is already gone. |
|
|
41
|
+
| `get_metrics()` | DIFF | One current sample (no history); `mem_cache` is 0. |
|
|
42
|
+
| `pause()` | OK, live | Keeps RAM and disk. `mode='filesystem'`, `keep_memory=False`: treated as a full pause. |
|
|
43
|
+
| `create_snapshot(name=)`, `list_snapshots(...)`, `Sandbox.delete_snapshot(id)` | OK, live | Snapshots are RAM + disk. `mode='filesystem'` is ignored. A name lookup reads at most 1000 snapshots. `sbx.list_snapshots()` lists snapshots taken of that sandbox. Class-level listing over every sandbox is `Sandbox.list_all_snapshots(name=)` (extension). |
|
|
44
|
+
| `fork(count=)` | DIFF, live | Checkpoint then `count` new sandboxes from it; the checkpoint is deleted afterwards (waiting at most 20 s for the gateway to finish the delete). Returns a sandbox or an exception per copy. |
|
|
45
|
+
| `get_host(port)` | NO | E2B returns the host synchronously; Solari mints preview URLs on the gateway. Use the extension `sbx.get_preview_url(port)` (returns `{'url': ..., 'token': ...}`). |
|
|
46
|
+
| `upload_url`, `download_url` | OK | `user=`: NO. |
|
|
47
|
+
| `update_network`, `get_mcp_url`, `get_mcp_token` (returns `None`), `git` | NO | `git` is deprecated in E2B; use `commands.run('git …')`. |
|
|
48
|
+
| Lifecycle events API, webhooks, OTel export, sandbox logs API | NO | |
|
|
49
|
+
|
|
50
|
+
## Commands and PTY
|
|
51
|
+
| Call | Status | Notes |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `commands.run(cmd, ...)` | OK, live | Runs `bash -lc` (a POSIX `sh` on images without bash) from the home directory. A non-zero exit raises `CommandExitException` (`stdout`, `stderr`, `exit_code`, `error`). `timeout` default 60 s (0 disables) raises `TimeoutException`. `on_stdout` / `on_stderr` (sync or async callables), `envs`, `cwd`, `user`: OK. |
|
|
54
|
+
| `commands.run(cmd, background=True)` | OK, live | Returns a command handle: `pid`, `stdout`, `stderr`, `exit_code`, `wait()`, `kill()`, `send_stdin()`, `disconnect()`. It is not iterable (E2B's handles are). |
|
|
55
|
+
| `commands.list`, `kill(pid)`, `send_stdin(pid)`, `connect(pid)`, `stream_input(pid)` | DIFF | `pid` is a number this package hands out; Solari keeps the process on the control channel that started it. These work for commands started by **this process**; another process gets `NotFoundException`. |
|
|
56
|
+
| `close_stdin` | NO | Solari cannot close a command's stdin. End the program another way (for example a sentinel line). |
|
|
57
|
+
| `pty.create(PtySize(rows, cols), on_data)`, `send_stdin`, `resize`, `kill`, `stream_input` | OK, live | `connect(pid)`: this process only. `handle.wait()` resolves when `kill` is called; a shell that exits by itself is not observed. `user=`: NO. |
|
|
58
|
+
|
|
59
|
+
## Filesystem
|
|
60
|
+
| Call | Status | Notes |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `read(path, format='text' \| 'bytes' \| 'stream')` | OK, live | Relative paths resolve against the guest's `$HOME`. `bytes` returns a `bytearray`. `stream` returns an async-iterable reader with one chunk (async API only; with the blocking API read with `format='bytes'`). A missing file raises `FileNotFoundException`. |
|
|
63
|
+
| `write(path, data)`, `write_files(entries)` | OK, live | `str`, `bytes` or a file-like object. Parent directories are created. `metadata=`: NO. `gzip`, `use_octet_stream`: accepted, ignored. |
|
|
64
|
+
| `list(path, depth)`, `get_info` | DIFF, live | `owner` and `group` are empty; `symlink_target` is not reported (symlinks are followed: a link lists as its target file). |
|
|
65
|
+
| `exists`, `make_dir`, `rename`, `remove` | OK, live | |
|
|
66
|
+
| `watch_dir(path, on_event, recursive=)` | DIFF, live | `FilesystemEvent.name` is relative to the watched path. Solari reports creates as `write`. `include_entry`: not offered. A watch ends after `timeout` seconds (default 60; 0 disables) and calls `on_exit(None)`. |
|
|
67
|
+
| `user=` on any filesystem call | NO | |
|
|
68
|
+
|
|
69
|
+
## Code interpreter (`solari_e2b.code_interpreter`)
|
|
70
|
+
| Call | Status | Notes |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `run_code(code, language=None, context=None, ...)` | OK, live | Python (and `bash`). Passing both `language` and `context` raises `InvalidArgumentException`. Python state persists across calls in the default context. |
|
|
73
|
+
| Other languages | NO | The guest ships a Python kernel only: `javascript`, `typescript`, `r`, `java` raise. `bash` runs each call as a fresh shell (no state between calls). |
|
|
74
|
+
| `on_stdout`, `on_stderr`, `on_result`, `on_error` | DIFF | Called after the run finishes, not while it runs (Solari returns the result in one reply). Sync or async callables. |
|
|
75
|
+
| `Execution` (`results`, `logs`, `error`, `text`) | DIFF | `execution_count` is `None`. `error` is `ExecutionError(name, value, traceback)`. The last result with a text repr is `is_main_result`. |
|
|
76
|
+
| `Result` formats | DIFF | `text`, `html`, `markdown`, `svg`, `png`, `jpeg`, `latex`, `json` (as given by the kernel), `chart`. `pdf`, `javascript`, `data` and `extra` are not produced. `chart` is a generic `Chart(type, title, elements, ...)` (not E2B's per-type classes) and appears when the guest kernel extracts a structured chart (not for every figure). |
|
|
77
|
+
| `envs=` per run | NO | |
|
|
78
|
+
| `timeout=` | DIFF | Stops waiting and raises `TimeoutException`; the kernel keeps running the code. No default timeout. |
|
|
79
|
+
| `create_code_context(cwd, language)` | OK, live | Python only. |
|
|
80
|
+
| `list_code_contexts`, `remove_code_context`, `restart_code_context` | DIFF, live | Solari cannot list or destroy kernels. `list` returns contexts created by this process; `remove` forgets the context (the kernel stays until the sandbox ends); `restart` clears the context's variables and working directory without restarting the process. |
|
|
81
|
+
| Default template | DIFF | `code-interpreter-v1` is built into your org on first `Sandbox.create()` without a template: about 80 seconds, billed as a template build, and two processes that both find it missing each start a build. `build_code_interpreter_template()` (async) / `build_code_interpreter_template_sync()` prebuild it. |
|
|
82
|
+
|
|
83
|
+
## Templates
|
|
84
|
+
| Call | Status | Notes |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `Template().from_base_image / from_image / from_python_image / from_node_image / from_ubuntu_image / from_debian_image / from_alpine_image / from_fedora_image / from_arch_image / from_bun_image / from_template` | OK, live | `from_base_image()` is Solari's base image. The others are public registry images. Credentials, `from_aws_registry`, `from_gcp_registry`, `from_dockerfile`: NO. |
|
|
87
|
+
| `apt_install`, `pip_install`, `npm_install`, `bun_install`, `run_cmd`, `copy`, `copy_items`, `make_dir`, `remove`, `rename`, `make_symlink`, `git_clone`, `set_workdir`, `set_envs` | OK, live | Compiled onto Solari's image steps. `set_envs` persists in the template (E2B: build time only). `user=` other than `root`: NO. `copy` reads from `file_context_path` (default: the current directory). Quoting uses `shlex`, so simple words are not quoted. |
|
|
88
|
+
| `set_user`, `set_start_cmd`, `set_ready_cmd`, `add_mcp_server`, dev containers | NO | Solari templates run no start command. Start processes after `Sandbox.create` with `commands.run(..., background=True)`. |
|
|
89
|
+
| `skip_cache()` | Accepted, ignored. |
|
|
90
|
+
| `Template.build(template, name)`, `build_in_background`, `get_build_status`, `exists`, `alias_exists` | OK, live | `name:latest` is accepted; any other tag, and `tags=`: NO. `on_build_logs` receives start and end entries. `Template.build` blocks (the `AsyncTemplate` twin is awaitable). |
|
|
91
|
+
| `Template.assign_tags`, `remove_tags`, `get_tags`, `to_json`, `to_dockerfile` | NO | |
|
|
92
|
+
|
|
93
|
+
## Python-specific differences
|
|
94
|
+
- The Python SDK's `GET /sandboxes/:id` view drops the creation time and the native-image marker, so this package reads that
|
|
95
|
+
route directly. `SandboxInfo.started_at` is the creation time when the gateway reports it, else the epoch.
|
|
96
|
+
- The blocking API runs the async client on one daemon thread. Calling it from one of your own callbacks (which run on that
|
|
97
|
+
thread) raises `RuntimeError`; use `AsyncSandbox` there.
|
|
98
|
+
- Tested on Python 3.14; the code avoids syntax newer than 3.9 but only 3.14 is exercised here.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 FOUNDRYLABS, INC.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
solari-e2b-compat
|
|
2
|
+
|
|
3
|
+
The public API shapes (class, method, option and exception names, and the result types) follow the `e2b` and
|
|
4
|
+
`e2b-code-interpreter` Python SDKs (e2b 2.59, e2b-code-interpreter 2.11), which are distributed under the MIT
|
|
5
|
+
licence:
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2025 FOUNDRYLABS, INC.
|
|
8
|
+
|
|
9
|
+
The licence text is reproduced in LICENSE-E2B. No E2B source code is bundled; the implementation is written against the
|
|
10
|
+
Solari SDKs (solari-core, solari-sandbox).
|
|
11
|
+
|
|
12
|
+
This package is not affiliated with, endorsed by, or sponsored by E2B or FOUNDRYLABS, INC. "E2B" is the name of their
|
|
13
|
+
product; it appears here only to describe API compatibility.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: solari-e2b-compat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: E2B-compatible Python interface (sandboxes and code interpreter) over Solari Sandboxes. Not affiliated with E2B.
|
|
5
|
+
Project-URL: Homepage, https://getsolari.com
|
|
6
|
+
Author: Solari
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE-E2B
|
|
9
|
+
License-File: NOTICE
|
|
10
|
+
Keywords: code-interpreter,compatibility,e2b,sandbox,solari
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Requires-Dist: solari-core>=0.4.0
|
|
13
|
+
Requires-Dist: solari-sandbox>=0.3.0
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
16
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# solari-e2b-compat
|
|
20
|
+
|
|
21
|
+
An E2B-compatible Python API on top of [Solari Sandbox](https://getsolari.com). Change the import, keep your sandbox code.
|
|
22
|
+
|
|
23
|
+
> **Not affiliated with, endorsed by, or sponsored by E2B.** "E2B" is E2B's product name; it appears here only to
|
|
24
|
+
> describe API compatibility. The API shapes follow the MIT-licensed `e2b` and `e2b-code-interpreter` SDKs, see
|
|
25
|
+
> [NOTICE](./NOTICE). Do not look for this package under `e2b*`.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
# before: from e2b_code_interpreter import Sandbox
|
|
29
|
+
from solari_e2b.code_interpreter import Sandbox
|
|
30
|
+
|
|
31
|
+
sbx = Sandbox.create() # SOLARI_API_KEY in the environment
|
|
32
|
+
execution = sbx.run_code("x = 1\nx + 1")
|
|
33
|
+
print(execution.text) # "2"
|
|
34
|
+
print(sbx.commands.run("echo hello").stdout)
|
|
35
|
+
sbx.files.write("/tmp/a.txt", "hi")
|
|
36
|
+
sbx.kill()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The plain `e2b` package maps to `solari_e2b`:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# before: from e2b import Sandbox, AsyncSandbox, Template
|
|
43
|
+
from solari_e2b import Sandbox, AsyncSandbox, Template
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Exports: `Sandbox` (blocking) and `AsyncSandbox` (create, connect, list, kill, pause, snapshots, fork, `commands`, `files`,
|
|
47
|
+
`pty`), `Template` / `AsyncTemplate` and the template builder, the E2B exception classes (`SandboxException`,
|
|
48
|
+
`TimeoutException`, `NotFoundException`, `FileNotFoundException`, `CommandExitException`, `AuthenticationException`, ...),
|
|
49
|
+
`SolariCompatUnsupported`, and from `solari_e2b.code_interpreter`: `Sandbox` / `AsyncSandbox` with `run_code` and code
|
|
50
|
+
contexts, `Execution`, `Result`, `Context`.
|
|
51
|
+
|
|
52
|
+
The blocking API runs the async client on one background thread; callbacks you pass (`on_stdout`, ...) run on that thread.
|
|
53
|
+
Do not call the blocking API from inside such a callback; use `AsyncSandbox` there.
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
| | Order |
|
|
58
|
+
|---|---|
|
|
59
|
+
| API key | `api_key=` option, `SOLARI_API_KEY`, `E2B_API_KEY`: only an `slr_…` Solari key is ever used. A non-`slr_` value (for example a real E2B key) is skipped with a warning (the key is never printed or sent); with no Solari key the call raises |
|
|
60
|
+
| Base URL | `api_url=` / `domain=` option (Solari or loopback only, anything else raises), `SOLARI_BASE_URL`, `E2B_API_URL` / `E2B_DOMAIN` (only if they point at getsolari.com or loopback; otherwise ignored with a warning), default `https://api.getsolari.com` |
|
|
61
|
+
|
|
62
|
+
An E2B key does not work here: use a Solari API key.
|
|
63
|
+
|
|
64
|
+
## The code-interpreter template
|
|
65
|
+
|
|
66
|
+
`e2b_code_interpreter` runs in a template with a data-analysis stack (pandas, numpy, scipy, scikit-learn, seaborn,
|
|
67
|
+
plotly, ...). Solari builds the equivalent, `code-interpreter-v1`, into your org the first time `Sandbox.create()` is
|
|
68
|
+
called without a template (about 80 seconds, once). To do it ahead of time:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from solari_e2b.code_interpreter import build_code_interpreter_template_sync
|
|
72
|
+
build_code_interpreter_template_sync() # or: await build_code_interpreter_template()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`SOLARI_CODE_INTERPRETER_TEMPLATE` overrides the template (a name or `tpl_…` id). The package list is
|
|
76
|
+
`CODE_INTERPRETER_PACKAGES`; the very large audio, NLP and computer-vision wheels E2B ships (spacy, librosa, opencv,
|
|
77
|
+
numba, gensim) are not included.
|
|
78
|
+
|
|
79
|
+
## What works, what does not
|
|
80
|
+
|
|
81
|
+
[COMPAT.md](./COMPAT.md) is the support matrix. Anything Solari cannot do raises `SolariCompatUnsupported`
|
|
82
|
+
(a `SandboxException`) naming the feature; nothing is silently ignored. The sibling TypeScript package is
|
|
83
|
+
`sdk/packages/e2b-compat` (`@solarisdk/e2b-compat`) and follows the same rules.
|
|
84
|
+
|
|
85
|
+
## Tests
|
|
86
|
+
|
|
87
|
+
- `python -m pytest`: offline unit tests, a fake gateway.
|
|
88
|
+
- `SOLARI_API_KEY=… SOLARI_BASE_URL=https://api-sta.getsolari.com python -m pytest -m live tests/test_live_staging.py`:
|
|
89
|
+
live tests against staging. They create their own sandboxes, snapshots and templates and delete only those.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# solari-e2b-compat
|
|
2
|
+
|
|
3
|
+
An E2B-compatible Python API on top of [Solari Sandbox](https://getsolari.com). Change the import, keep your sandbox code.
|
|
4
|
+
|
|
5
|
+
> **Not affiliated with, endorsed by, or sponsored by E2B.** "E2B" is E2B's product name; it appears here only to
|
|
6
|
+
> describe API compatibility. The API shapes follow the MIT-licensed `e2b` and `e2b-code-interpreter` SDKs, see
|
|
7
|
+
> [NOTICE](./NOTICE). Do not look for this package under `e2b*`.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
# before: from e2b_code_interpreter import Sandbox
|
|
11
|
+
from solari_e2b.code_interpreter import Sandbox
|
|
12
|
+
|
|
13
|
+
sbx = Sandbox.create() # SOLARI_API_KEY in the environment
|
|
14
|
+
execution = sbx.run_code("x = 1\nx + 1")
|
|
15
|
+
print(execution.text) # "2"
|
|
16
|
+
print(sbx.commands.run("echo hello").stdout)
|
|
17
|
+
sbx.files.write("/tmp/a.txt", "hi")
|
|
18
|
+
sbx.kill()
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The plain `e2b` package maps to `solari_e2b`:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
# before: from e2b import Sandbox, AsyncSandbox, Template
|
|
25
|
+
from solari_e2b import Sandbox, AsyncSandbox, Template
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Exports: `Sandbox` (blocking) and `AsyncSandbox` (create, connect, list, kill, pause, snapshots, fork, `commands`, `files`,
|
|
29
|
+
`pty`), `Template` / `AsyncTemplate` and the template builder, the E2B exception classes (`SandboxException`,
|
|
30
|
+
`TimeoutException`, `NotFoundException`, `FileNotFoundException`, `CommandExitException`, `AuthenticationException`, ...),
|
|
31
|
+
`SolariCompatUnsupported`, and from `solari_e2b.code_interpreter`: `Sandbox` / `AsyncSandbox` with `run_code` and code
|
|
32
|
+
contexts, `Execution`, `Result`, `Context`.
|
|
33
|
+
|
|
34
|
+
The blocking API runs the async client on one background thread; callbacks you pass (`on_stdout`, ...) run on that thread.
|
|
35
|
+
Do not call the blocking API from inside such a callback; use `AsyncSandbox` there.
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
| | Order |
|
|
40
|
+
|---|---|
|
|
41
|
+
| API key | `api_key=` option, `SOLARI_API_KEY`, `E2B_API_KEY`: only an `slr_…` Solari key is ever used. A non-`slr_` value (for example a real E2B key) is skipped with a warning (the key is never printed or sent); with no Solari key the call raises |
|
|
42
|
+
| Base URL | `api_url=` / `domain=` option (Solari or loopback only, anything else raises), `SOLARI_BASE_URL`, `E2B_API_URL` / `E2B_DOMAIN` (only if they point at getsolari.com or loopback; otherwise ignored with a warning), default `https://api.getsolari.com` |
|
|
43
|
+
|
|
44
|
+
An E2B key does not work here: use a Solari API key.
|
|
45
|
+
|
|
46
|
+
## The code-interpreter template
|
|
47
|
+
|
|
48
|
+
`e2b_code_interpreter` runs in a template with a data-analysis stack (pandas, numpy, scipy, scikit-learn, seaborn,
|
|
49
|
+
plotly, ...). Solari builds the equivalent, `code-interpreter-v1`, into your org the first time `Sandbox.create()` is
|
|
50
|
+
called without a template (about 80 seconds, once). To do it ahead of time:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from solari_e2b.code_interpreter import build_code_interpreter_template_sync
|
|
54
|
+
build_code_interpreter_template_sync() # or: await build_code_interpreter_template()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`SOLARI_CODE_INTERPRETER_TEMPLATE` overrides the template (a name or `tpl_…` id). The package list is
|
|
58
|
+
`CODE_INTERPRETER_PACKAGES`; the very large audio, NLP and computer-vision wheels E2B ships (spacy, librosa, opencv,
|
|
59
|
+
numba, gensim) are not included.
|
|
60
|
+
|
|
61
|
+
## What works, what does not
|
|
62
|
+
|
|
63
|
+
[COMPAT.md](./COMPAT.md) is the support matrix. Anything Solari cannot do raises `SolariCompatUnsupported`
|
|
64
|
+
(a `SandboxException`) naming the feature; nothing is silently ignored. The sibling TypeScript package is
|
|
65
|
+
`sdk/packages/e2b-compat` (`@solarisdk/e2b-compat`) and follows the same rules.
|
|
66
|
+
|
|
67
|
+
## Tests
|
|
68
|
+
|
|
69
|
+
- `python -m pytest`: offline unit tests, a fake gateway.
|
|
70
|
+
- `SOLARI_API_KEY=… SOLARI_BASE_URL=https://api-sta.getsolari.com python -m pytest -m live tests/test_live_staging.py`:
|
|
71
|
+
live tests against staging. They create their own sandboxes, snapshots and templates and delete only those.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "solari-e2b-compat"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "E2B-compatible Python interface (sandboxes and code interpreter) over Solari Sandboxes. Not affiliated with E2B."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Solari" }]
|
|
13
|
+
keywords = ["solari", "sandbox", "e2b", "code-interpreter", "compatibility"]
|
|
14
|
+
dependencies = ["solari-sandbox>=0.3.0", "solari-core>=0.4.0"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
test = ["pytest>=7", "pytest-asyncio>=0.23"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://getsolari.com"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["solari_e2b"]
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.sdist]
|
|
26
|
+
include = ["solari_e2b", "README.md", "COMPAT.md", "NOTICE", "LICENSE-E2B"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
asyncio_mode = "auto"
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
markers = ["live: runs against a real Solari gateway (needs SOLARI_API_KEY + SOLARI_BASE_URL)"]
|
|
32
|
+
addopts = "-m 'not live'"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ruff: noqa: F401
|
|
2
|
+
"""solari_e2b: an E2B-compatible Python API on Solari Sandbox. Not affiliated with E2B. See README.md, COMPAT.md, NOTICE.
|
|
3
|
+
|
|
4
|
+
``from solari_e2b import Sandbox`` (blocking) or ``AsyncSandbox``; ``from solari_e2b.code_interpreter import Sandbox`` for
|
|
5
|
+
``run_code``."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ._commands import AsyncCommandHandle, AsyncCommandInputHandle, AsyncCommands, AsyncPty
|
|
10
|
+
from ._config import DEFAULT_BASE_URL, is_solari_base_url
|
|
11
|
+
from ._filesystem import AsyncFileStreamReader, AsyncFilesystem, AsyncWatchHandle
|
|
12
|
+
from ._sandbox import AsyncSandbox, AsyncSandboxPaginator, AsyncSnapshotPaginator
|
|
13
|
+
from ._sync import Sandbox
|
|
14
|
+
from ._template import (
|
|
15
|
+
AsyncTemplate,
|
|
16
|
+
BuildInfo,
|
|
17
|
+
LogEntry,
|
|
18
|
+
LogEntryEnd,
|
|
19
|
+
LogEntryStart,
|
|
20
|
+
ReadyCmd,
|
|
21
|
+
Template,
|
|
22
|
+
TemplateBase,
|
|
23
|
+
TemplateBuildStatusResponse,
|
|
24
|
+
default_build_logger,
|
|
25
|
+
wait_for_file,
|
|
26
|
+
wait_for_port,
|
|
27
|
+
wait_for_process,
|
|
28
|
+
wait_for_timeout,
|
|
29
|
+
wait_for_url,
|
|
30
|
+
)
|
|
31
|
+
from .exceptions import * # noqa: F401,F403
|
|
32
|
+
from .exceptions import _Unsupported
|
|
33
|
+
from .models import * # noqa: F401,F403
|
|
34
|
+
|
|
35
|
+
#: E2B's Volume and Secret APIs have no Solari equivalent yet: every call raises SolariCompatUnsupported.
|
|
36
|
+
Volume = _Unsupported("Volume")
|
|
37
|
+
Secret = _Unsupported("Secret")
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Per-sandbox plumbing shared by Sandbox, Commands, Filesystem and Pty: the live control handle (reconnecting after a
|
|
2
|
+
resume), the guest's $HOME, and the shell wrapper E2B-style ``commands.run`` needs."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import inspect
|
|
8
|
+
from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from solari_core.errors import ConnectionError as SolariConnection, GatewayError, SolariError
|
|
12
|
+
|
|
13
|
+
from ._config import Conn
|
|
14
|
+
from .exceptions import translate
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
DEFAULT_HOME = "/home/user"
|
|
18
|
+
|
|
19
|
+
#: Native image sandboxes: a bash when the image has one, else sh. The command is "$1", so it is never re-parsed.
|
|
20
|
+
NATIVE_SHELL_SCRIPT = 'if [ -x /bin/bash ]; then exec /bin/bash -c "$1"; else exec /bin/sh -c "$1"; fi'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def shell_args(native: bool, command: str) -> Tuple[str, List[str]]:
|
|
24
|
+
"""``(program, args)`` for an E2B-style shell string: ``bash -lc`` normally, native-image safe otherwise."""
|
|
25
|
+
if native:
|
|
26
|
+
return "/bin/sh", ["-c", NATIVE_SHELL_SCRIPT, "sh", command]
|
|
27
|
+
return "/bin/bash", ["-lc", command]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def get_view(conn: Conn, sandbox_id: str) -> dict:
|
|
31
|
+
"""Raw ``GET /sandboxes/:id`` (the Python SDK view drops ``fromImage`` and the creation time)."""
|
|
32
|
+
return await conn.http.request("GET", f"/sandboxes/{quote(sandbox_id, safe='')}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fire(cb: Optional[Callable[..., Any]], *args: Any) -> None:
|
|
36
|
+
"""Invoke a user callback now if it is sync, or schedule it if it is async; an exception in it never breaks the stream."""
|
|
37
|
+
if cb is None:
|
|
38
|
+
return
|
|
39
|
+
try:
|
|
40
|
+
r = cb(*args)
|
|
41
|
+
if inspect.isawaitable(r):
|
|
42
|
+
asyncio.ensure_future(r)
|
|
43
|
+
except Exception: # noqa: BLE001
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def call_cb(cb: Optional[Callable[..., Any]], *args: Any) -> None:
|
|
48
|
+
"""Invoke a user callback (sync or async); an exception in it never breaks the stream."""
|
|
49
|
+
if cb is None:
|
|
50
|
+
return
|
|
51
|
+
try:
|
|
52
|
+
r = cb(*args)
|
|
53
|
+
if inspect.isawaitable(r):
|
|
54
|
+
await r
|
|
55
|
+
except Exception: # noqa: BLE001
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Box:
|
|
60
|
+
def __init__(self, conn: Conn, sandbox_id: str, initial: Any = None) -> None:
|
|
61
|
+
self.conn = conn
|
|
62
|
+
self.id = sandbox_id
|
|
63
|
+
self._h: Optional["asyncio.Future[Any]"] = None
|
|
64
|
+
self._initial = initial
|
|
65
|
+
self._native: Optional["asyncio.Future[bool]"] = None
|
|
66
|
+
self._home: Optional["asyncio.Future[str]"] = None
|
|
67
|
+
|
|
68
|
+
async def handle(self) -> Any:
|
|
69
|
+
"""The connected control handle, cached."""
|
|
70
|
+
if self._h is None:
|
|
71
|
+
self._h = asyncio.ensure_future(self._attach())
|
|
72
|
+
try:
|
|
73
|
+
return await asyncio.shield(self._h)
|
|
74
|
+
except BaseException:
|
|
75
|
+
if self._h is not None and self._h.done():
|
|
76
|
+
self._h = None
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
async def _attach(self) -> Any:
|
|
80
|
+
sb = self._initial if self._initial is not None else await self.conn.solari.connect(self.id)
|
|
81
|
+
self._initial = None
|
|
82
|
+
await sb.connect()
|
|
83
|
+
return sb
|
|
84
|
+
|
|
85
|
+
def drop(self) -> None:
|
|
86
|
+
self._h = None
|
|
87
|
+
self._initial = None
|
|
88
|
+
|
|
89
|
+
async def close(self) -> None:
|
|
90
|
+
"""Close this process's control channel to the sandbox (if one is open) and forget it."""
|
|
91
|
+
h, self._h = self._h, None
|
|
92
|
+
initial, self._initial = self._initial, None
|
|
93
|
+
sb = initial
|
|
94
|
+
if h is not None and h.done() and not h.cancelled() and h.exception() is None:
|
|
95
|
+
sb = h.result()
|
|
96
|
+
if sb is not None:
|
|
97
|
+
try:
|
|
98
|
+
await sb.close()
|
|
99
|
+
except Exception: # noqa: BLE001
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
async def on(self, fn: Callable[[Any], Awaitable[T]], what: str = "other") -> T:
|
|
103
|
+
"""Run ``fn`` on the live handle; if the channel was closed under us (first call after a resume), reconnect and retry."""
|
|
104
|
+
attempt = 0
|
|
105
|
+
while True:
|
|
106
|
+
try:
|
|
107
|
+
return await fn(await self.handle())
|
|
108
|
+
except Exception as e: # noqa: BLE001
|
|
109
|
+
closed = isinstance(e, SolariError) and not isinstance(e, GatewayError) and (
|
|
110
|
+
isinstance(e, SolariConnection) or any(w in str(e).lower() for w in ("closed", "not connected", "disconnect", "websocket"))
|
|
111
|
+
)
|
|
112
|
+
if not closed or attempt >= 2:
|
|
113
|
+
raise translate(e, what) from (e if translate(e, what) is not e else None)
|
|
114
|
+
self.drop()
|
|
115
|
+
attempt += 1
|
|
116
|
+
await asyncio.sleep(0.5 * attempt)
|
|
117
|
+
|
|
118
|
+
async def is_native(self) -> bool:
|
|
119
|
+
if self._native is None:
|
|
120
|
+
self._native = asyncio.ensure_future(self._fetch_native())
|
|
121
|
+
try:
|
|
122
|
+
return await asyncio.shield(self._native)
|
|
123
|
+
except BaseException:
|
|
124
|
+
self._native = None
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
async def _fetch_native(self) -> bool:
|
|
128
|
+
return bool((await get_view(self.conn, self.id)).get("fromImage"))
|
|
129
|
+
|
|
130
|
+
async def home(self) -> str:
|
|
131
|
+
"""The guest's $HOME (E2B's default cwd and relative-path base), created if missing."""
|
|
132
|
+
if self._home is None:
|
|
133
|
+
self._home = asyncio.ensure_future(self._fetch_home())
|
|
134
|
+
try:
|
|
135
|
+
return await asyncio.shield(self._home)
|
|
136
|
+
except BaseException:
|
|
137
|
+
self._home = None
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
async def _fetch_home(self) -> str:
|
|
141
|
+
prog, args = shell_args(await self.is_native(), 'printf %s "$HOME"')
|
|
142
|
+
r = await self.on(lambda sb: sb.commands.run(prog, args=args))
|
|
143
|
+
home = r.stdout.strip() or DEFAULT_HOME
|
|
144
|
+
await self.on(lambda sb: sb.commands.run("/bin/mkdir", args=["-p", home]))
|
|
145
|
+
return home
|
|
146
|
+
|
|
147
|
+
async def abs(self, path: str) -> str:
|
|
148
|
+
if path.startswith("/"):
|
|
149
|
+
return path
|
|
150
|
+
home = await self.home()
|
|
151
|
+
if path == "~":
|
|
152
|
+
return home
|
|
153
|
+
if path.startswith("~/"):
|
|
154
|
+
return f"{home}/{path[2:]}"
|
|
155
|
+
return f"{home}/{path}"
|