solari-e2b-compat 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/COMPAT.md +1 -1
  2. solari_e2b_compat-0.2.0/LICENSE +21 -0
  3. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/PKG-INFO +2 -1
  4. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/pyproject.toml +2 -2
  5. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/solari_e2b/__init__.py +1 -1
  6. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/solari_e2b/_config.py +4 -1
  7. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/solari_e2b/_sandbox.py +51 -8
  8. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/.gitignore +0 -0
  9. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/LICENSE-E2B +0 -0
  10. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/NOTICE +0 -0
  11. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/README.md +0 -0
  12. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/solari_e2b/_box.py +0 -0
  13. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/solari_e2b/_commands.py +0 -0
  14. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/solari_e2b/_filesystem.py +0 -0
  15. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/solari_e2b/_loop.py +0 -0
  16. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/solari_e2b/_sync.py +0 -0
  17. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/solari_e2b/_template.py +0 -0
  18. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/solari_e2b/code_interpreter.py +0 -0
  19. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/solari_e2b/exceptions.py +0 -0
  20. {solari_e2b_compat-0.1.0 → solari_e2b_compat-0.2.0}/solari_e2b/models.py +0 -0
@@ -41,7 +41,7 @@ and the differences that are specific to Python.
41
41
  | `get_metrics()` | DIFF | One current sample (no history); `mem_cache` is 0. |
42
42
  | `pause()` | OK, live | Keeps RAM and disk. `mode='filesystem'`, `keep_memory=False`: treated as a full pause. |
43
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. |
44
+ | `fork(count=)` | DIFF, live | Checkpoint then `count` new sandboxes from it; the gateway will not delete a checkpoint while a copy is alive, so each copy carries the checkpoint id in a hidden metadata key (`e2b.forkSnapshot`, not shown in `info.metadata`; you cannot set it yourself) and the **last copy killed deletes it** (waiting up to ~40 s). A copy that is not killed (it expires on its idle timeout) keeps the checkpoint, which is billed snapshot storage: kill your copies. Returns a sandbox or an exception per copy. |
45
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
46
  | `upload_url`, `download_url` | OK | `user=`: NO. |
47
47
  | `update_network`, `get_mcp_url`, `get_mcp_token` (returns `None`), `git` | NO | `git` is deprecated in E2B; use `commands.run('git …')`. |
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Solari Desktop
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.
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: solari-e2b-compat
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: E2B-compatible Python interface (sandboxes and code interpreter) over Solari Sandboxes. Not affiliated with E2B.
5
5
  Project-URL: Homepage, https://getsolari.com
6
6
  Author: Solari
7
7
  License: MIT
8
+ License-File: LICENSE
8
9
  License-File: LICENSE-E2B
9
10
  License-File: NOTICE
10
11
  Keywords: code-interpreter,compatibility,e2b,sandbox,solari
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "solari-e2b-compat"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "E2B-compatible Python interface (sandboxes and code interpreter) over Solari Sandboxes. Not affiliated with E2B."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -23,7 +23,7 @@ Homepage = "https://getsolari.com"
23
23
  packages = ["solari_e2b"]
24
24
 
25
25
  [tool.hatch.build.targets.sdist]
26
- include = ["solari_e2b", "README.md", "COMPAT.md", "NOTICE", "LICENSE-E2B"]
26
+ include = ["solari_e2b", "README.md", "COMPAT.md", "NOTICE", "LICENSE", "LICENSE-E2B"]
27
27
 
28
28
  [tool.pytest.ini_options]
29
29
  asyncio_mode = "auto"
@@ -36,4 +36,4 @@ from .models import * # noqa: F401,F403
36
36
  Volume = _Unsupported("Volume")
37
37
  Secret = _Unsupported("Secret")
38
38
 
39
- __version__ = "0.1.0"
39
+ __version__ = "0.2.0"
@@ -99,7 +99,10 @@ def _resolve(opts: Dict[str, Any]) -> Tuple[str, str]:
99
99
  _describe(inherited_raw),
100
100
  DEFAULT_BASE_URL,
101
101
  )
102
- return key, (base or os.environ.get("SOLARI_BASE_URL") or inherited or DEFAULT_BASE_URL).rstrip("/")
102
+ env_base = os.environ.get("SOLARI_BASE_URL")
103
+ if not base and env_base and not is_solari_base_url(env_base):
104
+ raise SandboxException(f"Refusing to send a Solari API key to {_describe(env_base)}: it does not point at Solari.")
105
+ return key, (base or env_base or inherited or DEFAULT_BASE_URL).rstrip("/")
103
106
 
104
107
 
105
108
  def resolve_conn(**opts: Any) -> Conn:
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import functools
7
7
  import inspect
8
+ import logging
8
9
  from datetime import datetime, timezone
9
10
  from typing import Any, Callable, Dict, List, Optional
10
11
  from urllib.parse import quote, urlencode
@@ -35,13 +36,46 @@ def _parse_time(s: Optional[str]) -> datetime:
35
36
  return datetime.fromisoformat(s.replace("Z", "+00:00"))
36
37
 
37
38
 
