solari-e2b-compat 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"
solari_e2b/_box.py ADDED
@@ -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}"
@@ -0,0 +1,307 @@
1
+ """E2B ``sandbox.commands`` and ``sandbox.pty``. Solari identifies a running command by a string id that lives on the
2
+ creating control channel, so E2B's numeric ``pid`` values are process-local handles: list/connect/kill/send_stdin work for
3
+ commands started by this process (see COMPAT.md)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
9
+
10
+ from ._box import Box, fire, shell_args
11
+ from .exceptions import CommandExitException, CommandResult, NotFoundException, SandboxException, SolariCompatUnsupported, TimeoutException, translate
12
+ from .models import ProcessInfo, PtySize
13
+
14
+ DEFAULT_COMMAND_TIMEOUT = 60.0
15
+ _next_pid = 1000
16
+ _procs: Dict[str, Dict[int, "_Rec"]] = {}
17
+
18
+
19
+ class _Rec:
20
+ def __init__(self, pid: int, info: ProcessInfo, handle: "AsyncCommandHandle", kind: str) -> None:
21
+ self.pid, self.info, self.handle, self.kind, self.exited = pid, info, handle, kind, False
22
+
23
+
24
+ def _procs_of(sandbox_id: str) -> Dict[int, _Rec]:
25
+ return _procs.setdefault(sandbox_id, {})
26
+
27
+
28
+ def _alloc_pid() -> int:
29
+ global _next_pid
30
+ _next_pid += 1
31
+ return _next_pid - 1
32
+
33
+
34
+ class AsyncCommandHandle:
35
+ """What ``commands.run(..., background=True)`` and ``pty.create`` return."""
36
+
37
+ def __init__(
38
+ self,
39
+ pid: int,
40
+ kill: Callable[[], Awaitable[None]],
41
+ stdin: Callable[[Union[str, bytes]], Awaitable[None]],
42
+ pty: bool = False,
43
+ ) -> None:
44
+ self._pid = pid
45
+ self._kill, self._stdin, self._pty = kill, stdin, pty
46
+ self._stdout = ""
47
+ self._stderr = ""
48
+ self._exit: Optional[int] = None
49
+ self._error: Optional[str] = None
50
+ self._done: "asyncio.Future[None]" = asyncio.get_running_loop().create_future()
51
+ self._on_stdout: List[Callable[..., Any]] = []
52
+ self._on_stderr: List[Callable[..., Any]] = []
53
+ self._on_pty: List[Callable[..., Any]] = []
54
+
55
+ @property
56
+ def pid(self) -> int:
57
+ return self._pid
58
+
59
+ @property
60
+ def stdout(self) -> str:
61
+ return self._stdout
62
+
63
+ @property
64
+ def stderr(self) -> str:
65
+ return self._stderr
66
+
67
+ @property
68
+ def exit_code(self) -> Optional[int]:
69
+ return self._exit
70
+
71
+ @property
72
+ def error(self) -> Optional[str]:
73
+ return self._error
74
+
75
+ # --- fed by the transport ---
76
+ def _data(self, stream: str, data: str) -> None:
77
+ if stream == "stdout":
78
+ self._stdout += data
79
+ cbs = self._on_stdout
80
+ else:
81
+ self._stderr += data
82
+ cbs = self._on_stderr
83
+ for cb in list(cbs):
84
+ fire(cb, data)
85
+
86
+ def _pty_data(self, data: bytes) -> None:
87
+ for cb in list(self._on_pty):
88
+ fire(cb, data)
89
+
90
+ def _exited(self, code: int, error: Optional[str] = None) -> None:
91
+ if self._exit is not None:
92
+ return
93
+ self._exit, self._error = code, error
94
+ if not self._done.done():
95
+ self._done.set_result(None)
96
+
97
+ async def wait(self, on_pty=None, on_stdout=None, on_stderr=None) -> CommandResult:
98
+ """Wait for the command to end. Raises :class:`CommandExitException` on a non-zero exit, as E2B does."""
99
+ if on_stdout:
100
+ self._on_stdout.append(on_stdout)
101
+ if on_stderr:
102
+ self._on_stderr.append(on_stderr)
103
+ if on_pty:
104
+ self._on_pty.append(on_pty)
105
+ await asyncio.shield(self._done)
106
+ r = CommandResult(stderr=self._stderr, stdout=self._stdout, exit_code=self._exit or 0, error=self._error)
107
+ if r.exit_code != 0:
108
+ raise CommandExitException(r.stderr, r.stdout, r.exit_code, r.error)
109
+ return r
110
+
111
+ async def disconnect(self) -> None:
112
+ """Stop receiving output; the command keeps running."""
113
+ self._on_stdout.clear()
114
+ self._on_stderr.clear()
115
+ self._on_pty.clear()
116
+
117
+ async def kill(self) -> bool:
118
+ """SIGKILL. ``False`` when the command already ended."""
119
+ if self._exit is not None:
120
+ return False
121
+ try:
122
+ await self._kill()
123
+ except Exception as e: # noqa: BLE001
124
+ raise translate(e) from e
125
+ if self._pty:
126
+ self._exited(137, "killed")
127
+ return True
128
+
129
+ async def send_stdin(self, data: Union[str, bytes], request_timeout: Optional[float] = None) -> None:
130
+ try:
131
+ await self._stdin(data)
132
+ except Exception as e: # noqa: BLE001
133
+ raise translate(e) from e
134
+
135
+ async def close_stdin(self, request_timeout: Optional[float] = None) -> None:
136
+ raise SolariCompatUnsupported("close_stdin", "Solari cannot close a command's stdin; end the program another way (for example a sentinel line)")
137
+
138
+
139
+ class AsyncCommandInputHandle:
140
+ """Ordered input stream, built over repeated writes (Solari writes are ordered per command)."""
141
+
142
+ def __init__(self, write: Callable[[Union[str, bytes]], Awaitable[None]]) -> None:
143
+ self._write = write
144
+ self._closed = False
145
+
146
+ async def send(self, data: Union[str, bytes]) -> None:
147
+ if self._closed:
148
+ raise SandboxException("The input stream is closed")
149
+ await self._write(data)
150
+
151
+ async def close(self) -> None:
152
+ self._closed = True
153
+
154
+
155
+ class AsyncCommands:
156
+ def __init__(self, box: Box) -> None:
157
+ self._box = box
158
+
159
+ @property
160
+ def supports_stdin_close(self) -> bool:
161
+ return False
162
+
163
+ async def run(
164
+ self,
165
+ cmd: str,
166
+ background: Optional[bool] = None,
167
+ envs: Optional[Dict[str, str]] = None,
168
+ user: Optional[str] = None,
169
+ cwd: Optional[str] = None,
170
+ on_stdout: Optional[Callable[..., Any]] = None,
171
+ on_stderr: Optional[Callable[..., Any]] = None,
172
+ stdin: Optional[bool] = None,
173
+ timeout: Optional[float] = DEFAULT_COMMAND_TIMEOUT,
174
+ request_timeout: Optional[float] = None,
175
+ ) -> Union[CommandResult, AsyncCommandHandle]:
176
+ handle = await self._start(cmd, envs, user, cwd, on_stdout, on_stderr)
177
+ if background:
178
+ return handle
179
+ if timeout:
180
+ try:
181
+ return await asyncio.wait_for(handle.wait(), timeout)
182
+ except asyncio.TimeoutError:
183
+ try:
184
+ await handle.kill()
185
+ except Exception: # noqa: BLE001
186
+ pass
187
+ raise TimeoutException(f"Command timed out after {timeout:g}s") from None
188
+ return await handle.wait()
189
+
190
+ async def _start(self, cmd, envs, user, cwd, on_stdout, on_stderr) -> AsyncCommandHandle:
191
+ native = await self._box.is_native()
192
+ program, args = shell_args(native, cmd)
193
+ # E2B runs commands from the user's home directory unless told otherwise.
194
+ wd = await self._box.abs(cwd) if cwd else await self._box.home()
195
+ core = await self._box.on(lambda sb: sb.commands.start(program, args=args, cwd=wd, env=envs, user=user))
196
+ pid = _alloc_pid()
197
+ handle = AsyncCommandHandle(pid, core.kill, core.stdin)
198
+ if on_stdout:
199
+ handle._on_stdout.append(on_stdout)
200
+ if on_stderr:
201
+ handle._on_stderr.append(on_stderr)
202
+ core.on_data(handle._data)
203
+ rec = _Rec(pid, ProcessInfo(pid=pid, cmd=program, args=args, envs=envs or {}, cwd=wd), handle, "cmd")
204
+ _procs_of(self._box.id)[pid] = rec
205
+
206
+ async def _watch() -> None:
207
+ try:
208
+ code = await core.wait()
209
+ rec.exited = True
210
+ handle._exited(code)
211
+ except Exception as e: # noqa: BLE001
212
+ rec.exited = True
213
+ handle._exited(1, str(e))
214
+
215
+ asyncio.ensure_future(_watch())
216
+ return handle
217
+
218
+ async def list(self, request_timeout: Optional[float] = None) -> List[ProcessInfo]:
219
+ """Commands and PTYs this process started on the sandbox that have not ended."""
220
+ return [r.info for r in _procs_of(self._box.id).values() if not r.exited]
221
+
222
+ def _known(self, pid: int) -> _Rec:
223
+ rec = _procs_of(self._box.id).get(pid)
224
+ if rec is None:
225
+ raise NotFoundException(f"Process {pid} not found. Solari can only address commands started by this process (see COMPAT.md).")
226
+ return rec
227
+
228
+ async def kill(self, pid: int, request_timeout: Optional[float] = None) -> bool:
229
+ rec = _procs_of(self._box.id).get(pid)
230
+ if rec is None or rec.exited:
231
+ return False
232
+ return await rec.handle.kill()
233
+
234
+ async def send_stdin(self, pid: int, data: Union[str, bytes], request_timeout: Optional[float] = None) -> None:
235
+ await self._known(pid).handle.send_stdin(data)
236
+
237
+ def stream_input(self, pid: int, *, timeout: Optional[float] = None) -> AsyncCommandInputHandle:
238
+ return AsyncCommandInputHandle(self._known(pid).handle.send_stdin)
239
+
240
+ async def close_stdin(self, pid: int, request_timeout: Optional[float] = None) -> None:
241
+ await self._known(pid).handle.close_stdin()
242
+
243
+ async def connect(self, pid: int, timeout: Optional[float] = 60, request_timeout: Optional[float] = None, on_stdout=None, on_stderr=None) -> AsyncCommandHandle:
244
+ h = self._known(pid).handle
245
+ if on_stdout:
246
+ h._on_stdout.append(on_stdout)
247
+ if on_stderr:
248
+ h._on_stderr.append(on_stderr)
249
+ return h
250
+
251
+
252
+ class AsyncPty:
253
+ def __init__(self, box: Box) -> None:
254
+ self._box = box
255
+
256
+ async def create(
257
+ self,
258
+ size: PtySize,
259
+ on_data: Callable[[bytes], Any],
260
+ user: Optional[str] = None,
261
+ cwd: Optional[str] = None,
262
+ envs: Optional[Dict[str, str]] = None,
263
+ timeout: Optional[float] = 60,
264
+ request_timeout: Optional[float] = None,
265
+ ) -> AsyncCommandHandle:
266
+ if user:
267
+ raise SolariCompatUnsupported("pty.create user")
268
+ env = {"TERM": "xterm-256color", **(envs or {})}
269
+ core = await self._box.on(lambda sb: sb.pty.create(cols=size.cols, rows=size.rows, cwd=cwd, env=env))
270
+ pid = _alloc_pid()
271
+ handle = AsyncCommandHandle(pid, core.kill, core.write, pty=True)
272
+ handle._on_pty.append(on_data)
273
+ core.on_data(handle._pty_data)
274
+ handle._pty_core = core # type: ignore[attr-defined]
275
+ _procs_of(self._box.id)[pid] = _Rec(pid, ProcessInfo(pid=pid, cmd="pty", envs=envs or {}, cwd=cwd), handle, "pty")
276
+ return handle
277
+
278
+ def _pty(self, pid: int) -> _Rec:
279
+ rec = _procs_of(self._box.id).get(pid)
280
+ if rec is None or rec.kind != "pty":
281
+ raise NotFoundException(f"PTY {pid} not found. Solari can only address PTYs created by this process (see COMPAT.md).")
282
+ return rec
283
+
284
+ async def connect(self, pid: int, on_data: Callable[[bytes], Any], timeout: Optional[float] = 60, request_timeout: Optional[float] = None) -> AsyncCommandHandle:
285
+ rec = self._pty(pid)
286
+ rec.handle._on_pty.append(on_data)
287
+ return rec.handle
288
+
289
+ async def send_stdin(self, pid: int, data: bytes, request_timeout: Optional[float] = None) -> None:
290
+ await self._pty(pid).handle.send_stdin(data)
291
+
292
+ def stream_input(self, pid: int, *, timeout: Optional[float] = None) -> AsyncCommandInputHandle:
293
+ return AsyncCommandInputHandle(self._pty(pid).handle.send_stdin)
294
+
295
+ async def resize(self, pid: int, size: PtySize, request_timeout: Optional[float] = None) -> None:
296
+ core = self._pty(pid).handle._pty_core # type: ignore[attr-defined]
297
+ try:
298
+ await core.resize(size.cols, size.rows)
299
+ except Exception as e: # noqa: BLE001
300
+ raise translate(e) from e
301
+
302
+ async def kill(self, pid: int, request_timeout: Optional[float] = None) -> bool:
303
+ rec = _procs_of(self._box.id).get(pid)
304
+ if rec is None or rec.kind != "pty" or rec.exited:
305
+ return False
306
+ rec.exited = True
307
+ return await rec.handle.kill()
solari_e2b/_config.py ADDED
@@ -0,0 +1,128 @@
1
+ """Credential and base-URL resolution. Only a Solari key (``slr_…``) is ever used or sent; E2B's own environment variables
2
+ (``E2B_API_KEY``, ``E2B_API_URL``, ``E2B_DOMAIN``) are honoured only when they are safe to send a Solari key to."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import logging
8
+ import os
9
+ from dataclasses import dataclass
10
+ from typing import Any, Callable, Dict, Optional, Tuple
11
+ from urllib.parse import urlparse
12
+
13
+ import httpx
14
+ from solari_core import HttpTransport, TemplateClient
15
+ from solari_sandbox import SandboxClient
16
+
17
+ from .exceptions import SandboxException, SolariCompatUnsupported
18
+
19
+ log = logging.getLogger("solari_e2b")
20
+
21
+ DEFAULT_BASE_URL = "https://api.getsolari.com"
22
+ KEY_PREFIX = "slr_"
23
+
24
+ #: Per-call connection options the E2B SDK accepts (``**opts`` on most methods).
25
+ API_OPTS = frozenset(
26
+ {"api_key", "domain", "api_url", "request_timeout", "debug", "headers", "api_headers", "proxy", "logger", "sandbox_url", "validate_api_key", "retries", "http_version"}
27
+ )
28
+ #: Accepted and ignored: they tune E2B's own transport.
29
+ IGNORED_OPTS = frozenset({"request_timeout", "debug", "logger", "validate_api_key", "retries", "http_version", "sandbox_url"})
30
+
31
+ #: Test hook: a callable returning the ``httpx.AsyncClient`` the clients should use (a MockTransport client in tests).
32
+ _HTTP_FACTORY: Optional[Callable[[], httpx.AsyncClient]] = None
33
+
34
+
35
+ def is_solari_base_url(raw: str) -> bool:
36
+ """https on getsolari.com or a subdomain, or loopback over http(s)."""
37
+ try:
38
+ u = urlparse(raw)
39
+ host = (u.hostname or "").lower().rstrip(".")
40
+ except ValueError:
41
+ return False
42
+ if u.scheme == "https" and (host == "getsolari.com" or host.endswith(".getsolari.com")):
43
+ return True
44
+ return u.scheme in ("http", "https") and (host in ("localhost", "::1") or host.replace(".", "").isdigit() and host.startswith("127."))
45
+
46
+
47
+ def _describe(raw: str) -> str:
48
+ try:
49
+ return urlparse(raw).hostname or "unparsable URL"
50
+ except ValueError:
51
+ return "unparsable URL"
52
+
53
+
54
+ @dataclass
55
+ class Conn:
56
+ api_key: str
57
+ base_url: str
58
+ solari: SandboxClient
59
+ http: HttpTransport
60
+ templates: TemplateClient
61
+
62
+
63
+ _cache: Dict[Tuple[str, str, int], Conn] = {}
64
+
65
+
66
+ def _resolve(opts: Dict[str, Any]) -> Tuple[str, str]:
67
+ if opts.get("headers"):
68
+ raise SolariCompatUnsupported("headers")
69
+ if opts.get("api_headers"):
70
+ raise SolariCompatUnsupported("api_headers")
71
+ if opts.get("proxy"):
72
+ raise SolariCompatUnsupported("proxy")
73
+ key: Optional[str] = None
74
+ for source, value in (("the api_key option", opts.get("api_key")), ("SOLARI_API_KEY", os.environ.get("SOLARI_API_KEY")), ("E2B_API_KEY", os.environ.get("E2B_API_KEY"))):
75
+ if not value:
76
+ continue
77
+ if value.startswith(KEY_PREFIX):
78
+ key = value
79
+ break
80
+ log.warning("Ignoring %s: it is not a Solari API key (%s…). It is not sent anywhere.", source, KEY_PREFIX)
81
+ if not key:
82
+ raise SandboxException(
83
+ "No Solari API key. Set SOLARI_API_KEY, or pass api_key='slr_live_…'. An E2B key (E2B_API_KEY) is never sent to Solari and cannot be used here."
84
+ )
85
+ explicit = opts.get("api_url") or (f"https://api.{opts['domain']}" if opts.get("domain") else None)
86
+ base: Optional[str] = None
87
+ if explicit:
88
+ if not is_solari_base_url(explicit):
89
+ raise SandboxException(f"Refusing to send a Solari API key to {_describe(explicit)}: it does not point at Solari.")
90
+ base = explicit
91
+ inherited_raw = os.environ.get("E2B_API_URL") or (f"https://api.{os.environ['E2B_DOMAIN']}" if os.environ.get("E2B_DOMAIN") else None)
92
+ inherited: Optional[str] = None
93
+ if inherited_raw:
94
+ if is_solari_base_url(inherited_raw):
95
+ inherited = inherited_raw
96
+ elif not base and not os.environ.get("SOLARI_BASE_URL"):
97
+ log.warning(
98
+ "Ignoring E2B_API_URL/E2B_DOMAIN (%s): it does not point at Solari, and your Solari API key is never sent anywhere else. Using %s.",
99
+ _describe(inherited_raw),
100
+ DEFAULT_BASE_URL,
101
+ )
102
+ return key, (base or os.environ.get("SOLARI_BASE_URL") or inherited or DEFAULT_BASE_URL).rstrip("/")
103
+
104
+
105
+ def resolve_conn(**opts: Any) -> Conn:
106
+ """Connection for one call, cached per key + URL + running event loop (httpx clients are loop-bound)."""
107
+ key, base = _resolve(opts)
108
+ loop_id = id(asyncio.get_running_loop())
109
+ ck = (key, base, loop_id)
110
+ c = _cache.get(ck)
111
+ if c is None:
112
+ http = _HTTP_FACTORY() if _HTTP_FACTORY else None
113
+ c = Conn(
114
+ api_key=key,
115
+ base_url=base,
116
+ solari=SandboxClient(api_key=key, base_url=base, http=http),
117
+ http=HttpTransport(api_key=key, base_url=base, http=http),
118
+ templates=TemplateClient(api_key=key, base_url=base, http=http),
119
+ )
120
+ _cache[ck] = c
121
+ return c
122
+
123
+
124
+ def split_opts(kwargs: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
125
+ """Separate connection options from the rest of a call's keyword arguments."""
126
+ opts = {k: v for k, v in kwargs.items() if k in API_OPTS}
127
+ rest = {k: v for k, v in kwargs.items() if k not in API_OPTS}
128
+ return opts, rest