code-sandboxes 0.0.2__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.
code_sandboxes/base.py ADDED
@@ -0,0 +1,572 @@
1
+ # Copyright (c) 2025-2026 Datalayer, Inc.
2
+ #
3
+ # BSD 3-Clause License
4
+
5
+ """Abstract base class for code sandboxes.
6
+
7
+ Inspired by E2B Code Interpreter and Modal Sandbox APIs.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from abc import ABC, abstractmethod
13
+ from typing import Any, AsyncIterator, Iterator, Literal, Optional, Union
14
+ import time
15
+ import uuid
16
+
17
+ from .commands import CommandResult, ProcessHandle, SandboxCommands
18
+ from .filesystem import FileInfo, SandboxFileHandle, SandboxFilesystem
19
+ from .models import (
20
+ Context,
21
+ Execution,
22
+ ExecutionError,
23
+ OutputHandler,
24
+ OutputMessage,
25
+ ResourceConfig,
26
+ Result,
27
+ SandboxConfig,
28
+ SandboxInfo,
29
+ SandboxStatus,
30
+ )
31
+
32
+
33
+ SandboxVariant = Literal["local-eval", "local-docker", "datalayer-runtime"]
34
+
35
+
36
+
37
+ class Sandbox(ABC):
38
+ """Abstract base class for code execution sandboxes.
39
+
40
+ A sandbox provides a safe, isolated environment for executing code.
41
+ Different implementations provide different isolation levels:
42
+ - local-eval: Simple Python exec() based, minimal isolation
43
+ - local-docker: Docker container based, good isolation
44
+ - datalayer-runtime: Cloud-based Datalayer runtime, full isolation
45
+
46
+ Features inspired by E2B and Modal:
47
+ - Code execution with result streaming
48
+ - Filesystem operations (read, write, list, upload, download)
49
+ - Command execution (run, exec, spawn)
50
+ - Context management for state persistence
51
+ - Snapshot support (for datalayer-runtime)
52
+ - GPU/resource configuration (for datalayer-runtime)
53
+ - Timeout and lifecycle management
54
+
55
+ Example:
56
+ with Sandbox.create(variant="datalayer-runtime") as sandbox:
57
+ # Execute code
58
+ result = sandbox.run_code("x = 1 + 1")
59
+ result = sandbox.run_code("print(x)") # prints 2
60
+
61
+ # Use filesystem
62
+ sandbox.files.write("/data/test.txt", "Hello")
63
+ content = sandbox.files.read("/data/test.txt")
64
+
65
+ # Run commands
66
+ result = sandbox.commands.run("ls -la")
67
+
68
+ Attributes:
69
+ config: The sandbox configuration.
70
+ info: Information about the running sandbox.
71
+ files: Filesystem operations.
72
+ commands: Command execution operations.
73
+ """
74
+
75
+ def __init__(self, config: Optional[SandboxConfig] = None):
76
+ """Initialize sandbox with configuration.
77
+
78
+ Args:
79
+ config: Sandbox configuration. Uses defaults if not provided.
80
+ """
81
+ self.config = config or SandboxConfig()
82
+ self._info: Optional[SandboxInfo] = None
83
+ self._started = False
84
+ self._default_context: Optional[Context] = None
85
+ self._files: Optional[SandboxFilesystem] = None
86
+ self._commands: Optional[SandboxCommands] = None
87
+ self._tags: dict[str, str] = {}
88
+ self._created_at: float = 0.0
89
+
90
+ @property
91
+ def info(self) -> Optional[SandboxInfo]:
92
+ """Get information about this sandbox."""
93
+ return self._info
94
+
95
+ @property
96
+ def is_started(self) -> bool:
97
+ """Check if sandbox has been started."""
98
+ return self._started
99
+
100
+ @property
101
+ def sandbox_id(self) -> Optional[str]:
102
+ """Get the sandbox ID."""
103
+ return self._info.id if self._info else None
104
+
105
+ @property
106
+ def files(self) -> SandboxFilesystem:
107
+ """Get filesystem operations interface.
108
+
109
+ Returns:
110
+ SandboxFilesystem for file operations.
111
+ """
112
+ if self._files is None:
113
+ self._files = SandboxFilesystem(self)
114
+ return self._files
115
+
116
+ @property
117
+ def commands(self) -> SandboxCommands:
118
+ """Get command execution interface.
119
+
120
+ Returns:
121
+ SandboxCommands for running terminal commands.
122
+ """
123
+ if self._commands is None:
124
+ self._commands = SandboxCommands(self)
125
+ return self._commands
126
+
127
+ @property
128
+ def tags(self) -> dict[str, str]:
129
+ """Get sandbox tags (key-value pairs for metadata)."""
130
+ return self._tags.copy()
131
+
132
+ def set_tags(self, tags: dict[str, str]) -> None:
133
+ """Set sandbox tags.
134
+
135
+ Tags can be used to filter and organize sandboxes.
136
+ Similar to Modal's sandbox tagging feature.
137
+
138
+ Args:
139
+ tags: Dictionary of tag names to values.
140
+ """
141
+ self._tags.update(tags)
142
+
143
+
144
+ @classmethod
145
+ def create(
146
+ cls,
147
+ variant: SandboxVariant = "datalayer-runtime",
148
+ config: Optional[SandboxConfig] = None,
149
+ timeout: Optional[float] = None,
150
+ name: Optional[str] = None,
151
+ environment: Optional[str] = None,
152
+ gpu: Optional[str] = None,
153
+ cpu: Optional[float] = None,
154
+ memory: Optional[int] = None,
155
+ env: Optional[dict[str, str]] = None,
156
+ tags: Optional[dict[str, str]] = None,
157
+ **kwargs,
158
+ ) -> "Sandbox":
159
+ """Factory method to create a sandbox of the specified variant.
160
+
161
+ This method provides a simple interface similar to E2B and Modal:
162
+ - E2B: Sandbox.create(timeout=60_000)
163
+ - Modal: Sandbox.create(gpu="T4", timeout=300)
164
+
165
+ Args:
166
+ variant: The type of sandbox to create.
167
+ - "local-eval": Simple Python exec() based, minimal isolation
168
+ - "local-docker": Docker container based (requires Docker)
169
+ - "datalayer-runtime": Cloud-based Datalayer runtime (default)
170
+ config: Optional full configuration object (overrides individual params).
171
+ timeout: Default timeout for code execution in seconds.
172
+ name: Optional name for the sandbox.
173
+ environment: Runtime environment (e.g., "python-simple-env", "python-gpu-env").
174
+ gpu: GPU type to use (e.g., "T4", "A100", "H100"). Only for datalayer-runtime.
175
+ cpu: CPU cores to allocate.
176
+ memory: Memory limit in MB.
177
+ env: Environment variables to set in the sandbox.
178
+ tags: Metadata tags for the sandbox.
179
+ **kwargs: Additional arguments passed to the sandbox constructor.
180
+
181
+ Returns:
182
+ A Sandbox instance of the specified variant.
183
+
184
+ Raises:
185
+ ValueError: If the variant is not supported.
186
+
187
+ Example:
188
+ # Simple usage
189
+ sandbox = Sandbox.create()
190
+
191
+ # With timeout (like E2B)
192
+ sandbox = Sandbox.create(timeout=60)
193
+
194
+ # With GPU (like Modal)
195
+ sandbox = Sandbox.create(gpu="T4", environment="python-gpu-env")
196
+
197
+ # Local development
198
+ sandbox = Sandbox.create(variant="local-eval")
199
+ """
200
+ # Build config from individual parameters if not provided
201
+ if config is None:
202
+ config = SandboxConfig(
203
+ timeout=timeout or 30.0,
204
+ environment=environment or "python-simple-env",
205
+ memory_limit=memory * 1024 * 1024 if memory else None,
206
+ cpu_limit=cpu,
207
+ env_vars=env or {},
208
+ gpu=gpu,
209
+ name=name,
210
+ )
211
+
212
+ from .local.eval_sandbox import LocalEvalSandbox
213
+
214
+ if variant == "local-eval":
215
+ sandbox = LocalEvalSandbox(config=config, **kwargs)
216
+ elif variant == "local-docker":
217
+ # Import here to avoid circular imports
218
+ from .local.docker_sandbox import LocalDockerSandbox
219
+
220
+ sandbox = LocalDockerSandbox(config=config, **kwargs)
221
+ elif variant == "datalayer-runtime":
222
+ from .remote.datalayer_sandbox import DatalayerSandbox
223
+
224
+ sandbox = DatalayerSandbox(config=config, **kwargs)
225
+ else:
226
+ raise ValueError(
227
+ f"Unknown sandbox variant: {variant}. "
228
+ f"Supported variants: local-eval, local-docker, datalayer-runtime"
229
+ )
230
+
231
+ # Set tags if provided
232
+ if tags:
233
+ sandbox.set_tags(tags)
234
+
235
+ return sandbox
236
+
237
+ @classmethod
238
+ def from_id(cls, sandbox_id: str, **kwargs) -> "Sandbox":
239
+ """Retrieve an existing sandbox by its ID.
240
+
241
+ Similar to Modal's Sandbox.from_id() method.
242
+
243
+ Args:
244
+ sandbox_id: The unique identifier of the sandbox.
245
+ **kwargs: Additional arguments.
246
+
247
+ Returns:
248
+ A Sandbox instance connected to the existing sandbox.
249
+
250
+ Raises:
251
+ SandboxNotFoundError: If no sandbox with the given ID exists.
252
+ """
253
+ # This is primarily for datalayer-runtime
254
+ from .remote.datalayer_sandbox import DatalayerSandbox
255
+
256
+ return DatalayerSandbox.from_id(sandbox_id, **kwargs)
257
+
258
+ @classmethod
259
+ def list(
260
+ cls,
261
+ tags: Optional[dict[str, str]] = None,
262
+ **kwargs,
263
+ ) -> Iterator["Sandbox"]:
264
+ """List all running sandboxes.
265
+
266
+ Similar to Modal's Sandbox.list() method.
267
+
268
+ Args:
269
+ tags: Filter sandboxes by tags.
270
+ **kwargs: Additional filter arguments.
271
+
272
+ Yields:
273
+ Sandbox instances.
274
+ """
275
+ from .remote.datalayer_sandbox import DatalayerSandbox
276
+
277
+ yield from DatalayerSandbox.list_all(tags=tags, **kwargs)
278
+
279
+ def __enter__(self) -> "Sandbox":
280
+ """Context manager entry - starts the sandbox."""
281
+ self.start()
282
+ return self
283
+
284
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
285
+ """Context manager exit - stops the sandbox."""
286
+ self.stop()
287
+
288
+ async def __aenter__(self) -> "Sandbox":
289
+ """Async context manager entry."""
290
+ await self.start_async()
291
+ return self
292
+
293
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
294
+ """Async context manager exit."""
295
+ await self.stop_async()
296
+
297
+ @abstractmethod
298
+ def start(self) -> None:
299
+ """Start the sandbox.
300
+
301
+ Must be called before any code execution. Called automatically
302
+ when using the sandbox as a context manager.
303
+ """
304
+ pass
305
+
306
+ @abstractmethod
307
+ def stop(self) -> None:
308
+ """Stop the sandbox and release resources.
309
+
310
+ Called automatically when exiting the context manager.
311
+ """
312
+ pass
313
+
314
+ async def start_async(self) -> None:
315
+ """Async version of start(). Default implementation calls sync version."""
316
+ self.start()
317
+
318
+ async def stop_async(self) -> None:
319
+ """Async version of stop(). Default implementation calls sync version."""
320
+ self.stop()
321
+
322
+ @abstractmethod
323
+ def run_code(
324
+ self,
325
+ code: str,
326
+ language: str = "python",
327
+ context: Optional[Context] = None,
328
+ on_stdout: Optional[OutputHandler[OutputMessage]] = None,
329
+ on_stderr: Optional[OutputHandler[OutputMessage]] = None,
330
+ on_result: Optional[OutputHandler[Result]] = None,
331
+ on_error: Optional[OutputHandler[ExecutionError]] = None,
332
+ envs: Optional[dict[str, str]] = None,
333
+ timeout: Optional[float] = None,
334
+ ) -> Execution:
335
+ """Execute code in the sandbox.
336
+
337
+ Args:
338
+ code: The code to execute.
339
+ language: Programming language (default: "python").
340
+ context: Execution context for maintaining state. If not provided,
341
+ uses the default context.
342
+ on_stdout: Callback for stdout messages.
343
+ on_stderr: Callback for stderr messages.
344
+ on_result: Callback for results.
345
+ on_error: Callback for errors.
346
+ envs: Additional environment variables for this execution.
347
+ timeout: Timeout in seconds. Uses config default if not provided.
348
+
349
+ Returns:
350
+ Execution result containing output, results, and any errors.
351
+ """
352
+ pass
353
+
354
+ async def run_code_async(
355
+ self,
356
+ code: str,
357
+ language: str = "python",
358
+ context: Optional[Context] = None,
359
+ on_stdout: Optional[OutputHandler[OutputMessage]] = None,
360
+ on_stderr: Optional[OutputHandler[OutputMessage]] = None,
361
+ on_result: Optional[OutputHandler[Result]] = None,
362
+ on_error: Optional[OutputHandler[ExecutionError]] = None,
363
+ envs: Optional[dict[str, str]] = None,
364
+ timeout: Optional[float] = None,
365
+ ) -> Execution:
366
+ """Async version of run_code(). Default implementation calls sync version."""
367
+ return self.run_code(
368
+ code=code,
369
+ language=language,
370
+ context=context,
371
+ on_stdout=on_stdout,
372
+ on_stderr=on_stderr,
373
+ on_result=on_result,
374
+ on_error=on_error,
375
+ envs=envs,
376
+ timeout=timeout,
377
+ )
378
+
379
+ def run_code_streaming(
380
+ self,
381
+ code: str,
382
+ language: str = "python",
383
+ context: Optional[Context] = None,
384
+ envs: Optional[dict[str, str]] = None,
385
+ timeout: Optional[float] = None,
386
+ ) -> Iterator[Union[OutputMessage, Result, ExecutionError]]:
387
+ """Execute code with streaming output.
388
+
389
+ Yields output messages, results, and errors as they are produced.
390
+
391
+ Args:
392
+ code: The code to execute.
393
+ language: Programming language (default: "python").
394
+ context: Execution context for maintaining state.
395
+ envs: Additional environment variables.
396
+ timeout: Timeout in seconds.
397
+
398
+ Yields:
399
+ OutputMessage, Result, or ExecutionError objects.
400
+ """
401
+ # Default implementation: run and yield all at once
402
+ execution = self.run_code(
403
+ code=code,
404
+ language=language,
405
+ context=context,
406
+ envs=envs,
407
+ timeout=timeout,
408
+ )
409
+ for msg in execution.logs.stdout:
410
+ yield msg
411
+ for msg in execution.logs.stderr:
412
+ yield msg
413
+ for result in execution.results:
414
+ yield result
415
+ if execution.error:
416
+ yield execution.error
417
+
418
+ async def run_code_streaming_async(
419
+ self,
420
+ code: str,
421
+ language: str = "python",
422
+ context: Optional[Context] = None,
423
+ envs: Optional[dict[str, str]] = None,
424
+ timeout: Optional[float] = None,
425
+ ) -> AsyncIterator[Union[OutputMessage, Result, ExecutionError]]:
426
+ """Async version of run_code_streaming()."""
427
+ execution = await self.run_code_async(
428
+ code=code,
429
+ language=language,
430
+ context=context,
431
+ envs=envs,
432
+ timeout=timeout,
433
+ )
434
+ for msg in execution.logs.stdout:
435
+ yield msg
436
+ for msg in execution.logs.stderr:
437
+ yield msg
438
+ for result in execution.results:
439
+ yield result
440
+ if execution.error:
441
+ yield execution.error
442
+
443
+ def create_context(self, name: Optional[str] = None) -> Context:
444
+ """Create a new execution context.
445
+
446
+ A context maintains state (variables, imports, etc.) between executions.
447
+
448
+ Args:
449
+ name: Optional name for the context. Auto-generated if not provided.
450
+
451
+ Returns:
452
+ A new Context object.
453
+ """
454
+ context_id = name or str(uuid.uuid4())
455
+ return Context(id=context_id, language="python", cwd=self.config.working_dir)
456
+
457
+ def get_variable(self, name: str, context: Optional[Context] = None) -> Any:
458
+ """Get a variable from the sandbox.
459
+
460
+ Args:
461
+ name: Name of the variable to retrieve.
462
+ context: Context to get the variable from. Uses default if not provided.
463
+
464
+ Returns:
465
+ The value of the variable.
466
+
467
+ Raises:
468
+ VariableNotFoundError: If the variable doesn't exist.
469
+ """
470
+ # Default implementation using code execution
471
+ execution = self.run_code(f"__result__ = {name}", context=context)
472
+ if execution.error:
473
+ from .exceptions import VariableNotFoundError
474
+
475
+ raise VariableNotFoundError(name)
476
+ return self._get_internal_variable("__result__", context)
477
+
478
+ def set_variable(self, name: str, value: Any, context: Optional[Context] = None) -> None:
479
+ """Set a variable in the sandbox.
480
+
481
+ Args:
482
+ name: Name of the variable to set.
483
+ value: Value to assign.
484
+ context: Context to set the variable in. Uses default if not provided.
485
+ """
486
+ self._set_internal_variable(name, value, context)
487
+
488
+ def set_variables(
489
+ self, variables: dict[str, Any], context: Optional[Context] = None
490
+ ) -> None:
491
+ """Set multiple variables in the sandbox.
492
+
493
+ Args:
494
+ variables: Dictionary of variable names to values.
495
+ context: Context to set variables in. Uses default if not provided.
496
+ """
497
+ for name, value in variables.items():
498
+ self.set_variable(name, value, context)
499
+
500
+ @abstractmethod
501
+ def _get_internal_variable(self, name: str, context: Optional[Context] = None) -> Any:
502
+ """Internal method to get a variable. Must be implemented by subclasses."""
503
+ pass
504
+
505
+ @abstractmethod
506
+ def _set_internal_variable(
507
+ self, name: str, value: Any, context: Optional[Context] = None
508
+ ) -> None:
509
+ """Internal method to set a variable. Must be implemented by subclasses."""
510
+ pass
511
+
512
+ def install_packages(
513
+ self, packages: list[str], timeout: Optional[float] = None
514
+ ) -> Execution:
515
+ """Install Python packages in the sandbox.
516
+
517
+ Args:
518
+ packages: List of package names to install.
519
+ timeout: Timeout in seconds.
520
+
521
+ Returns:
522
+ Execution result from the installation.
523
+ """
524
+ install_cmd = f"import subprocess; subprocess.run(['pip', 'install'] + {packages!r}, check=True)"
525
+ return self.run_code(install_cmd, timeout=timeout or 300)
526
+
527
+ def upload_file(self, local_path: str, remote_path: str) -> None:
528
+ """Upload a file to the sandbox.
529
+
530
+ Args:
531
+ local_path: Path to the local file.
532
+ remote_path: Destination path in the sandbox.
533
+ """
534
+ with open(local_path, "rb") as f:
535
+ content = f.read()
536
+ self._write_file(remote_path, content)
537
+
538
+ def download_file(self, remote_path: str, local_path: str) -> None:
539
+ """Download a file from the sandbox.
540
+
541
+ Args:
542
+ remote_path: Path to the file in the sandbox.
543
+ local_path: Destination path on the local filesystem.
544
+ """
545
+ content = self._read_file(remote_path)
546
+ with open(local_path, "wb") as f:
547
+ f.write(content)
548
+
549
+ def _write_file(self, path: str, content: bytes) -> None:
550
+ """Write a file in the sandbox. Override in subclasses for better performance."""
551
+ import base64
552
+
553
+ encoded = base64.b64encode(content).decode("utf-8")
554
+ code = f"""
555
+ import base64
556
+ with open({path!r}, 'wb') as f:
557
+ f.write(base64.b64decode({encoded!r}))
558
+ """
559
+ self.run_code(code)
560
+
561
+ def _read_file(self, path: str) -> bytes:
562
+ """Read a file from the sandbox. Override in subclasses for better performance."""
563
+ import base64
564
+
565
+ code = f"""
566
+ import base64
567
+ with open({path!r}, 'rb') as f:
568
+ __file_content__ = base64.b64encode(f.read()).decode('utf-8')
569
+ """
570
+ self.run_code(code)
571
+ encoded = self.get_variable("__file_content__")
572
+ return base64.b64decode(encoded)