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 +39 -0
- solari_e2b/_box.py +155 -0
- solari_e2b/_commands.py +307 -0
- solari_e2b/_config.py +128 -0
- solari_e2b/_filesystem.py +226 -0
- solari_e2b/_loop.py +49 -0
- solari_e2b/_sandbox.py +448 -0
- solari_e2b/_sync.py +160 -0
- solari_e2b/_template.py +445 -0
- solari_e2b/code_interpreter.py +374 -0
- solari_e2b/exceptions.py +168 -0
- solari_e2b/models.py +128 -0
- solari_e2b_compat-0.1.0.dist-info/METADATA +89 -0
- solari_e2b_compat-0.1.0.dist-info/RECORD +17 -0
- solari_e2b_compat-0.1.0.dist-info/WHEEL +4 -0
- solari_e2b_compat-0.1.0.dist-info/licenses/LICENSE-E2B +9 -0
- solari_e2b_compat-0.1.0.dist-info/licenses/NOTICE +13 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""E2B ``sandbox.files``. Relative paths resolve against the guest's $HOME. Solari's listing carries name/dir/size; mode and
|
|
2
|
+
mtime come from a stat per entry; owner and group are not exposed."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import io
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any, AsyncIterator, Callable, Dict, List, Optional, Union
|
|
10
|
+
|
|
11
|
+
from ._box import Box, fire
|
|
12
|
+
from .exceptions import FileNotFoundException, InvalidArgumentException, SolariCompatUnsupported
|
|
13
|
+
from .models import EntryInfo, FilesystemEvent, FilesystemEventType, FileType, WriteEntry, WriteInfo
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _permissions(mode: int, is_dir: bool) -> str:
|
|
17
|
+
bits = "rwxrwxrwx"
|
|
18
|
+
return ("d" if is_dir else "-") + "".join(bits[i] if mode & (1 << (8 - i)) else "-" for i in range(9))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _dirname(p: str) -> str:
|
|
22
|
+
i = p.rfind("/")
|
|
23
|
+
return "/" if i <= 0 else p[:i]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _to_bytes(data: Any) -> bytes:
|
|
27
|
+
if isinstance(data, str):
|
|
28
|
+
return data.encode("utf-8")
|
|
29
|
+
if isinstance(data, (bytes, bytearray, memoryview)):
|
|
30
|
+
return bytes(data)
|
|
31
|
+
if isinstance(data, io.IOBase) or hasattr(data, "read"):
|
|
32
|
+
out = data.read()
|
|
33
|
+
return out.encode("utf-8") if isinstance(out, str) else bytes(out)
|
|
34
|
+
raise InvalidArgumentException("Unsupported data type for write: use str, bytes or a file-like object")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _reject(user: Optional[str], what: str, metadata: Optional[Dict[str, str]] = None) -> None:
|
|
38
|
+
if user:
|
|
39
|
+
raise SolariCompatUnsupported(f"{what} user", "Solari runs file operations as the sandbox's default user")
|
|
40
|
+
if metadata:
|
|
41
|
+
raise SolariCompatUnsupported(f"{what} metadata", "file extended attributes are not stored")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AsyncFileStreamReader:
|
|
45
|
+
"""``read(format='stream')``: an async iterator over the file's bytes (a single chunk)."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, data: bytes) -> None:
|
|
48
|
+
self._data: Optional[bytes] = data
|
|
49
|
+
|
|
50
|
+
def __aiter__(self) -> AsyncIterator[bytes]:
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
async def __anext__(self) -> bytes:
|
|
54
|
+
if self._data is None:
|
|
55
|
+
raise StopAsyncIteration
|
|
56
|
+
d, self._data = self._data, None
|
|
57
|
+
return d
|
|
58
|
+
|
|
59
|
+
async def aclose(self) -> None:
|
|
60
|
+
self._data = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AsyncWatchHandle:
|
|
64
|
+
def __init__(self, unwatch: Callable[[], Any]) -> None:
|
|
65
|
+
self._unwatch = unwatch
|
|
66
|
+
self._stopped = False
|
|
67
|
+
|
|
68
|
+
async def stop(self) -> None:
|
|
69
|
+
if self._stopped:
|
|
70
|
+
return
|
|
71
|
+
self._stopped = True
|
|
72
|
+
await self._unwatch()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AsyncFilesystem:
|
|
76
|
+
def __init__(self, box: Box) -> None:
|
|
77
|
+
self._box = box
|
|
78
|
+
|
|
79
|
+
async def read(self, path: str, format: str = "text", user: Optional[str] = None, request_timeout: Optional[float] = None, gzip: bool = False, stream_idle_timeout: Optional[float] = None):
|
|
80
|
+
_reject(user, "files.read")
|
|
81
|
+
if format not in ("text", "bytes", "stream"):
|
|
82
|
+
raise InvalidArgumentException(f"unknown format {format!r}: use 'text', 'bytes' or 'stream'")
|
|
83
|
+
abs_ = await self._box.abs(path)
|
|
84
|
+
data = await self._box.on(lambda sb: sb.files.read(abs_), "file")
|
|
85
|
+
if format == "bytes":
|
|
86
|
+
return bytearray(data)
|
|
87
|
+
if format == "stream":
|
|
88
|
+
return AsyncFileStreamReader(bytes(data))
|
|
89
|
+
return bytes(data).decode("utf-8", "replace")
|
|
90
|
+
|
|
91
|
+
async def write(self, path: str, data: Union[str, bytes, io.IOBase], user: Optional[str] = None, request_timeout: Optional[float] = None, gzip: bool = False, use_octet_stream: Optional[bool] = None, metadata: Optional[Dict[str, str]] = None) -> WriteInfo:
|
|
92
|
+
_reject(user, "files.write", metadata)
|
|
93
|
+
abs_ = await self._box.abs(path)
|
|
94
|
+
raw = _to_bytes(data)
|
|
95
|
+
|
|
96
|
+
async def _w(sb: Any) -> None:
|
|
97
|
+
# E2B creates missing parent directories.
|
|
98
|
+
try:
|
|
99
|
+
await sb.files.mkdir(_dirname(abs_))
|
|
100
|
+
except Exception: # noqa: BLE001
|
|
101
|
+
pass
|
|
102
|
+
await sb.files.write(abs_, raw)
|
|
103
|
+
|
|
104
|
+
await self._box.on(_w, "file")
|
|
105
|
+
return WriteInfo(name=abs_[abs_.rfind("/") + 1:], type=FileType.FILE, path=abs_)
|
|
106
|
+
|
|
107
|
+
async def write_files(self, files: List[WriteEntry], user: Optional[str] = None, request_timeout: Optional[float] = None, gzip: bool = False, use_octet_stream: Optional[bool] = None, metadata: Optional[Dict[str, str]] = None) -> List[WriteInfo]:
|
|
108
|
+
return [await self.write(f["path"], f["data"], user, request_timeout, gzip, use_octet_stream, metadata) for f in files]
|
|
109
|
+
|
|
110
|
+
async def _info(self, abs_: str) -> EntryInfo:
|
|
111
|
+
st = await self._box.on(lambda sb: sb.files.stat(abs_), "file")
|
|
112
|
+
is_dir = bool(st.dir)
|
|
113
|
+
return EntryInfo(
|
|
114
|
+
name=st.name or abs_[abs_.rfind("/") + 1:],
|
|
115
|
+
type=FileType.DIR if is_dir else FileType.FILE,
|
|
116
|
+
path=abs_,
|
|
117
|
+
size=st.size,
|
|
118
|
+
mode=st.mode & 0o7777,
|
|
119
|
+
permissions=_permissions(st.mode, is_dir),
|
|
120
|
+
owner="",
|
|
121
|
+
group="",
|
|
122
|
+
modified_time=datetime.fromtimestamp(st.modTimeMs / 1000, tz=timezone.utc),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def list(self, path: str, depth: Optional[int] = 1, user: Optional[str] = None, request_timeout: Optional[float] = None) -> List[EntryInfo]:
|
|
126
|
+
_reject(user, "files.list")
|
|
127
|
+
depth = 1 if depth is None else depth
|
|
128
|
+
if depth < 1:
|
|
129
|
+
raise InvalidArgumentException("depth should be at least one")
|
|
130
|
+
root = await self._box.abs(path)
|
|
131
|
+
if len(root) > 1:
|
|
132
|
+
root = root.rstrip("/") or "/"
|
|
133
|
+
out: List[EntryInfo] = []
|
|
134
|
+
sem = asyncio.Semaphore(8)
|
|
135
|
+
|
|
136
|
+
async def one(dir_: str, name: str) -> EntryInfo:
|
|
137
|
+
async with sem:
|
|
138
|
+
return await self._info(f"{'' if dir_ == '/' else dir_}/{name}")
|
|
139
|
+
|
|
140
|
+
async def walk(dir_: str, left: int) -> None:
|
|
141
|
+
entries = await self._box.on(lambda sb: sb.files.list(dir_), "file")
|
|
142
|
+
infos = await asyncio.gather(*(one(dir_, e.name) for e in entries))
|
|
143
|
+
out.extend(infos)
|
|
144
|
+
if left > 1:
|
|
145
|
+
for i in infos:
|
|
146
|
+
if i.type == FileType.DIR:
|
|
147
|
+
await walk(i.path, left - 1)
|
|
148
|
+
|
|
149
|
+
await walk(root, depth)
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
async def get_info(self, path: str, user: Optional[str] = None, request_timeout: Optional[float] = None) -> EntryInfo:
|
|
153
|
+
_reject(user, "files.get_info")
|
|
154
|
+
return await self._info(await self._box.abs(path))
|
|
155
|
+
|
|
156
|
+
async def exists(self, path: str, user: Optional[str] = None, request_timeout: Optional[float] = None) -> bool:
|
|
157
|
+
_reject(user, "files.exists")
|
|
158
|
+
try:
|
|
159
|
+
await self._info(await self._box.abs(path))
|
|
160
|
+
return True
|
|
161
|
+
except FileNotFoundException:
|
|
162
|
+
return False
|
|
163
|
+
except Exception as e: # noqa: BLE001
|
|
164
|
+
if "no such file" in str(e).lower() or "not exist" in str(e).lower() or "not found" in str(e).lower():
|
|
165
|
+
return False
|
|
166
|
+
raise
|
|
167
|
+
|
|
168
|
+
async def make_dir(self, path: str, user: Optional[str] = None, request_timeout: Optional[float] = None) -> bool:
|
|
169
|
+
_reject(user, "files.make_dir")
|
|
170
|
+
abs_ = await self._box.abs(path)
|
|
171
|
+
if await self.exists(abs_):
|
|
172
|
+
return False
|
|
173
|
+
await self._box.on(lambda sb: sb.files.mkdir(abs_), "file")
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
async def rename(self, old_path: str, new_path: str, user: Optional[str] = None, request_timeout: Optional[float] = None) -> EntryInfo:
|
|
177
|
+
_reject(user, "files.rename")
|
|
178
|
+
a, b = await self._box.abs(old_path), await self._box.abs(new_path)
|
|
179
|
+
await self._box.on(lambda sb: sb.files.rename(a, b), "file")
|
|
180
|
+
return await self._info(b)
|
|
181
|
+
|
|
182
|
+
async def remove(self, path: str, user: Optional[str] = None, request_timeout: Optional[float] = None) -> None:
|
|
183
|
+
_reject(user, "files.remove")
|
|
184
|
+
abs_ = await self._box.abs(path)
|
|
185
|
+
await self._box.on(lambda sb: sb.files.remove(abs_, True), "file")
|
|
186
|
+
|
|
187
|
+
async def watch_dir(
|
|
188
|
+
self,
|
|
189
|
+
path: str,
|
|
190
|
+
on_event: Callable[[FilesystemEvent], Any],
|
|
191
|
+
on_exit: Optional[Callable[[Optional[Exception]], Any]] = None,
|
|
192
|
+
user: Optional[str] = None,
|
|
193
|
+
request_timeout: Optional[float] = None,
|
|
194
|
+
timeout: Optional[float] = 60,
|
|
195
|
+
recursive: bool = False,
|
|
196
|
+
) -> AsyncWatchHandle:
|
|
197
|
+
_reject(user, "files.watch_dir")
|
|
198
|
+
abs_ = await self._box.abs(path)
|
|
199
|
+
base = abs_.rstrip("/")
|
|
200
|
+
valid = {t.value for t in FilesystemEventType}
|
|
201
|
+
|
|
202
|
+
def _cb(ev: Any) -> None:
|
|
203
|
+
typ = FilesystemEventType(ev.type) if ev.type in valid else FilesystemEventType.WRITE
|
|
204
|
+
name = ev.path[len(base) + 1:] if ev.path.startswith(base + "/") else ev.path
|
|
205
|
+
fire(on_event, FilesystemEvent(name=name, type=typ))
|
|
206
|
+
|
|
207
|
+
unwatch = await self._box.on(lambda sb: sb.files.watch(abs_, _cb, recursive))
|
|
208
|
+
timer: List[Optional["asyncio.TimerHandle"]] = [None]
|
|
209
|
+
|
|
210
|
+
async def _stop() -> None:
|
|
211
|
+
if timer[0] is not None:
|
|
212
|
+
timer[0].cancel()
|
|
213
|
+
try:
|
|
214
|
+
await unwatch()
|
|
215
|
+
except Exception: # noqa: BLE001
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
handle = AsyncWatchHandle(_stop)
|
|
219
|
+
# E2B ends a watch after ``timeout`` seconds (default 60; 0 disables).
|
|
220
|
+
if timeout and timeout > 0:
|
|
221
|
+
async def _expire() -> None:
|
|
222
|
+
await handle.stop()
|
|
223
|
+
fire(on_exit, None)
|
|
224
|
+
|
|
225
|
+
timer[0] = asyncio.get_running_loop().call_later(timeout, lambda: asyncio.ensure_future(_expire()))
|
|
226
|
+
return handle
|
solari_e2b/_loop.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Run coroutines from synchronous code on one background event loop (same design as solari_runloop._loop).
|
|
2
|
+
|
|
3
|
+
Commands, streaming and file RPCs need a loop that keeps running between calls, so the sync API owns one daemon thread
|
|
4
|
+
running a loop for the process lifetime. User callbacks passed to the sync API run on that thread."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Any, Coroutine, Optional, TypeVar
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LoopThread:
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
18
|
+
self._thread: Optional[threading.Thread] = None
|
|
19
|
+
self._lock = threading.Lock()
|
|
20
|
+
|
|
21
|
+
def _ensure(self) -> asyncio.AbstractEventLoop:
|
|
22
|
+
with self._lock:
|
|
23
|
+
if self._loop is None:
|
|
24
|
+
ready = threading.Event()
|
|
25
|
+
holder: dict = {}
|
|
26
|
+
|
|
27
|
+
def run() -> None:
|
|
28
|
+
loop = asyncio.new_event_loop()
|
|
29
|
+
holder["loop"] = loop
|
|
30
|
+
asyncio.set_event_loop(loop)
|
|
31
|
+
ready.set()
|
|
32
|
+
loop.run_forever()
|
|
33
|
+
|
|
34
|
+
t = threading.Thread(target=run, name="solari-e2b-loop", daemon=True)
|
|
35
|
+
t.start()
|
|
36
|
+
ready.wait()
|
|
37
|
+
self._loop = holder["loop"]
|
|
38
|
+
self._thread = t
|
|
39
|
+
return self._loop
|
|
40
|
+
|
|
41
|
+
def run(self, coro: "Coroutine[Any, Any, T]") -> T:
|
|
42
|
+
loop = self._ensure()
|
|
43
|
+
if self._thread is not None and threading.current_thread() is self._thread:
|
|
44
|
+
coro.close()
|
|
45
|
+
raise RuntimeError("the sync solari_e2b API was called from inside its own event loop (from a streaming callback?); use AsyncSandbox there")
|
|
46
|
+
return asyncio.run_coroutine_threadsafe(coro, loop).result()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
LOOP = LoopThread()
|