39
+ log = logging.getLogger("solari_e2b")
40
+
41
+ #: Reserved metadata key (hidden from ``info.metadata``): the checkpoint a fork was booted from, deleted with the last copy.
42
+ FORK_KEY = "e2b.forkSnapshot"
43
+
44
+
45
+ def _public_metadata(m: Optional[Dict[str, str]]) -> Dict[str, str]:
46
+ return {k: v for k, v in (m or {}).items() if k != FORK_KEY}
47
+
48
+
49
+ async def _release_fork_snapshot(conn: Any, snapshot_id: str, self_id: str) -> None:
50
+ """Delete a fork's checkpoint once no copy that needs it is left. The gateway refuses while a copy is alive, so this runs
51
+ after the last copy is killed (it waits for that sandbox's release to settle, about 40 s at most) and says so if it fails."""
52
+ try:
53
+ listed = await conn.solari.list(metadata={FORK_KEY: snapshot_id}, limit=50)
54
+ views = [_view_dict(x) for x in listed["sandboxes"]]
55
+ if any(v.get("sandboxId") != self_id and v.get("state") not in ("gone", "releasing") for v in views):
56
+ return # another live copy still needs it; the last one to be killed deletes it (one that is only "releasing" no longer does)
57
+ last: Optional[BaseException] = None
58
+ for _ in range(20):
59
+ try:
60
+ await conn.solari.delete_snapshot(snapshot_id)
61
+ return
62
+ except Exception as e: # noqa: BLE001
63
+ if getattr(e, "status", None) == 404: # another copy killed at the same time already deleted it
64
+ return
65
+ last = e
66
+ await asyncio.sleep(2)
67
+ log.warning("could not delete the fork checkpoint %s (%s); delete it with Sandbox.delete_snapshot(%r)", snapshot_id, last, snapshot_id)
68
+ except Exception: # noqa: BLE001
69
+ pass
70
+
71
+
38
72
  def to_info(v: Dict[str, Any]) -> SandboxInfo:
39
73
  state = "paused" if v.get("state") in ("paused", "stopped") else "running"
40
74
  return SandboxInfo(
41
75
  sandbox_id=v["sandboxId"],
42
76
  template_id="",
43
77
  name=v.get("name"),
44
- metadata=v.get("metadata") or {},
78
+ metadata=_public_metadata(v.get("metadata")),
45
79
  started_at=_parse_time(v.get("createdAt") or v.get("startedAt")),
46
80
  end_at=_parse_time(v.get("expiresAt")),
47
81
  state=state,
@@ -224,6 +258,9 @@ class AsyncSandbox:
224
258
  raise SolariCompatUnsupported("iam")
225
259
  if allow_internet_access is False:
226
260
  raise SolariCompatUnsupported("allow_internet_access=False", "Solari sandboxes always have internet access")
261
+ # A caller could otherwise name another sandbox's checkpoint here, and killing this sandbox would then delete it.
262
+ if metadata and FORK_KEY in metadata:
263
+ raise SolariCompatUnsupported(f"metadata key {FORK_KEY!r}", "this key is reserved by the compatibility layer")
227
264
  conn = resolve_conn(**split_opts(opts)[0])
228
265
  volumes = await _resolve_volumes(conn, volume_mounts) if volume_mounts else None
229
266
  try:
@@ -280,8 +317,11 @@ class AsyncSandbox:
280
317
  # The gateway's DELETE is idempotent; E2B reports False for a sandbox that is already gone.
281
318
  await self._box.close()
282
319
  try:
283
- await get_view(self._box.conn, self._sandbox_id)
320
+ view = await get_view(self._box.conn, self._sandbox_id)
284
321
  await self._box.conn.solari.kill(self._sandbox_id)
322
+ fork = (view.get("metadata") or {}).get(FORK_KEY)
323
+ if fork:
324
+ await _release_fork_snapshot(self._box.conn, fork, self._sandbox_id)
285
325
  return True
286
326
  except Exception as e: # noqa: BLE001
287
327
  t = translate(e, "sandbox")
@@ -379,23 +419,26 @@ class AsyncSandbox:
379
419
  try:
380
420
  view = await get_view(conn, self._sandbox_id)
381
421
  snap = (await self.create_snapshot()).snapshot_id
422
+ parent_md = _public_metadata(view.get("metadata"))
382
423
  except Exception as e: # noqa: BLE001
383
424
  raise translate(e, "sandbox") from e
384
425
 
385
426
  async def one() -> Any:
386
427
  try:
387
- core = await conn.solari.create(from_snapshot=snap, timeout_ms=int((timeout if timeout is not None else DEFAULT_TIMEOUT) * 1000), metadata=view.get("metadata") or None)
428
+ core = await conn.solari.create(from_snapshot=snap, timeout_ms=int((timeout if timeout is not None else DEFAULT_TIMEOUT) * 1000), metadata={**parent_md, FORK_KEY: snap})
388
429
  return cls(conn, core.sandboxId, core)
389
430
  except Exception as e: # noqa: BLE001
390
431
  t = translate(e)
391
432
  return t if isinstance(t, BaseException) else SandboxException(str(t))
392
433
 
393
434
  forks = await asyncio.gather(*(one() for _ in range(n)))
394
- # The checkpoint was made for this call only: release it once the copies are up (best effort, bounded).
395
- try:
396
- await asyncio.wait_for(conn.solari.delete_snapshot(snap), 20)
397
- except Exception: # noqa: BLE001
398
- pass
435
+ # The checkpoint was made for this call only. While a copy is alive the gateway refuses to delete it; the copies carry its
436
+ # id (FORK_KEY) and the last one killed deletes it (see ``kill``). If no copy came up there is nothing to wait for.
437
+ if all(isinstance(f, BaseException) for f in forks):
438
+ try:
439
+ await asyncio.wait_for(conn.solari.delete_snapshot(snap), 20)
440
+ except Exception: # noqa: BLE001
441
+ pass
399
442
  return list(forks)
400
443
 
401
444
  # --- network / urls ---