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.
@@ -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()