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.
@@ -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}"