boxfab-code-interpreter 0.1.0a1__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,414 @@
1
+ import logging
2
+ import httpx
3
+
4
+ from typing import Optional, Dict, overload, Union, List
5
+ from httpx import Client
6
+ from boxfab import Sandbox as BaseSandbox, InvalidArgumentException
7
+ from boxfab.api.client_sync import get_transport
8
+
9
+ from boxfab_code_interpreter.constants import (
10
+ DEFAULT_TEMPLATE,
11
+ JUPYTER_PORT,
12
+ DEFAULT_TIMEOUT,
13
+ )
14
+ from boxfab_code_interpreter.models import (
15
+ ExecutionError,
16
+ Execution,
17
+ RunCodeLanguage,
18
+ Context,
19
+ Result,
20
+ extract_exception,
21
+ parse_output,
22
+ OutputHandler,
23
+ OutputMessage,
24
+ )
25
+ from boxfab_code_interpreter.exceptions import (
26
+ format_execution_timeout_error,
27
+ format_request_timeout_error,
28
+ format_sandbox_killed_error,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class Sandbox(BaseSandbox):
35
+ """
36
+ BoxFab cloud sandbox is a secure and isolated cloud environment.
37
+
38
+ The sandbox allows you to:
39
+ - Access Linux OS
40
+ - Create, list, and delete files and directories
41
+ - Run commands
42
+ - Run isolated code
43
+ - Access the internet
44
+
45
+ Check docs [here](https://boxfab.dev/docs).
46
+
47
+ Use the `Sandbox.create()` to create a new sandbox.
48
+
49
+ Example:
50
+ ```python
51
+ from boxfab_code_interpreter import Sandbox
52
+
53
+ sandbox = Sandbox.create()
54
+ ```
55
+ """
56
+
57
+ default_template = DEFAULT_TEMPLATE
58
+
59
+ @property
60
+ def _jupyter_url(self) -> str:
61
+ return self.connection_config.get_sandbox_url(
62
+ self.sandbox_id, self.sandbox_domain, port=JUPYTER_PORT
63
+ )
64
+
65
+ @property
66
+ def _client(self) -> Client:
67
+ # TODO: Remove later
68
+ # Use a dedicated HTTP/1.1 transport for Jupyter requests.
69
+ #
70
+ # The base SDK's shared transport now defaults to HTTP/2. With
71
+ # HTTP/2, multiple requests are multiplexed over a single TCP
72
+ # connection, so when a client cancels a request (e.g. the caller
73
+ # disconnects from the streaming `/execute` endpoint) the server
74
+ # may not detect the disconnect: only the HTTP/2 stream is
75
+ # cancelled, the underlying TCP connection stays open.
76
+ #
77
+ # Forcing HTTP/1.1 here keeps the 1:1 mapping between TCP
78
+ # connection and request, so client disconnects propagate to the
79
+ # server as a TCP close and long-running executions can be
80
+ # cancelled reliably.
81
+ return Client(
82
+ transport=get_transport(self.connection_config, http_version="1.1")
83
+ )
84
+
85
+ def _handle_connection_error(self, err: Exception) -> None:
86
+ """
87
+ Raises a descriptive exception if the connection error was caused by
88
+ the sandbox being killed mid-request. If the sandbox is still running
89
+ (or its state can't be determined), returns so the caller can re-raise
90
+ the original error.
91
+ """
92
+ try:
93
+ running = self.is_running()
94
+ except Exception:
95
+ # The state check itself failed, so we can't tell whether the
96
+ # sandbox was killed — let the caller re-raise the original error
97
+ # instead of wrongly claiming the sandbox is gone.
98
+ return
99
+ if not running:
100
+ raise format_sandbox_killed_error() from err
101
+
102
+ @overload
103
+ def run_code(
104
+ self,
105
+ code: str,
106
+ language: Optional[RunCodeLanguage] = None,
107
+ on_stdout: Optional[OutputHandler[OutputMessage]] = None,
108
+ on_stderr: Optional[OutputHandler[OutputMessage]] = None,
109
+ on_result: Optional[OutputHandler[Result]] = None,
110
+ on_error: Optional[OutputHandler[ExecutionError]] = None,
111
+ envs: Optional[Dict[str, str]] = None,
112
+ timeout: Optional[float] = None,
113
+ request_timeout: Optional[float] = None,
114
+ ) -> Execution:
115
+ """
116
+ Runs the code for the specified language.
117
+
118
+ Specify the `language` or `context` option to run the code as a different language or in a different `Context`.
119
+ If no language is specified, Python is used.
120
+
121
+ You can reference previously defined variables, imports, and functions in the code.
122
+
123
+ :param code: Code to execute
124
+ :param language: Language to use for code execution. If not defined, the default Python context is used.
125
+ :param on_stdout: Callback for stdout messages
126
+ :param on_stderr: Callback for stderr messages
127
+ :param on_result: Callback for the `Result` object
128
+ :param on_error: Callback for the `ExecutionError` object
129
+ :param envs: Custom environment variables
130
+ :param timeout: Timeout for the code execution in **seconds**
131
+ :param request_timeout: Timeout for the request in **seconds**
132
+
133
+ :return: `Execution` result object
134
+ """
135
+ ...
136
+
137
+ @overload
138
+ def run_code(
139
+ self,
140
+ code: str,
141
+ context: Optional[Context] = None,
142
+ on_stdout: Optional[OutputHandler[OutputMessage]] = None,
143
+ on_stderr: Optional[OutputHandler[OutputMessage]] = None,
144
+ on_result: Optional[OutputHandler[Result]] = None,
145
+ on_error: Optional[OutputHandler[ExecutionError]] = None,
146
+ envs: Optional[Dict[str, str]] = None,
147
+ timeout: Optional[float] = None,
148
+ request_timeout: Optional[float] = None,
149
+ ) -> Execution:
150
+ """
151
+ Runs the code in the specified context, if not specified, the default context is used.
152
+
153
+ Specify the `language` or `context` option to run the code as a different language or in a different `Context`.
154
+
155
+ You can reference previously defined variables, imports, and functions in the code.
156
+
157
+ :param code: Code to execute
158
+ :param context: Concrete context to run the code in. If not specified, the default context for the language is used. It's mutually exclusive with the language.
159
+ :param on_stdout: Callback for stdout messages
160
+ :param on_stderr: Callback for stderr messages
161
+ :param on_result: Callback for the `Result` object
162
+ :param on_error: Callback for the `ExecutionError` object
163
+ :param envs: Custom environment variables
164
+ :param timeout: Timeout for the code execution in **seconds**
165
+ :param request_timeout: Timeout for the request in **seconds**
166
+
167
+ :return: `Execution` result object
168
+ """
169
+ ...
170
+
171
+ def run_code(
172
+ self,
173
+ code: str,
174
+ language: Optional[str] = None,
175
+ context: Optional[Context] = None,
176
+ on_stdout: Optional[OutputHandler[OutputMessage]] = None,
177
+ on_stderr: Optional[OutputHandler[OutputMessage]] = None,
178
+ on_result: Optional[OutputHandler[Result]] = None,
179
+ on_error: Optional[OutputHandler[ExecutionError]] = None,
180
+ envs: Optional[Dict[str, str]] = None,
181
+ timeout: Optional[float] = None,
182
+ request_timeout: Optional[float] = None,
183
+ ) -> Execution:
184
+ logger.debug(f"Executing code {code}")
185
+
186
+ if language and context:
187
+ raise InvalidArgumentException(
188
+ "You can provide context or language, but not both at the same time."
189
+ )
190
+
191
+ timeout = None if timeout == 0 else (timeout or DEFAULT_TIMEOUT)
192
+ request_timeout = request_timeout or self.connection_config.request_timeout
193
+ context_id = context.id if context else None
194
+
195
+ try:
196
+ headers: Dict[str, str] = {
197
+ "Content-Type": "application/json",
198
+ "E2b-Sandbox-Id": self.sandbox_id,
199
+ "E2b-Sandbox-Port": str(JUPYTER_PORT),
200
+ }
201
+ if self._envd_access_token:
202
+ headers["X-Access-Token"] = self._envd_access_token
203
+ if self.traffic_access_token:
204
+ headers["E2B-Traffic-Access-Token"] = self.traffic_access_token
205
+
206
+ with self._client.stream(
207
+ "POST",
208
+ f"{self._jupyter_url}/execute",
209
+ json={
210
+ "code": code,
211
+ "context_id": context_id,
212
+ "language": language,
213
+ "env_vars": envs,
214
+ },
215
+ headers=headers,
216
+ # `timeout` bounds the execution, `request_timeout` only the
217
+ # connect. Every non-connect phase must carry `timeout`: the
218
+ # SDK's pyqwest-backed transport collapses the per-phase
219
+ # timeouts into a single whole-request deadline and takes the
220
+ # longest of them, so leaving `request_timeout` on the write
221
+ # and pool phases would raise the floor to
222
+ # `max(timeout, request_timeout)` and silently ignore any
223
+ # `timeout` shorter than it. This matches the JS SDK, which
224
+ # aborts the execution on a `timeout`-long timer.
225
+ # `timeout=0` disables the deadline entirely; the transport
226
+ # still bounds connect on its own.
227
+ timeout=(
228
+ httpx.Timeout(timeout, connect=request_timeout)
229
+ if timeout is not None
230
+ else httpx.Timeout(None)
231
+ ),
232
+ ) as response:
233
+ err = extract_exception(response)
234
+ if err:
235
+ raise err
236
+
237
+ execution = Execution()
238
+
239
+ for line in response.iter_lines():
240
+ parse_output(
241
+ execution,
242
+ line,
243
+ on_stdout=on_stdout,
244
+ on_stderr=on_stderr,
245
+ on_result=on_result,
246
+ on_error=on_error,
247
+ )
248
+
249
+ return execution
250
+ except httpx.ReadTimeout:
251
+ raise format_execution_timeout_error()
252
+ except httpx.TimeoutException:
253
+ raise format_request_timeout_error()
254
+ except (httpx.ReadError, httpx.RemoteProtocolError) as err:
255
+ self._handle_connection_error(err)
256
+ raise
257
+
258
+ def create_code_context(
259
+ self,
260
+ cwd: Optional[str] = None,
261
+ language: Optional[RunCodeLanguage] = None,
262
+ request_timeout: Optional[float] = None,
263
+ ) -> Context:
264
+ """
265
+ Creates a new context to run code in.
266
+
267
+ :param cwd: Set the current working directory for the context, defaults to `/home/user`
268
+ :param language: Language of the context. If not specified, defaults to Python
269
+ :param request_timeout: Timeout for the request in **milliseconds**
270
+
271
+ :return: Context object
272
+ """
273
+ logger.debug(f"Creating new {language} context")
274
+
275
+ data = {}
276
+ if language:
277
+ data["language"] = language
278
+ if cwd:
279
+ data["cwd"] = cwd
280
+
281
+ try:
282
+ headers: Dict[str, str] = {
283
+ "Content-Type": "application/json",
284
+ "E2b-Sandbox-Id": self.sandbox_id,
285
+ "E2b-Sandbox-Port": str(JUPYTER_PORT),
286
+ }
287
+ if self.traffic_access_token:
288
+ headers["E2B-Traffic-Access-Token"] = self.traffic_access_token
289
+
290
+ response = self._client.post(
291
+ f"{self._jupyter_url}/contexts",
292
+ json=data,
293
+ headers=headers,
294
+ timeout=request_timeout or self.connection_config.request_timeout,
295
+ )
296
+
297
+ err = extract_exception(response)
298
+ if err:
299
+ raise err
300
+
301
+ data = response.json()
302
+ return Context.from_json(data)
303
+ except httpx.TimeoutException:
304
+ raise format_request_timeout_error()
305
+ except (httpx.ReadError, httpx.RemoteProtocolError) as err:
306
+ self._handle_connection_error(err)
307
+ raise
308
+
309
+ def remove_code_context(
310
+ self,
311
+ context: Union[Context, str],
312
+ ) -> None:
313
+ """
314
+ Removes a context.
315
+
316
+ :param context: Context to remove. Can be a Context object or a context ID string.
317
+
318
+ :return: None
319
+ """
320
+ context_id = context.id if isinstance(context, Context) else context
321
+
322
+ try:
323
+ headers: Dict[str, str] = {
324
+ "Content-Type": "application/json",
325
+ "E2b-Sandbox-Id": self.sandbox_id,
326
+ "E2b-Sandbox-Port": str(JUPYTER_PORT),
327
+ }
328
+ if self.traffic_access_token:
329
+ headers["E2B-Traffic-Access-Token"] = self.traffic_access_token
330
+
331
+ response = self._client.delete(
332
+ f"{self._jupyter_url}/contexts/{context_id}",
333
+ headers=headers,
334
+ timeout=self.connection_config.request_timeout,
335
+ )
336
+
337
+ err = extract_exception(response)
338
+ if err:
339
+ raise err
340
+ except httpx.TimeoutException:
341
+ raise format_request_timeout_error()
342
+ except (httpx.ReadError, httpx.RemoteProtocolError) as err:
343
+ self._handle_connection_error(err)
344
+ raise
345
+
346
+ def list_code_contexts(self) -> List[Context]:
347
+ """
348
+ List all contexts.
349
+
350
+ :return: List of contexts.
351
+ """
352
+ try:
353
+ headers: Dict[str, str] = {
354
+ "Content-Type": "application/json",
355
+ "E2b-Sandbox-Id": self.sandbox_id,
356
+ "E2b-Sandbox-Port": str(JUPYTER_PORT),
357
+ }
358
+ if self.traffic_access_token:
359
+ headers["E2B-Traffic-Access-Token"] = self.traffic_access_token
360
+
361
+ response = self._client.get(
362
+ f"{self._jupyter_url}/contexts",
363
+ headers=headers,
364
+ timeout=self.connection_config.request_timeout,
365
+ )
366
+
367
+ err = extract_exception(response)
368
+ if err:
369
+ raise err
370
+
371
+ data = response.json()
372
+ return [Context.from_json(context_data) for context_data in data]
373
+ except httpx.TimeoutException:
374
+ raise format_request_timeout_error()
375
+ except (httpx.ReadError, httpx.RemoteProtocolError) as err:
376
+ self._handle_connection_error(err)
377
+ raise
378
+
379
+ def restart_code_context(
380
+ self,
381
+ context: Union[Context, str],
382
+ ) -> None:
383
+ """
384
+ Restart a context.
385
+
386
+ :param context: Context to restart. Can be a Context object or a context ID string.
387
+
388
+ :return: None
389
+ """
390
+ context_id = context.id if isinstance(context, Context) else context
391
+
392
+ try:
393
+ headers: Dict[str, str] = {
394
+ "Content-Type": "application/json",
395
+ "E2b-Sandbox-Id": self.sandbox_id,
396
+ "E2b-Sandbox-Port": str(JUPYTER_PORT),
397
+ }
398
+ if self.traffic_access_token:
399
+ headers["E2B-Traffic-Access-Token"] = self.traffic_access_token
400
+
401
+ response = self._client.post(
402
+ f"{self._jupyter_url}/contexts/{context_id}/restart",
403
+ headers=headers,
404
+ timeout=self.connection_config.request_timeout,
405
+ )
406
+
407
+ err = extract_exception(response)
408
+ if err:
409
+ raise err
410
+ except httpx.TimeoutException:
411
+ raise format_request_timeout_error()
412
+ except (httpx.ReadError, httpx.RemoteProtocolError) as err:
413
+ self._handle_connection_error(err)
414
+ raise
@@ -0,0 +1,3 @@
1
+ DEFAULT_TEMPLATE = "code-interpreter-v1"
2
+ JUPYTER_PORT = 49999
3
+ DEFAULT_TIMEOUT = 300
@@ -0,0 +1,20 @@
1
+ from boxfab import TimeoutException
2
+
3
+
4
+ def format_request_timeout_error() -> Exception:
5
+ return TimeoutException(
6
+ "Request timed out — the 'request_timeout' option can be used to increase this timeout",
7
+ )
8
+
9
+
10
+ def format_execution_timeout_error() -> Exception:
11
+ return TimeoutException(
12
+ "Execution timed out — the 'timeout' option can be used to increase this timeout",
13
+ )
14
+
15
+
16
+ def format_sandbox_killed_error() -> Exception:
17
+ return TimeoutException(
18
+ "The sandbox was killed while the request was in progress. This can happen when the sandbox times out or is killed manually. "
19
+ "You can modify the sandbox timeout by passing 'timeout' when starting the sandbox or calling '.set_timeout' on the sandbox with the desired timeout",
20
+ )