From 43b96579e557d067dda5451783e0c7059d341369 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Mon, 20 Jul 2026 10:06:38 +0200 Subject: [PATCH 01/21] client --- code_sandboxes/__init__.py | 3 + code_sandboxes/client.py | 277 +++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 code_sandboxes/client.py diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index 27899b5..ddf5028 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -52,6 +52,7 @@ """ from .base import Sandbox +from .client import CodeExecutionOutcome, CodeSandboxClient from .commands import CommandResult, ProcessHandle, SandboxCommands from .datalayer_sandbox import DatalayerSandbox from .docker_sandbox import DockerSandbox @@ -102,6 +103,8 @@ __all__ = [ # Models "CodeError", + "CodeExecutionOutcome", + "CodeSandboxClient", "CommandResult", "Context", "ContextNotFoundError", diff --git a/code_sandboxes/client.py b/code_sandboxes/client.py new file mode 100644 index 0000000..7e61c9e --- /dev/null +++ b/code_sandboxes/client.py @@ -0,0 +1,277 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""High-level, variant-agnostic client for executing code in a sandbox. + +The :class:`CodeSandboxClient` wraps any concrete :class:`~code_sandboxes.base.Sandbox` +implementation (eval, docker, jupyter, datalayer) behind a small, ergonomic +API that returns a normalized :class:`CodeExecutionOutcome`. It is meant to be +the single entry point consumers should reach for when all they need is +"run this code / command and give me the stdout, stderr and success flag", +without caring about the underlying sandbox variant. + +Example: + from code_sandboxes import CodeSandboxClient + + # Create + own the sandbox lifecycle. + with CodeSandboxClient.create(variant="jupyter", jupyter_url=url) as client: + outcome = client.execute_code("x = 1") + outcome = client.execute_code("print(x)") + print(outcome.stdout) # "1" + + # Or wrap an already-running sandbox managed elsewhere. + client = CodeSandboxClient(existing_sandbox) + outcome = await client.execute_code_async("print('hi')") + +Kubernetes / colocated-sidecar contract: + The client is deliberately variant-agnostic and performs **no** fallback of + its own. It never picks a variant and never silently spins up an ``eval`` + sandbox. When it wraps a shared/managed sandbox (e.g. agent-runtimes' + ``ManagedSandbox`` proxy over a per-pod Jupyter sidecar), every call is + delegated to that sandbox, so: + + * code and skill execution reuse the *existing* colocated Jupyter kernel + (state persists across executions), and + * a sidecar that is configured but not yet reachable fails fast — the + wrapped sandbox raises rather than degrading to an in-process ``eval``. + + Owning the sandbox lifecycle (``owns_sandbox`` / :meth:`create`) is meant + for local/standalone callers; in the pod the sandbox is owned by the + manager and the client is created with ``owns_sandbox=False``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + +from .base import Sandbox +from .commands import CommandResult +from .models import ExecutionResult, SandboxConfig, SandboxVariant + +__all__ = ["CodeExecutionOutcome", "CodeSandboxClient"] + + +@dataclass +class CodeExecutionOutcome: + """Normalized result of a code execution, independent of sandbox variant. + + This is a faithful *superset* of :class:`ExecutionResult`: it distinguishes + the same two failure levels (infrastructure vs. user-code) plus intentional + ``sys.exit()`` codes, so it can drive both plain code execution (the TUX / + ``/sandbox/execute`` endpoint) and skill-script execution + (``agent_skills.SandboxExecutor``) without losing information. + + Attributes: + success: True when the infrastructure ran the code and the code itself + raised no error, was not interrupted, and exited cleanly. + execution_ok: True when the sandbox infrastructure ran the code, even if + the user's code raised an exception. + stdout: Combined standard output text. + stderr: Combined standard error text. + results: Textual representation of rich results (display data / return + values) produced by the execution. + error: Human-readable error message when ``success`` is False, otherwise + ``None``. + execution_error: Infrastructure failure detail when ``execution_ok`` is + False (connection loss, kernel timeout, sidecar unavailable, …). + code_error: Structured user-code exception ``{name, value, traceback}`` + when the code ran but raised, otherwise ``None``. + exit_code: Exit code when the code called ``sys.exit()``; ``None`` for a + normal completion without an explicit exit. + interrupted: Whether the execution was cancelled/interrupted. + """ + + success: bool + execution_ok: bool + stdout: str = "" + stderr: str = "" + results: list[str] = field(default_factory=list) + error: Optional[str] = None + execution_error: Optional[str] = None + code_error: Optional[dict[str, str]] = None + exit_code: Optional[int] = None + interrupted: bool = False + + @classmethod + def from_execution_result(cls, execution: ExecutionResult) -> "CodeExecutionOutcome": + """Build a normalized outcome from a raw :class:`ExecutionResult`.""" + results: list[str] = [] + for result in execution.results: + text = getattr(result, "text", None) + if text: + results.append(text) + + code_error_dict: Optional[dict[str, str]] = None + if execution.code_error is not None: + code_error = execution.code_error + code_error_dict = { + "name": getattr(code_error, "name", None) or "Error", + "value": getattr(code_error, "value", None) or "", + "traceback": getattr(code_error, "traceback", None) or "", + } + + exit_code = getattr(execution, "exit_code", None) + + error: Optional[str] = None + if not execution.execution_ok: + error = execution.execution_error or "Sandbox infrastructure failure" + elif code_error_dict is not None: + name = code_error_dict["name"] + value = code_error_dict["value"] + error = f"{name}: {value}".strip().rstrip(":") + elif execution.interrupted: + error = "Execution interrupted" + elif exit_code is not None and exit_code != 0: + error = f"Script exited with code {exit_code}" + + return cls( + success=execution.success, + execution_ok=execution.execution_ok, + stdout=execution.logs.stdout_text, + stderr=execution.logs.stderr_text, + results=results, + error=error, + execution_error=execution.execution_error, + code_error=code_error_dict, + exit_code=exit_code, + interrupted=execution.interrupted, + ) + + +class CodeSandboxClient: + """Variant-agnostic facade over a :class:`Sandbox`. + + The client owns no variant-specific logic: it simply delegates to the + wrapped sandbox and normalizes the result. Callers can either let the + client create and manage the sandbox (:meth:`create`) or hand it an + existing sandbox instance that is managed elsewhere. + """ + + def __init__(self, sandbox: Sandbox, *, owns_sandbox: bool = False) -> None: + """Wrap an existing sandbox. + + Args: + sandbox: The concrete sandbox to delegate execution to. + owns_sandbox: When True the client will stop the sandbox on + :meth:`close` / context-manager exit. Defaults to False so that + wrapping a shared/managed sandbox never shuts it down. + """ + self._sandbox = sandbox + self._owns_sandbox = owns_sandbox + + @classmethod + def create( + cls, + variant: SandboxVariant | str = SandboxVariant.EVAL, + config: Optional[SandboxConfig] = None, + **kwargs, + ) -> "CodeSandboxClient": + """Create a client that owns a freshly created sandbox of ``variant``. + + Accepts the same keyword arguments as :meth:`Sandbox.create`. + """ + sandbox = Sandbox.create(variant=variant, config=config, **kwargs) + return cls(sandbox, owns_sandbox=True) + + @property + def sandbox(self) -> Sandbox: + """The wrapped sandbox instance.""" + return self._sandbox + + @property + def variant(self) -> Optional[SandboxVariant]: + """The variant of the wrapped sandbox, if known.""" + return getattr(self._sandbox.config, "variant", None) + + @property + def is_started(self) -> bool: + """Whether the wrapped sandbox has been started. + + Sandboxes that do not expose ``is_started`` (e.g. minimal duck-typed + objects that only implement ``run_code``) are treated as ready. + """ + return bool(getattr(self._sandbox, "is_started", True)) + + def start(self) -> None: + """Start the wrapped sandbox if it exposes a lifecycle and is not started.""" + start_fn = getattr(self._sandbox, "start", None) + if callable(start_fn) and not self.is_started: + start_fn() + + async def start_async(self) -> None: + """Async variant of :meth:`start`.""" + if self.is_started: + return + start_async_fn = getattr(self._sandbox, "start_async", None) + if callable(start_async_fn): + await start_async_fn() + else: + self.start() + + def close(self) -> None: + """Stop the wrapped sandbox when this client owns it.""" + stop_fn = getattr(self._sandbox, "stop", None) + if self._owns_sandbox and callable(stop_fn) and self.is_started: + stop_fn() + + async def close_async(self) -> None: + """Async variant of :meth:`close`.""" + if not (self._owns_sandbox and self.is_started): + return + stop_async_fn = getattr(self._sandbox, "stop_async", None) + if callable(stop_async_fn): + await stop_async_fn() + else: + self.close() + + def execute_code( + self, + code: str, + language: str = "python", + timeout: Optional[float] = None, + envs: Optional[dict[str, str]] = None, + ) -> CodeExecutionOutcome: + """Execute code and return a normalized outcome. + + The sandbox is started automatically if needed. + """ + self.start() + execution = self._sandbox.run_code( + code, language=language, timeout=timeout, envs=envs + ) + return CodeExecutionOutcome.from_execution_result(execution) + + async def execute_code_async( + self, + code: str, + language: str = "python", + timeout: Optional[float] = None, + envs: Optional[dict[str, str]] = None, + ) -> CodeExecutionOutcome: + """Async variant of :meth:`execute_code`.""" + await self.start_async() + execution = await self._sandbox.run_code_async( + code, language=language, timeout=timeout, envs=envs + ) + return CodeExecutionOutcome.from_execution_result(execution) + + def run_command(self, command: str, timeout: Optional[float] = None) -> CommandResult: + """Run a shell command inside the sandbox.""" + self.start() + return self._sandbox.commands.run(command, timeout=timeout) + + def __enter__(self) -> "CodeSandboxClient": + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() + + async def __aenter__(self) -> "CodeSandboxClient": + await self.start_async() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + await self.close_async() From 0264c6259cb5d71ab32a1db59d42991ef7316aae Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Mon, 20 Jul 2026 14:46:36 +0200 Subject: [PATCH 02/21] bump --- code_sandboxes/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code_sandboxes/__version__.py b/code_sandboxes/__version__.py index c3d50cb..176ab83 100644 --- a/code_sandboxes/__version__.py +++ b/code_sandboxes/__version__.py @@ -3,4 +3,4 @@ """Code Sandboxes.""" -__version__ = "0.0.13" +__version__ = "0.0.14" From 94b3737f679929ed1951ea61d661c2bb5be24aa5 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 14:30:50 +0200 Subject: [PATCH 03/21] more sandbox variants --- README.md | 384 +++++++++++++++++++++++++++++-- code_sandboxes/__init__.py | 11 + code_sandboxes/__version__.py | 2 +- code_sandboxes/base.py | 14 +- code_sandboxes/colab_sandbox.py | 257 +++++++++++++++++++++ code_sandboxes/modal_sandbox.py | 255 ++++++++++++++++++++ code_sandboxes/models.py | 3 + code_sandboxes/monty_sandbox.py | 235 +++++++++++++++++++ docs/docs/installation/index.mdx | 18 +- docs/docs/sandboxes/index.mdx | 219 ++++++++++++++++-- pyproject.toml | 13 +- 11 files changed, 1369 insertions(+), 42 deletions(-) create mode 100644 code_sandboxes/colab_sandbox.py create mode 100644 code_sandboxes/modal_sandbox.py create mode 100644 code_sandboxes/monty_sandbox.py diff --git a/README.md b/README.md index 10a6220..0221de4 100644 --- a/README.md +++ b/README.md @@ -25,33 +25,44 @@ This package provides a unified API for code execution with features like: ## Sandbox Variants -Four variants are available: - -Canonical variant names are `eval`, `docker`, `jupyter`, and -`datalayer`. The older `local-*` names are no longer supported. - -| Variant | Isolation | Use Case | -| ----------- | -------------------------- | ------------------------- | -| `eval` | None (Python exec) | Development, testing | -| `docker` | Container (Jupyter Server) | isolated execution | -| `jupyter` | Process (Jupyter kernel) | persistent state | -| `datalayer` | Cloud VM | Production, GPU workloads | +Seven variants are available. Canonical variant names are `jupyter`, `docker`, +`eval`, `monty`, `colab`, `modal`, and `datalayer`. The older `local-*` names +are no longer supported. + +| Variant | Isolation | Use Case | +| ----------- | ---------------------------- | --------------------------------- | +| `jupyter` | Process (Jupyter kernel) | Persistent state, local/remote | +| `docker` | Container (Jupyter Server) | Local isolated execution | +| `eval` | None (Python exec) | Development, testing | +| `monty` | In-process secure interpreter | Fast, safe LLM snippets | +| `colab` | Google Colab runtime | Free hosted GPU/CPU kernels | +| `modal` | Modal cloud container | On-demand isolated cloud compute | +| `datalayer` | Cloud VM | Production, GPU workloads | + +See [Backend Setup Guides](#backend-setup-guides) below for per-variant +installation, credentials, and usage. ## Module Layout Sandbox implementations are exposed as top-level modules: -- `code_sandboxes.eval_sandbox` - `code_sandboxes.jupyter_sandbox` - `code_sandboxes.docker_sandbox` +- `code_sandboxes.eval_sandbox` +- `code_sandboxes.monty_sandbox` +- `code_sandboxes.colab_sandbox` +- `code_sandboxes.modal_sandbox` - `code_sandboxes.datalayer_sandbox` Example direct imports: ```python -from code_sandboxes.eval_sandbox import EvalSandbox from code_sandboxes.jupyter_sandbox import JupyterSandbox from code_sandboxes.docker_sandbox import DockerSandbox +from code_sandboxes.eval_sandbox import EvalSandbox +from code_sandboxes.monty_sandbox import MontySandbox +from code_sandboxes.colab_sandbox import ColabSandbox +from code_sandboxes.modal_sandbox import ModalSandbox from code_sandboxes.datalayer_sandbox import DatalayerSandbox ``` @@ -67,6 +78,15 @@ pip install code-sandboxes[datalayer] # With Docker support pip install code-sandboxes[docker] +# With Google Colab support +pip install code-sandboxes[colab] + +# With Monty (secure in-process interpreter) support +pip install code-sandboxes[monty] + +# With Modal cloud sandbox support +pip install code-sandboxes[modal] + # All features pip install code-sandboxes[all] ``` @@ -195,6 +215,344 @@ with Sandbox.create() as sandbox: ) ``` +## Backend Setup Guides + +Each backend has its own installation, credential, and parameter requirements. +Select a backend by passing `variant=...` to `Sandbox.create()`. The sections +below explain, for every variant, exactly **how to obtain the credentials and the +parameters** you need to pass. + +### 1. Jupyter Server + +Runs code out-of-process against a local or remote Jupyter Server via the Jupyter +kernel protocol (`jupyter-kernel-client`), providing process isolation and +persistent kernel state. + +**Install** (included by default): + +```bash +pip install code-sandboxes +``` + +**Parameters:** + +| Parameter | Description | +| --------- | ----------- | +| `server_url` | Jupyter Server URL (default: an auto-started local server) | +| `token` | Jupyter Server authentication token | +| `host` / `port` | Bind address when the sandbox starts its own server | +| `python_executable` | Interpreter used to launch the managed server | + +**How to obtain the `token`:** + +- If you start the server yourself, you choose the token: + ```bash + jupyter server --port 8888 --IdentityProvider.token MY_TOKEN + ``` +- For an already-running server, print the URL + token with: + ```bash + jupyter server list + # http://localhost:8888/?token=abcd1234... :: /home/you/notebooks + ``` + The `token=...` query value is your token. You can also pass the full URL as + `server_url` (the `?token=...` is parsed automatically). +- If you omit `server_url` entirely, `JupyterSandbox` **starts and manages its own + local Jupyter Server** and generates the token for you — no configuration needed. + +**Usage:** + +```python +from code_sandboxes import Sandbox + +# Connect to an existing server: +with Sandbox.create( + variant="jupyter", + server_url="http://localhost:8888", + token="MY_TOKEN", +) as sandbox: + sandbox.run_code("x = 40") + print(sandbox.run_code("x + 2").text) # 42 + +# Or let the sandbox manage a local server automatically: +with Sandbox.create(variant="jupyter") as sandbox: + print(sandbox.run_code("1 + 1").text) # 2 +``` + +### 2. Docker + +Runs a Jupyter Server inside a Docker container for local, isolated execution and +connects to it with `jupyter-kernel-client`. + +**Install:** + +```bash +pip install code-sandboxes[docker] +``` + +**Prerequisites — verify Docker is installed and running:** + +```bash +docker version # must succeed (daemon reachable) +``` + +**Build the image** used by `DockerSandbox` (default tag +`code-sandboxes-jupyter:latest`): + +```bash +docker build -t code-sandboxes-jupyter:latest -f docker/Dockerfile . +``` + +**Parameters** (no external credentials — the kernel `token` is generated +automatically): + +| Parameter | Description | +| --------- | ----------- | +| `image` | Container image to run (default `code-sandboxes-jupyter:latest`) | +| `container_name` | Optional fixed container name | +| `host` / `container_port` | Where the in-container server is exposed | +| `auto_remove` | Remove the container on stop (default `True`) | +| `workdir` | Host working directory to mount | + +**Usage:** + +```python +from code_sandboxes import Sandbox + +with Sandbox.create( + variant="docker", + image="code-sandboxes-jupyter:latest", +) as sandbox: + result = sandbox.run_code("import sys; print(sys.version)") + print(result.stdout) +``` + +### 3. Eval + +Executes code in the host process with Python's `exec()`. No isolation — intended +for development and testing only. + +**Install** (included by default): + +```bash +pip install code-sandboxes +``` + +**Credentials / parameters:** none. There is nothing to configure. + +**Usage:** + +```python +from code_sandboxes import Sandbox + +with Sandbox.create(variant="eval") as sandbox: + sandbox.run_code("x = 1 + 1") + print(sandbox.run_code("print(x)").stdout) # 2 +``` + +> ⚠️ `eval` shares memory with your process and provides no sandboxing. Never run +> untrusted code with it — use `monty`, `docker`, `modal`, or `datalayer` instead. + +### 4. Monty + +Runs code in [Monty](https://github.com/pydantic/monty), a minimal, secure Python +interpreter written in Rust (`pydantic-monty`). Monty executes a restricted +subset of Python in-process with microsecond startup and no access to the host +filesystem, environment, or network unless explicitly granted. Ideal for short, +LLM-generated snippets. Session state persists across `run_code` calls. + +**Install:** + +```bash +pip install code-sandboxes[monty] +``` + +**Credentials / parameters:** none required (fully local, in-process). Optional +constructor parameters on `MontySandbox`: + +| Parameter | How to obtain / when to use | +| --------- | --------------------------- | +| `type_check` | Set `True` to type-check code before running it | +| `type_check_stubs` | Provide type stub definitions when `type_check` is enabled | +| `external_functions` | Dict of `{name: callable}` host functions the code may call | +| `limits` | Monty `ResourceLimits` mapping (memory, stack depth, time) | + +**Usage:** + +```python +from code_sandboxes import Sandbox + +with Sandbox.create(variant="monty") as sandbox: + sandbox.run_code("x = 21") + print(sandbox.run_code("x * 2").text) # 42 + +# Expose host callables and enable type checking: +from code_sandboxes.monty_sandbox import MontySandbox + +sandbox = MontySandbox( + type_check=True, + external_functions={"now": lambda: "2026-01-01"}, +) +sandbox.start() +sandbox.run_code("print(now())") +``` + +> Monty supports only a subset of Python — third-party libraries and rich display +> outputs are not available. + +### 5. Google Colab + +Runs code against a Google Colab runtime. Colab exposes a Jupyter-compatible +kernel behind an authenticating proxy, so this variant connects using +`jupyter-kernel-client`'s `ColabKernelClient`. + +**Install:** + +```bash +pip install code-sandboxes[colab] +``` + +**Parameters:** + +| Parameter | Description | +| --------- | ----------- | +| `server_url` | The Colab runtime proxy/tunnel URL | +| `kernel_id` | The assigned kernel identifier | +| `proxy_token` | The `colab-runtime-proxy-token` value | + +**How to obtain these values** — they are the pieces of the WebSocket URL that +Colab's own frontend uses to reach your assigned runtime: + +``` +wss:///api/kernels//channels?session_id=<...>&colab-runtime-proxy-token=&colab-client-agent=web +``` + +Read them from your browser's developer tools: + +1. Open your notebook on [colab.research.google.com](https://colab.research.google.com) + and **connect to a runtime** (*Runtime → Connect*, or run any cell). +2. Open DevTools (`F12`) → **Network** tab, select the **WS** filter (or type + `kernels`), then run a cell to trigger kernel traffic. +3. Click the `.../api/kernels//channels?...` request and read off: + - **`server_url`** — scheme + host *before* `/api/kernels` (change `wss://` to + `https://`). Colab assigns a per-session host such as + `https://8080-m-s-kkb-...-d.us-east1-0.prod.colab.dev`; there is usually **no** + `/tun/m/...` path segment. + - **`kernel_id`** — the UUID segment right after `/api/kernels/`. + - **`proxy_token`** — the `colab-runtime-proxy-token` query parameter (same + value as the `X-Colab-Runtime-Proxy-Token` request header). Ignore the + `session_id` and `colab-client-agent` parameters. + +The programmatic "runtime assignment API" is the internal endpoint Colab's +frontend calls (authenticated with your Google session); it is not an officially +published public API, so the DevTools method above is the practical approach. The +values are tied to your Colab session and are short-lived — refresh them after the +runtime is reassigned or reconnected. + +**Usage:** + +```python +from code_sandboxes import Sandbox + +with Sandbox.create( + variant="colab", + server_url="https://8080-m-s-kkb-...-d.us-east1-0.prod.colab.dev", + kernel_id="c9bba548-3995-4f26-8e1a-7b8fbb10c578", + proxy_token="eyJhbGci....", +) as sandbox: + sandbox.run_code("x = 40") + print(sandbox.run_code("x + 2").text) # 42 +``` + +### 6. Modal + +Runs code in a [Modal](https://modal.com/docs/guide) cloud sandbox, providing +fully isolated, on-demand containers with configurable images and secrets. + +**Install:** + +```bash +pip install code-sandboxes[modal] +``` + +**How to obtain Modal credentials:** + +1. Create a free account at [modal.com](https://modal.com). +2. Authenticate the CLI — this opens a browser and writes credentials to + `~/.modal.toml`: + ```bash + modal token new + ``` +3. Alternatively, create a token in the Modal dashboard + (**Settings → API Tokens**) and export it as environment variables: + + | Environment variable | Description | + | -------------------- | ----------- | + | `MODAL_TOKEN_ID` | Modal token id (starts with `ak-`) | + | `MODAL_TOKEN_SECRET` | Modal token secret (starts with `as-`) | + +**Parameters:** `app_name`, `image` (a prebuilt `modal.Image`), `pip_packages` +(extra packages for the default image), `python_executable`. + +**Usage:** + +```python +from code_sandboxes import Sandbox + +with Sandbox.create( + variant="modal", + pip_packages=["numpy"], +) as sandbox: + result = sandbox.run_code("import numpy as np; print(np.arange(3).sum())") + print(result.stdout) # "3" +``` + +> Each `run_code` call runs in a fresh `python -c` process, so state does not +> persist across calls. Use a single multi-statement snippet when you need shared +> state. + +### 7. Datalayer + +Cloud-based execution with full isolation, GPU support, snapshots, and +persistence via the [Datalayer](https://datalayer.ai) runtime. + +**Install:** + +```bash +pip install code-sandboxes[datalayer] +``` + +**How to obtain Datalayer credentials:** + +1. Create an account at [datalayer.ai](https://datalayer.ai). +2. Generate an API token from your account settings (**IAM → Tokens / API Keys**). +3. Export it (or pass it as the `token` parameter): + + | Environment variable | Description | + | -------------------- | ----------- | + | `DATALAYER_API_KEY` | API key for Datalayer runtime authentication | + | `DATALAYER_RUN_URL` | Custom Datalayer service URL (optional, for self-hosted) | + +**Parameters:** `token` (defaults to `DATALAYER_API_KEY`), `run_url`, +`snapshot_name`, plus creation options like `environment`, `gpu`, `cpu`, `memory`. + +**Usage:** + +```python +import os +from code_sandboxes import Sandbox + +os.environ["DATALAYER_API_KEY"] = "your-datalayer-token" + +with Sandbox.create( + variant="datalayer", + gpu="A100", + environment="python-gpu-env", + timeout=300, +) as sandbox: + sandbox.run_code("import torch") + print(sandbox.run_code("print(torch.cuda.is_available())").stdout) +``` + ## API Reference ### Sandbox.create() diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index ddf5028..d8784f9 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -9,11 +9,16 @@ sandboxes (in-process execution): - EvalSandbox: Simple Python exec() based, for development/testing + - MontySandbox: Minimal secure Python interpreter (pydantic-monty) Remote sandboxes (out-of-process execution via Jupyter kernel protocol): - DockerSandbox: Docker container based, good isolation - JupyterSandbox: Jupyter Server with persistent kernel state - DatalayerSandbox: Cloud-based Datalayer runtime, full isolation + - ColabSandbox: Google Colab runtime, connects to an assigned kernel + +Cloud container sandboxes: + - ModalSandbox: Modal cloud containers, per-snippet process execution Features: - Code execution with streaming support @@ -53,6 +58,7 @@ from .base import Sandbox from .client import CodeExecutionOutcome, CodeSandboxClient +from .colab_sandbox import ColabSandbox from .commands import CommandResult, ProcessHandle, SandboxCommands from .datalayer_sandbox import DatalayerSandbox from .docker_sandbox import DockerSandbox @@ -80,6 +86,8 @@ SandboxFilesystem, ) from .jupyter_sandbox import JupyterSandbox +from .modal_sandbox import ModalSandbox +from .monty_sandbox import MontySandbox from .models import ( CodeError, Context, @@ -105,6 +113,7 @@ "CodeError", "CodeExecutionOutcome", "CodeSandboxClient", + "ColabSandbox", "CommandResult", "Context", "ContextNotFoundError", @@ -121,6 +130,8 @@ "JupyterSandbox", "Logs", "MIMEType", + "ModalSandbox", + "MontySandbox", "OutputHandler", "OutputMessage", "ProcessHandle", diff --git a/code_sandboxes/__version__.py b/code_sandboxes/__version__.py index 176ab83..49955d2 100644 --- a/code_sandboxes/__version__.py +++ b/code_sandboxes/__version__.py @@ -3,4 +3,4 @@ """Code Sandboxes.""" -__version__ = "0.0.14" +__version__ = "0.0.15" diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index 963bdc1..0655991 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -262,11 +262,23 @@ def create( from .datalayer_sandbox import DatalayerSandbox sandbox = DatalayerSandbox(config=config, **kwargs) + elif variant_value == "colab": + from .colab_sandbox import ColabSandbox + + sandbox = ColabSandbox(config=config, **kwargs) + elif variant_value == "monty": + from .monty_sandbox import MontySandbox + + sandbox = MontySandbox(config=config, **kwargs) + elif variant_value == "modal": + from .modal_sandbox import ModalSandbox + + sandbox = ModalSandbox(config=config, **kwargs) else: raise ValueError( f"Unknown sandbox variant: {variant}. " "Supported variants: eval, docker, jupyter, " - "datalayer" + "datalayer, colab, monty, modal" ) # Set tags if provided diff --git a/code_sandboxes/colab_sandbox.py b/code_sandboxes/colab_sandbox.py new file mode 100644 index 0000000..feced17 --- /dev/null +++ b/code_sandboxes/colab_sandbox.py @@ -0,0 +1,257 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Google Colab sandbox implementation. + +This sandbox connects to an existing Google Colab runtime and executes code in +its kernel using ``jupyter-kernel-client``'s :class:`ColabKernelClient`. + +Unlike the Jupyter/Docker sandboxes, this sandbox does **not** provision a +runtime: a Colab runtime must already have been assigned (typically through a +Colab runtime assignment API), providing a ``server_url``, ``kernel_id`` and +``proxy_token``. +""" + +from __future__ import annotations + +import logging +import time +import uuid +from typing import Optional + +from .base import Sandbox +from .exceptions import SandboxConfigurationError, SandboxNotStartedError +from .models import ( + CodeError, + Context, + ExecutionResult, + Logs, + OutputHandler, + OutputMessage, + Result, + SandboxConfig, + SandboxEnvironment, + SandboxInfo, + SandboxStatus, +) + +logger = logging.getLogger(__name__) + + +class ColabSandbox(Sandbox): + """Sandbox backed by a Google Colab runtime. + + Args: + config: Optional sandbox configuration. + server_url: The Colab runtime proxy URL (from the assignment API). + kernel_id: The Colab kernel identifier to connect to. + proxy_token: The Colab runtime proxy token (from the assignment API). + client_agent: Value advertised through the ``X-Colab-Client-Agent`` header. + """ + + def __init__( + self, + config: Optional[SandboxConfig] = None, + server_url: Optional[str] = None, + kernel_id: Optional[str] = None, + proxy_token: Optional[str] = None, + client_agent: str = "code-sandboxes", + **kwargs, + ): + super().__init__(config) + # Allow configuration via SandboxConfig extras as a fallback. + extras = getattr(self.config, "model_extra", None) or {} + self._server_url = server_url or extras.get("server_url") + self._kernel_id = kernel_id or extras.get("kernel_id") + self._proxy_token = proxy_token or extras.get("proxy_token") + self._client_agent = client_agent + self._client = None + self._sandbox_id = str(uuid.uuid4()) + self._extra_kwargs = kwargs + + @classmethod + def list_environments(cls) -> list[SandboxEnvironment]: + return [ + SandboxEnvironment( + name="colab", + title="Google Colab", + language="python", + owner="google", + visibility="cloud", + burning_rate=0.0, + metadata={"variant": "colab"}, + ) + ] + + def start(self) -> None: + if self._started: + return + + if not self._server_url or not self._kernel_id or not self._proxy_token: + raise SandboxConfigurationError( + "ColabSandbox requires 'server_url', 'kernel_id' and 'proxy_token'. " + "These are typically obtained from a Colab runtime assignment API." + ) + + try: + from jupyter_kernel_client import ColabKernelClient + except ImportError as exc: + raise SandboxConfigurationError( + "jupyter-kernel-client>=0.10 is required for ColabSandbox. " + "Install it with: pip install jupyter-kernel-client" + ) from exc + + self._client = ColabKernelClient( + server_url=self._server_url, + kernel_id=self._kernel_id, + proxy_token=self._proxy_token, + client_agent=self._client_agent, + ) + self._client.start() + + self._default_context = self.create_context("default") + self._info = SandboxInfo( + id=self._sandbox_id, + variant="colab", + status=SandboxStatus.RUNNING, + created_at=time.time(), + name=self.config.name, + metadata={"server_url": self._server_url, "kernel_id": self._kernel_id}, + config=self.config, + ) + self._started = True + + def _setup_tool_caller(self) -> None: + """Keep tool calling on the client side for Colab sandboxes.""" + return + + def stop(self) -> None: + if not self._started: + return + if self._client is not None: + try: + # Do not shut down the Colab kernel; we only disconnect. + self._client.stop(shutdown_kernel=False) + except Exception: + pass + self._client = None + self._started = False + if self._info: + self._info.status = SandboxStatus.STOPPED + + def run_code( + self, + code: str, + language: str = "python", + context: Optional[Context] = None, + on_stdout: Optional[OutputHandler[OutputMessage]] = None, + on_stderr: Optional[OutputHandler[OutputMessage]] = None, + on_result: Optional[OutputHandler[Result]] = None, + on_error: Optional[OutputHandler[CodeError]] = None, + envs: Optional[dict[str, str]] = None, + timeout: Optional[float] = None, + ) -> ExecutionResult: + if not self._started or self._client is None: + raise SandboxNotStartedError() + + if language != "python": + raise ValueError(f"ColabSandbox only supports Python, got: {language}") + + started_at = time.time() + self._interrupt_requested.clear() + self._executing_event.set() + + if envs: + env_code = "\n".join(f"import os; os.environ[{k!r}] = {v!r}" for k, v in envs.items()) + code = f"{env_code}\n{code}" + + try: + reply = self._client.execute(code, timeout=timeout or self.config.timeout) + except Exception as e: + self._executing_event.clear() + was_interrupted = self._interrupt_requested.is_set() + self._interrupt_requested.clear() + return ExecutionResult( + execution_ok=not was_interrupted, + execution_error=f"Failed to execute code: {e}" if not was_interrupted else None, + started_at=started_at, + completed_at=time.time(), + context_id=context.id if context else "default", + interrupted=was_interrupted, + ) + + stdout_messages: list[OutputMessage] = [] + stderr_messages: list[OutputMessage] = [] + results: list[Result] = [] + code_error: Optional[CodeError] = None + exit_code: Optional[int] = None + + current_time = time.time() + for output in reply.get("outputs", []): + output_type = output.get("output_type") + if output_type == "stream": + name = output.get("name") + text = output.get("text", "") + for line in text.splitlines(): + msg = OutputMessage(line=line, timestamp=current_time, error=name == "stderr") + if name == "stderr": + stderr_messages.append(msg) + if on_stderr: + on_stderr(msg) + else: + stdout_messages.append(msg) + if on_stdout: + on_stdout(msg) + elif output_type in ("execute_result", "display_data"): + result = Result( + data=output.get("data", {}), + is_main_result=output_type == "execute_result", + extra=output.get("metadata", {}), + ) + results.append(result) + if on_result: + on_result(result) + elif output_type == "error": + ename = output.get("ename", "Error") + evalue = output.get("evalue", "") + if ename == "SystemExit": + try: + exit_code = int(evalue) if evalue else 0 + except (ValueError, TypeError): + exit_code = 1 if evalue else 0 + else: + code_error = CodeError( + name=ename, + value=evalue, + traceback="\n".join(output.get("traceback", [])), + ) + if on_error: + on_error(code_error) + + self._executing_event.clear() + was_interrupted = self._interrupt_requested.is_set() + self._interrupt_requested.clear() + + return ExecutionResult( + results=results, + logs=Logs(stdout=stdout_messages, stderr=stderr_messages), + execution_ok=True, + code_error=code_error, + exit_code=exit_code, + execution_count=reply.get("execution_count", 0), + context_id=context.id if context else "default", + started_at=started_at, + completed_at=time.time(), + interrupted=was_interrupted, + ) + + def _get_internal_variable(self, name: str, context: Optional[Context] = None): + if not self._started or self._client is None: + raise SandboxNotStartedError() + return self._client.get_variable(name) + + def _set_internal_variable(self, name: str, value, context: Optional[Context] = None) -> None: + if not self._started or self._client is None: + raise SandboxNotStartedError() + self._client.set_variable(name, value) diff --git a/code_sandboxes/modal_sandbox.py b/code_sandboxes/modal_sandbox.py new file mode 100644 index 0000000..56180ba --- /dev/null +++ b/code_sandboxes/modal_sandbox.py @@ -0,0 +1,255 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Modal sandbox implementation. + +`Modal `_ provides secure, cloud-hosted +containers that can run arbitrary code. This sandbox uses ``modal.Sandbox`` to +provision a container and executes Python snippets inside it via ``sandbox.exec``. + +Each ``run_code`` call runs the snippet as a fresh ``python -c`` process, so +Python variables do **not** persist across calls (use the filesystem or a single +snippet for stateful workflows). Rich display outputs (images, HTML) are not +captured; only stdout/stderr text and the process exit code are returned. +""" + +from __future__ import annotations + +import logging +import time +import uuid +from typing import Any, Optional + +from .base import Sandbox +from .exceptions import SandboxConfigurationError, SandboxNotStartedError +from .models import ( + CodeError, + Context, + ExecutionResult, + Logs, + OutputHandler, + OutputMessage, + Result, + SandboxConfig, + SandboxEnvironment, + SandboxInfo, + SandboxStatus, +) + +logger = logging.getLogger(__name__) + +DEFAULT_APP_NAME = "code-sandboxes" + + +class ModalSandbox(Sandbox): + """Sandbox backed by a Modal cloud container. + + Args: + config: Optional sandbox configuration. + app_name: Name of the Modal App to attach the sandbox to (created if missing). + image: An optional pre-built ``modal.Image``. When omitted, a + ``debian_slim`` image is used, optionally extended with ``pip_packages``. + pip_packages: Optional list of pip packages to install in the default image. + python_executable: Executable used to run snippets (default ``python``). + """ + + def __init__( + self, + config: Optional[SandboxConfig] = None, + app_name: str = DEFAULT_APP_NAME, + image: Optional[Any] = None, + pip_packages: Optional[list[str]] = None, + python_executable: str = "python", + **kwargs, + ): + super().__init__(config) + self._app_name = app_name + self._image = image + self._pip_packages = pip_packages or [] + self._python_executable = python_executable + self._app = None + self._sandbox = None + self._sandbox_id = str(uuid.uuid4()) + self._execution_count = 0 + self._extra_kwargs = kwargs + + @classmethod + def list_environments(cls) -> list[SandboxEnvironment]: + return [ + SandboxEnvironment( + name="modal", + title="Modal", + language="python", + owner="modal", + visibility="cloud", + burning_rate=0.0, + metadata={"variant": "modal"}, + ) + ] + + def start(self) -> None: + if self._started: + return + + try: + import modal + except ImportError as exc: + raise SandboxConfigurationError( + "modal is required for ModalSandbox. Install it with: pip install modal" + ) from exc + + self._app = modal.App.lookup(self._app_name, create_if_missing=True) + + image = self._image + if image is None: + image = modal.Image.debian_slim() + if self._pip_packages: + image = image.pip_install(*self._pip_packages) + + secrets = [] + if self.config.env_vars: + secrets.append(modal.Secret.from_dict(dict(self.config.env_vars))) + + create_kwargs: dict[str, Any] = { + "app": self._app, + "image": image, + "timeout": int(self.config.max_lifetime), + } + if secrets: + create_kwargs["secrets"] = secrets + + self._sandbox = modal.Sandbox.create(**create_kwargs) + + self._default_context = self.create_context("default") + self._info = SandboxInfo( + id=self._sandbox_id, + variant="modal", + status=SandboxStatus.RUNNING, + created_at=time.time(), + name=self.config.name, + metadata={ + "app_name": self._app_name, + "modal_sandbox_id": getattr(self._sandbox, "object_id", None), + }, + config=self.config, + ) + self._started = True + + def stop(self) -> None: + if not self._started: + return + if self._sandbox is not None: + try: + self._sandbox.terminate() + except Exception: + pass + try: + self._sandbox.detach() + except Exception: + pass + self._sandbox = None + self._app = None + self._started = False + if self._info: + self._info.status = SandboxStatus.STOPPED + + def run_code( + self, + code: str, + language: str = "python", + context: Optional[Context] = None, + on_stdout: Optional[OutputHandler[OutputMessage]] = None, + on_stderr: Optional[OutputHandler[OutputMessage]] = None, + on_result: Optional[OutputHandler[Result]] = None, + on_error: Optional[OutputHandler[CodeError]] = None, + envs: Optional[dict[str, str]] = None, + timeout: Optional[float] = None, + ) -> ExecutionResult: + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + + if language != "python": + raise ValueError(f"ModalSandbox only supports Python, got: {language}") + + started_at = time.time() + self._execution_count += 1 + + if envs: + env_code = "\n".join(f"import os; os.environ[{k!r}] = {v!r}" for k, v in envs.items()) + code = f"{env_code}\n{code}" + + stdout_messages: list[OutputMessage] = [] + stderr_messages: list[OutputMessage] = [] + code_error: Optional[CodeError] = None + + try: + process = self._sandbox.exec( + self._python_executable, + "-c", + code, + timeout=int(timeout or self.config.timeout), + ) + stdout_text = process.stdout.read() + stderr_text = process.stderr.read() + process.wait() + returncode = process.returncode + except Exception as e: + return ExecutionResult( + execution_ok=False, + execution_error=f"Failed to execute code on Modal: {e}", + started_at=started_at, + completed_at=time.time(), + context_id=context.id if context else "default", + ) + + current_time = time.time() + for line in (stdout_text or "").splitlines(): + msg = OutputMessage(line=line, timestamp=current_time, error=False) + stdout_messages.append(msg) + if on_stdout: + on_stdout(msg) + for line in (stderr_text or "").splitlines(): + msg = OutputMessage(line=line, timestamp=current_time, error=True) + stderr_messages.append(msg) + if on_stderr: + on_stderr(msg) + + exit_code = returncode + # A non-zero return code with stderr output indicates the user code + # raised an exception. Surface it as a code error. + if returncode not in (0, None) and stderr_text: + last_line = stderr_text.strip().splitlines()[-1] if stderr_text.strip() else "" + name = last_line.split(":", 1)[0].strip() or "Error" + value = last_line.split(":", 1)[1].strip() if ":" in last_line else last_line + code_error = CodeError(name=name, value=value, traceback=stderr_text) + if on_error: + on_error(code_error) + + return ExecutionResult( + results=[], + logs=Logs(stdout=stdout_messages, stderr=stderr_messages), + execution_ok=True, + code_error=code_error, + exit_code=exit_code, + execution_count=self._execution_count, + context_id=context.id if context else "default", + started_at=started_at, + completed_at=time.time(), + ) + + def _do_interrupt(self) -> bool: + """Modal does not expose fine-grained interrupts; terminate the process.""" + return False + + def _get_internal_variable(self, name: str, context: Optional[Context] = None): + raise NotImplementedError( + "ModalSandbox executes each snippet in a fresh process and does not " + "support cross-call variable access." + ) + + def _set_internal_variable(self, name: str, value, context: Optional[Context] = None) -> None: + raise NotImplementedError( + "ModalSandbox executes each snippet in a fresh process and does not " + "support cross-call variable access." + ) diff --git a/code_sandboxes/models.py b/code_sandboxes/models.py index 461e59c..9669cdd 100644 --- a/code_sandboxes/models.py +++ b/code_sandboxes/models.py @@ -69,6 +69,9 @@ class SandboxVariant(str, Enum): DOCKER = "docker" JUPYTER = "jupyter" DATALAYER = "datalayer" + COLAB = "colab" + MONTY = "monty" + MODAL = "modal" class GPUType(str, Enum): diff --git a/code_sandboxes/monty_sandbox.py b/code_sandboxes/monty_sandbox.py new file mode 100644 index 0000000..d5a3e73 --- /dev/null +++ b/code_sandboxes/monty_sandbox.py @@ -0,0 +1,235 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Monty sandbox implementation. + +`Monty `_ is a minimal, secure Python +interpreter written in Rust (distributed as ``pydantic-monty``). It runs a +restricted subset of Python in-process with microsecond startup times and no +access to the host filesystem, environment or network unless explicitly granted. + +This makes it an excellent fit for running short, LLM-generated snippets where a +full container/kernel would be overkill. Note that Monty only supports a subset +of Python (no third-party libraries, limited stdlib), so rich display outputs and +filesystem/command operations are not available. + +This sandbox uses ``MontyRepl``, whose session state (heap and namespace) +persists across successive ``run_code`` calls. +""" + +from __future__ import annotations + +import logging +import time +import uuid +from typing import Any, Optional + +from .base import Sandbox +from .exceptions import SandboxConfigurationError, SandboxNotStartedError +from .models import ( + CodeError, + Context, + ExecutionResult, + Logs, + OutputHandler, + OutputMessage, + Result, + SandboxConfig, + SandboxEnvironment, + SandboxInfo, + SandboxStatus, +) + +logger = logging.getLogger(__name__) + + +class MontySandbox(Sandbox): + """Sandbox backed by the Monty secure Python interpreter. + + Args: + config: Optional sandbox configuration. + type_check: Whether Monty should type-check the code before running it. + type_check_stubs: Optional type stub definitions used when ``type_check`` + is enabled. + external_functions: Mapping of names to host callables the sandboxed code + is allowed to call. + limits: Optional Monty ``ResourceLimits`` mapping (memory, stack depth, + execution time, ...). + """ + + def __init__( + self, + config: Optional[SandboxConfig] = None, + type_check: bool = False, + type_check_stubs: Optional[str] = None, + external_functions: Optional[dict[str, Any]] = None, + limits: Optional[dict[str, Any]] = None, + **kwargs, + ): + super().__init__(config) + self._type_check = type_check + self._type_check_stubs = type_check_stubs + self._external_functions = external_functions or {} + self._limits = limits + self._repl = None + self._collect_streams = None + self._sandbox_id = str(uuid.uuid4()) + self._execution_count = 0 + self._extra_kwargs = kwargs + + @classmethod + def list_environments(cls) -> list[SandboxEnvironment]: + return [ + SandboxEnvironment( + name="monty", + title="Monty", + language="python", + owner="local", + visibility="local", + burning_rate=0.0, + metadata={"variant": "monty"}, + ) + ] + + def start(self) -> None: + if self._started: + return + + try: + import pydantic_monty + except ImportError as exc: + raise SandboxConfigurationError( + "pydantic-monty is required for MontySandbox. " + "Install it with: pip install pydantic-monty" + ) from exc + + repl_kwargs: dict[str, Any] = {"type_check": self._type_check} + if self._type_check_stubs is not None: + repl_kwargs["type_check_stubs"] = self._type_check_stubs + if self._limits is not None: + repl_kwargs["limits"] = self._limits + + # A stateful REPL session; heap and namespace persist across feed_run calls. + self._repl = pydantic_monty.MontyRepl(**repl_kwargs) + self._collect_streams = pydantic_monty.CollectStreams + + self._default_context = self.create_context("default") + self._info = SandboxInfo( + id=self._sandbox_id, + variant="monty", + status=SandboxStatus.RUNNING, + created_at=time.time(), + name=self.config.name, + metadata={"interpreter": "monty"}, + config=self.config, + ) + self._started = True + + def stop(self) -> None: + if not self._started: + return + self._repl = None + self._started = False + if self._info: + self._info.status = SandboxStatus.STOPPED + + @staticmethod + def _coerce_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (bytes, bytearray)): + return value.decode("utf-8", errors="replace") + return str(value) + + def run_code( + self, + code: str, + language: str = "python", + context: Optional[Context] = None, + on_stdout: Optional[OutputHandler[OutputMessage]] = None, + on_stderr: Optional[OutputHandler[OutputMessage]] = None, + on_result: Optional[OutputHandler[Result]] = None, + on_error: Optional[OutputHandler[CodeError]] = None, + envs: Optional[dict[str, str]] = None, + timeout: Optional[float] = None, + ) -> ExecutionResult: + if not self._started or self._repl is None: + raise SandboxNotStartedError() + + if language != "python": + raise ValueError(f"MontySandbox only supports Python, got: {language}") + + started_at = time.time() + self._execution_count += 1 + + stdout_messages: list[OutputMessage] = [] + stderr_messages: list[OutputMessage] = [] + results: list[Result] = [] + code_error: Optional[CodeError] = None + + # Fresh collector per call so we only capture this snippet's output. + collector = self._collect_streams() + return_value: Any = None + raised = False + try: + return_value = self._repl.feed_run( + code, + external_functions=self._external_functions or None, + print_callback=collector, + ) + except Exception as e: + raised = True + code_error = CodeError( + name=type(e).__name__, + value=str(e), + traceback="", + ) + if on_error: + on_error(code_error) + + current_time = time.time() + + # ``collector.output`` yields a list of (stream, text) tuples. + for stream, text in collector.output or []: + is_err = stream == "stderr" + for line in self._coerce_text(text).splitlines(): + msg = OutputMessage(line=line, timestamp=current_time, error=is_err) + if is_err: + stderr_messages.append(msg) + if on_stderr: + on_stderr(msg) + else: + stdout_messages.append(msg) + if on_stdout: + on_stdout(msg) + + if not raised and return_value is not None: + result = Result( + data={"text/plain": self._coerce_text(return_value)}, + is_main_result=True, + ) + results.append(result) + if on_result: + on_result(result) + + return ExecutionResult( + results=results, + logs=Logs(stdout=stdout_messages, stderr=stderr_messages), + execution_ok=True, + code_error=code_error, + execution_count=self._execution_count, + context_id=context.id if context else "default", + started_at=started_at, + completed_at=time.time(), + ) + + def _get_internal_variable(self, name: str, context: Optional[Context] = None): + if not self._started or self._repl is None: + raise SandboxNotStartedError() + return self._repl.feed_run(name) + + def _set_internal_variable(self, name: str, value, context: Optional[Context] = None) -> None: + if not self._started or self._repl is None: + raise SandboxNotStartedError() + self._repl.feed_run(f"{name} = {value!r}") diff --git a/docs/docs/installation/index.mdx b/docs/docs/installation/index.mdx index 6eed81f..0d1caff 100644 --- a/docs/docs/installation/index.mdx +++ b/docs/docs/installation/index.mdx @@ -24,6 +24,15 @@ pip install code-sandboxes[datalayer] # With Docker support (local containers) pip install code-sandboxes[docker] +# With Google Colab support +pip install code-sandboxes[colab] + +# With Monty (secure in-process interpreter) support +pip install code-sandboxes[monty] + +# With Modal cloud sandbox support +pip install code-sandboxes[modal] + # All features pip install code-sandboxes[all] ``` @@ -32,7 +41,12 @@ pip install code-sandboxes[all] - Python 3.10 or higher - For Docker variant: Docker installed and running -- For Datalayer variant: Valid API key +- For Datalayer variant: valid `DATALAYER_API_KEY` +- For Google Colab variant: `code-sandboxes[colab]` and a Colab runtime assignment + (server URL, kernel id, proxy token) +- For Monty variant: `code-sandboxes[monty]` (no credentials required) +- For Modal variant: `code-sandboxes[modal]` and Modal credentials + (`modal token new` or `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`) ## Configuration @@ -42,6 +56,8 @@ pip install code-sandboxes[all] |----------|-------------| | `DATALAYER_API_KEY` | API key for Datalayer runtime authentication | | `DATALAYER_RUN_URL` | Custom Datalayer service URL (optional) | +| `MODAL_TOKEN_ID` | Modal token id (Modal variant) | +| `MODAL_TOKEN_SECRET` | Modal token secret (Modal variant) | ### Programmatic Configuration diff --git a/docs/docs/sandboxes/index.mdx b/docs/docs/sandboxes/index.mdx index 6d092d1..430a4a0 100644 --- a/docs/docs/sandboxes/index.mdx +++ b/docs/docs/sandboxes/index.mdx @@ -11,8 +11,8 @@ A sandbox is an isolated environment where code can be executed safely. Code San Use `Sandbox.create()` to create a new sandbox: -Canonical variant names are `eval`, `docker`, `jupyter`, and `datalayer`. -Older `local-*` names are no longer supported. +Canonical variant names are `jupyter`, `docker`, `eval`, `monty`, `colab`, +`modal`, and `datalayer`. Older `local-*` names are no longer supported. ```python from code_sandboxes import Sandbox @@ -50,71 +50,242 @@ sandbox = Sandbox.create( Concrete implementations are available from top-level modules: ```python -from code_sandboxes.eval_sandbox import EvalSandbox from code_sandboxes.jupyter_sandbox import JupyterSandbox from code_sandboxes.docker_sandbox import DockerSandbox +from code_sandboxes.eval_sandbox import EvalSandbox from code_sandboxes.datalayer_sandbox import DatalayerSandbox ``` -Code Sandboxes provides two categories of execution backends: +Code Sandboxes provides local (in-process) and remote (out-of-process / cloud) +execution backends. Each section below explains **how to obtain the credentials +and parameters** required by that variant. + +### Local Sandboxes + +Local sandboxes run on your machine with no external accounts. -### Sandboxes +#### jupyter + +Runs code against a local or remote Jupyter Server and connects using +`jupyter-kernel-client`. This variant provides process isolation via the Jupyter +kernel and persistent state across requests. + +- **Requirements:** `jupyter_server` and `jupyter-kernel-client` (included by default). +- **Parameters:** `server_url`, `token`, `host`/`port` (when the sandbox starts + its own server), `python_executable`. + +**How to obtain the `token`:** + +- If you start the server yourself, you choose the token: + ```bash + jupyter server --port 8888 --IdentityProvider.token MY_TOKEN + ``` +- For an already-running server, list servers and read the `token=...` value: + ```bash + jupyter server list + # http://localhost:8888/?token=abcd1234... :: /home/you/notebooks + ``` + You can also pass the full `http://host:port/?token=...` URL as `server_url`; + the token is parsed automatically. +- If you omit `server_url`, `JupyterSandbox` **starts and manages its own local + Jupyter Server** and generates the token for you — no configuration needed. + +```python +# Connect to an existing server: +with Sandbox.create( + variant="jupyter", + server_url="http://localhost:8888", + token="MY_TOKEN", +) as sandbox: + sandbox.run_code("x = 40") + result = sandbox.run_code("x + 2") + print(result.text) # 42 -sandboxes execute code in-process, sharing memory with the host Python process. +# Or let the sandbox manage a local server automatically: +with Sandbox.create(variant="jupyter") as sandbox: + print(sandbox.run_code("1 + 1").text) # 2 +``` #### eval Uses Python's `exec()` for code execution. No isolation, but fast and simple for development. +- **Credentials / parameters:** none — nothing to configure. +- ⚠️ `eval` shares memory with the host process and provides no sandboxing. Never + run untrusted code with it. + ```python with Sandbox.create(variant="eval") as sandbox: - result = sandbox.run_code("x = 1 + 1") - result = sandbox.run_code("print(x)") # prints 2 + result = sandbox.run_code("x = 1 + 1") + result = sandbox.run_code("print(x)") # prints 2 ``` -### Remote Sandboxes +#### monty -Remote sandboxes execute code out-of-process via the Jupyter kernel protocol, providing -better isolation and persistent kernel state. +Runs code in [Monty](https://github.com/pydantic/monty), a minimal, secure Python +interpreter written in Rust (`pydantic-monty`). Monty runs a restricted subset of +Python in-process with microsecond startup and no access to the host filesystem, +environment, or network unless explicitly granted. It is ideal for short, +LLM-generated snippets where a full container or kernel would be overkill. -#### docker +Session state (variables, imports, definitions) persists across `run_code` calls. +Note that Monty supports only a subset of Python — third-party libraries and rich +display outputs are not available. + +- **Requirements:** `code-sandboxes[monty]` (installs `pydantic-monty`). +- **Credentials:** none required (fully local, in-process). +- **Optional parameters** (on `MontySandbox`): `type_check` (type-check before + running), `type_check_stubs`, `external_functions` (`{name: callable}` host + functions the code may call), `limits` (memory / stack / time limits). -Runs code in a Docker container for local isolated execution. +```python +with Sandbox.create(variant="monty") as sandbox: + sandbox.run_code("x = 21") + result = sandbox.run_code("x * 2") + print(result.text) # 42 +``` -This variant runs a Jupyter Server inside the container and connects using -`jupyter-kernel-client`. +You can expose host callables to the sandboxed code and enable type checking: ```python -with Sandbox.create(variant="docker", image="code-sandboxes-jupyter:latest") as sandbox: - result = sandbox.run_code("import sys; print(sys.version)") +from code_sandboxes.monty_sandbox import MontySandbox + +sandbox = MontySandbox( + type_check=True, + external_functions={"now": lambda: "2026-01-01"}, +) +sandbox.start() +sandbox.run_code("print(now())") ``` +#### docker + +Runs a Jupyter Server inside a Docker container for local isolated execution and +connects using `jupyter-kernel-client`. + +- **Requirements:** `code-sandboxes[docker]` plus a running Docker Engine + (verify with `docker version`). +- **Credentials:** none — the kernel `token` is generated automatically. +- **Parameters:** `image` (default `code-sandboxes-jupyter:latest`), + `container_name`, `host`/`container_port`, `auto_remove`, `workdir`. + Build the default image used by `DockerSandbox`: ```bash docker build -t code-sandboxes-jupyter:latest -f docker/Dockerfile . ``` -#### jupyter +```python +with Sandbox.create(variant="docker", image="code-sandboxes-jupyter:latest") as sandbox: + result = sandbox.run_code("import sys; print(sys.version)") + print(result.stdout) +``` + +### Cloud Sandboxes + +Cloud sandboxes run on managed infrastructure and require credentials. + +#### colab + +Runs code against a Google Colab runtime. Colab exposes a Jupyter-compatible +kernel behind an authenticating proxy, so this variant connects using +`jupyter-kernel-client`'s `ColabKernelClient` under the hood. -Runs code against a local Jupyter Server and connects using `jupyter-kernel-client`. -This variant provides process isolation via the Jupyter kernel and persistent -state across requests. +- **Requirements:** `code-sandboxes[colab]` (installs `jupyter-kernel-client`). +- **Parameters:** `server_url`, `kernel_id`, `proxy_token` (pass as keyword + arguments or through the sandbox configuration). -Requirements: `jupyter_server` and `jupyter-kernel-client`. +**How to obtain these values** — they are the pieces of the WebSocket URL Colab's +own frontend uses to reach your runtime: + +``` +wss:///api/kernels//channels?session_id=<...>&colab-runtime-proxy-token=&colab-client-agent=web +``` + +Read them from your browser's developer tools while a Colab runtime is connected: + +1. Open your notebook on [colab.research.google.com](https://colab.research.google.com) + and **connect to a runtime** (*Runtime → Connect*, or run any cell). +2. Open DevTools (`F12`) → **Network** tab → **WS** filter, then run a cell to + trigger kernel traffic. +3. Click the `.../api/kernels//channels?...` request and read off: + - **`server_url`** — scheme + host *before* `/api/kernels` (change `wss://` to + `https://`). Colab assigns a per-session host such as + `https://8080-m-s-kkb-...-d.us-east1-0.prod.colab.dev`; there is usually **no** + `/tun/m/...` path segment. + - **`kernel_id`** — the UUID right after `/api/kernels/`. + - **`proxy_token`** — the `colab-runtime-proxy-token` query parameter (same + value as the `X-Colab-Runtime-Proxy-Token` request header). Ignore the + `session_id` and `colab-client-agent` parameters. + +The internal "runtime assignment API" that returns these values is not an +officially published public API, so the DevTools method above is the practical +approach. The values are tied to your Colab session and are short-lived — refresh +them after the runtime is reassigned or reconnected. ```python -with Sandbox.create(variant="jupyter") as sandbox: +with Sandbox.create( + variant="colab", + server_url="https://8080-m-s-kkb-...-d.us-east1-0.prod.colab.dev", + kernel_id="c9bba548-3995-4f26-8e1a-7b8fbb10c578", + proxy_token="eyJhbGci....", +) as sandbox: sandbox.run_code("x = 40") result = sandbox.run_code("x + 2") print(result.text) # 42 ``` +#### modal + +Runs code in a [Modal](https://modal.com/docs/guide) cloud sandbox, providing +fully isolated, on-demand containers with configurable images and secrets. + +Each `run_code` call executes in a fresh `python -c` process, so state does **not** +persist across calls (use a single multi-statement snippet if you need shared +state). Configure the image with additional pip packages as needed. + +- **Requirements:** `code-sandboxes[modal]` (installs `modal`). +- **Parameters:** `app_name`, `image` (a prebuilt `modal.Image`), `pip_packages`, + `python_executable`. + +**How to obtain Modal credentials:** + +1. Create a free account at [modal.com](https://modal.com). +2. Authenticate the CLI (opens a browser, writes `~/.modal.toml`): + ```bash + modal token new + ``` +3. Alternatively, create a token in the Modal dashboard (**Settings → API Tokens**) + and export `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`. + +```python +with Sandbox.create( + variant="modal", + pip_packages=["numpy"], +) as sandbox: + result = sandbox.run_code("import numpy as np; print(np.arange(3).sum())") + print(result.stdout) # "3" +``` + #### datalayer -Cloud-based execution with full isolation, GPU support, and persistence. +Cloud-based execution with full isolation, GPU support, snapshots, and persistence. + +- **Requirements:** `code-sandboxes[datalayer]`. +- **Parameters:** `token` (defaults to `DATALAYER_API_KEY`), `run_url`, + `snapshot_name`, plus creation options like `environment`, `gpu`, `cpu`, `memory`. + +**How to obtain Datalayer credentials:** + +1. Create an account at [datalayer.ai](https://datalayer.ai). +2. Generate an API token from your account settings (**IAM → Tokens / API Keys**). +3. Export `DATALAYER_API_KEY` (or pass it as the `token` parameter). Set + `DATALAYER_RUN_URL` only for a self-hosted / custom deployment. ```python +import os +os.environ["DATALAYER_API_KEY"] = "your-datalayer-token" + with Sandbox.create( variant="datalayer", gpu="A100", diff --git a/pyproject.toml b/pyproject.toml index cc0c815..e389c56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,14 +24,23 @@ dependencies = [ "pydantic>=2.0", "datalayer-core", "jupyter-server", - "jupyter-kernel-client", + "jupyter-kernel-client>=0.11.0", "jupyter-server-client", ] [project.optional-dependencies] datalayer = ["datalayer_core"] docker = ["docker>=6.0"] -all = ["datalayer_core", "docker>=6.0"] +colab = ["jupyter-kernel-client>=0.10"] +monty = ["pydantic-monty"] +modal = ["modal>=0.64"] +all = [ + "datalayer_core", + "docker>=6.0", + "jupyter-kernel-client>=0.10", + "pydantic-monty", + "modal>=0.64", +] test = [ "ipykernel", "jupyter_server>=1.6,<3", From 9e6c4b4807c8558f974624f778cf29eb7c6b91cc Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 14:34:38 +0200 Subject: [PATCH 04/21] fix --- code_sandboxes/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index de0fa00..d8784f9 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -58,10 +58,7 @@ from .base import Sandbox from .client import CodeExecutionOutcome, CodeSandboxClient -<<<<<<< HEAD from .colab_sandbox import ColabSandbox -======= ->>>>>>> main from .commands import CommandResult, ProcessHandle, SandboxCommands from .datalayer_sandbox import DatalayerSandbox from .docker_sandbox import DockerSandbox @@ -116,10 +113,7 @@ "CodeError", "CodeExecutionOutcome", "CodeSandboxClient", -<<<<<<< HEAD "ColabSandbox", -======= ->>>>>>> main "CommandResult", "Context", "ContextNotFoundError", From eb9b03434009878a4a2361f95056df4814a47b7e Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 15:04:56 +0200 Subject: [PATCH 05/21] tests --- README.md | 138 +++++++++++++++++----------- code_sandboxes/__init__.py | 2 +- code_sandboxes/__version__.py | 2 +- code_sandboxes/base.py | 98 ++++++++++---------- code_sandboxes/client.py | 39 ++++---- code_sandboxes/colab_sandbox.py | 39 ++++---- code_sandboxes/datalayer_sandbox.py | 41 +++++---- code_sandboxes/docker_sandbox.py | 37 ++++---- code_sandboxes/jupyter_sandbox.py | 39 ++++---- code_sandboxes/modal_sandbox.py | 42 +++++---- code_sandboxes/monty_sandbox.py | 32 +++---- docs/docs/sandboxes/index.mdx | 2 +- pyproject.toml | 13 ++- tests/test_factory.py | 54 +++++++++++ tests/test_modal_colab_sandbox.py | 88 ++++++++++++++++++ tests/test_models.py | 3 + 16 files changed, 420 insertions(+), 249 deletions(-) create mode 100644 tests/test_modal_colab_sandbox.py diff --git a/README.md b/README.md index 0221de4..4734214 100644 --- a/README.md +++ b/README.md @@ -29,15 +29,15 @@ Seven variants are available. Canonical variant names are `jupyter`, `docker`, `eval`, `monty`, `colab`, `modal`, and `datalayer`. The older `local-*` names are no longer supported. -| Variant | Isolation | Use Case | -| ----------- | ---------------------------- | --------------------------------- | -| `jupyter` | Process (Jupyter kernel) | Persistent state, local/remote | -| `docker` | Container (Jupyter Server) | Local isolated execution | -| `eval` | None (Python exec) | Development, testing | -| `monty` | In-process secure interpreter | Fast, safe LLM snippets | -| `colab` | Google Colab runtime | Free hosted GPU/CPU kernels | -| `modal` | Modal cloud container | On-demand isolated cloud compute | -| `datalayer` | Cloud VM | Production, GPU workloads | +| Variant | Isolation | Use Case | +| ----------- | ----------------------------- | -------------------------------- | +| `jupyter` | Process (Jupyter kernel) | Persistent state, local/remote | +| `docker` | Container (Jupyter Server) | Local isolated execution | +| `eval` | None (Python exec) | Development, testing | +| `monty` | In-process secure interpreter | Fast, safe LLM snippets | +| `colab` | Google Colab runtime | Free hosted GPU/CPU kernels | +| `modal` | Modal cloud container | On-demand isolated cloud compute | +| `datalayer` | Cloud VM | Production, GPU workloads | See [Backend Setup Guides](#backend-setup-guides) below for per-variant installation, credentials, and usage. @@ -236,28 +236,28 @@ pip install code-sandboxes **Parameters:** -| Parameter | Description | -| --------- | ----------- | -| `server_url` | Jupyter Server URL (default: an auto-started local server) | -| `token` | Jupyter Server authentication token | -| `host` / `port` | Bind address when the sandbox starts its own server | -| `python_executable` | Interpreter used to launch the managed server | +| Parameter | Description | +| ------------------- | ---------------------------------------------------------- | +| `server_url` | Jupyter Server URL (default: an auto-started local server) | +| `token` | Jupyter Server authentication token | +| `host` / `port` | Bind address when the sandbox starts its own server | +| `python_executable` | Interpreter used to launch the managed server | **How to obtain the `token`:** - If you start the server yourself, you choose the token: - ```bash - jupyter server --port 8888 --IdentityProvider.token MY_TOKEN - ``` + ```bash + jupyter server --port 8888 --IdentityProvider.token MY_TOKEN + ``` - For an already-running server, print the URL + token with: - ```bash - jupyter server list - # http://localhost:8888/?token=abcd1234... :: /home/you/notebooks - ``` - The `token=...` query value is your token. You can also pass the full URL as - `server_url` (the `?token=...` is parsed automatically). + ```bash + jupyter server list + # http://localhost:8888/?token=abcd1234... :: /home/you/notebooks + ``` + The `token=...` query value is your token. You can also pass the full URL as + `server_url` (the `?token=...` is parsed automatically). - If you omit `server_url` entirely, `JupyterSandbox` **starts and manages its own - local Jupyter Server** and generates the token for you — no configuration needed. + local Jupyter Server** and generates the token for you — no configuration needed. **Usage:** @@ -305,13 +305,13 @@ docker build -t code-sandboxes-jupyter:latest -f docker/Dockerfile . **Parameters** (no external credentials — the kernel `token` is generated automatically): -| Parameter | Description | -| --------- | ----------- | -| `image` | Container image to run (default `code-sandboxes-jupyter:latest`) | -| `container_name` | Optional fixed container name | -| `host` / `container_port` | Where the in-container server is exposed | -| `auto_remove` | Remove the container on stop (default `True`) | -| `workdir` | Host working directory to mount | +| Parameter | Description | +| ------------------------- | ---------------------------------------------------------------- | +| `image` | Container image to run (default `code-sandboxes-jupyter:latest`) | +| `container_name` | Optional fixed container name | +| `host` / `container_port` | Where the in-container server is exposed | +| `auto_remove` | Remove the container on stop (default `True`) | +| `workdir` | Host working directory to mount | **Usage:** @@ -369,12 +369,12 @@ pip install code-sandboxes[monty] **Credentials / parameters:** none required (fully local, in-process). Optional constructor parameters on `MontySandbox`: -| Parameter | How to obtain / when to use | -| --------- | --------------------------- | -| `type_check` | Set `True` to type-check code before running it | -| `type_check_stubs` | Provide type stub definitions when `type_check` is enabled | +| Parameter | How to obtain / when to use | +| -------------------- | ----------------------------------------------------------- | +| `type_check` | Set `True` to type-check code before running it | +| `type_check_stubs` | Provide type stub definitions when `type_check` is enabled | | `external_functions` | Dict of `{name: callable}` host functions the code may call | -| `limits` | Monty `ResourceLimits` mapping (memory, stack depth, time) | +| `limits` | Monty `ResourceLimits` mapping (memory, stack depth, time) | **Usage:** @@ -413,10 +413,10 @@ pip install code-sandboxes[colab] **Parameters:** -| Parameter | Description | -| --------- | ----------- | -| `server_url` | The Colab runtime proxy/tunnel URL | -| `kernel_id` | The assigned kernel identifier | +| Parameter | Description | +| ------------- | ------------------------------------- | +| `server_url` | The Colab runtime proxy/tunnel URL | +| `kernel_id` | The assigned kernel identifier | | `proxy_token` | The `colab-runtime-proxy-token` value | **How to obtain these values** — they are the pieces of the WebSocket URL that @@ -430,9 +430,9 @@ Read them from your browser's developer tools: 1. Open your notebook on [colab.research.google.com](https://colab.research.google.com) and **connect to a runtime** (*Runtime → Connect*, or run any cell). -2. Open DevTools (`F12`) → **Network** tab, select the **WS** filter (or type +1. Open DevTools (`F12`) → **Network** tab, select the **WS** filter (or type `kernels`), then run a cell to trigger kernel traffic. -3. Click the `.../api/kernels//channels?...` request and read off: +1. Click the `.../api/kernels//channels?...` request and read off: - **`server_url`** — scheme + host *before* `/api/kernels` (change `wss://` to `https://`). Colab assigns a per-session host such as `https://8080-m-s-kkb-...-d.us-east1-0.prod.colab.dev`; there is usually **no** @@ -477,17 +477,20 @@ pip install code-sandboxes[modal] **How to obtain Modal credentials:** 1. Create a free account at [modal.com](https://modal.com). -2. Authenticate the CLI — this opens a browser and writes credentials to + +1. Authenticate the CLI — this opens a browser and writes credentials to `~/.modal.toml`: + ```bash modal token new ``` -3. Alternatively, create a token in the Modal dashboard + +1. Alternatively, create a token in the Modal dashboard (**Settings → API Tokens**) and export it as environment variables: - | Environment variable | Description | - | -------------------- | ----------- | - | `MODAL_TOKEN_ID` | Modal token id (starts with `ak-`) | + | Environment variable | Description | + | -------------------- | -------------------------------------- | + | `MODAL_TOKEN_ID` | Modal token id (starts with `ak-`) | | `MODAL_TOKEN_SECRET` | Modal token secret (starts with `as-`) | **Parameters:** `app_name`, `image` (a prebuilt `modal.Image`), `pip_packages` @@ -513,7 +516,8 @@ with Sandbox.create( ### 7. Datalayer Cloud-based execution with full isolation, GPU support, snapshots, and -persistence via the [Datalayer](https://datalayer.ai) runtime. +persistence via the [Datalayer](https://datalayer.ai) runtime, powered by the +`agent_runtimes` package. **Install:** @@ -521,16 +525,21 @@ persistence via the [Datalayer](https://datalayer.ai) runtime. pip install code-sandboxes[datalayer] ``` +This extra installs `agent_runtimes`, which provides the runtime client used by +the `datalayer` sandbox variant. + **How to obtain Datalayer credentials:** 1. Create an account at [datalayer.ai](https://datalayer.ai). -2. Generate an API token from your account settings (**IAM → Tokens / API Keys**). -3. Export it (or pass it as the `token` parameter): - | Environment variable | Description | - | -------------------- | ----------- | - | `DATALAYER_API_KEY` | API key for Datalayer runtime authentication | - | `DATALAYER_RUN_URL` | Custom Datalayer service URL (optional, for self-hosted) | +1. Generate an API token from your account settings (**IAM → Tokens / API Keys**). + +1. Export it (or pass it as the `token` parameter): + + | Environment variable | Description | + | -------------------- | -------------------------------------------------------- | + | `DATALAYER_API_KEY` | API key for Datalayer runtime authentication | + | `DATALAYER_RUN_URL` | Custom Datalayer service URL (optional, for self-hosted) | **Parameters:** `token` (defaults to `DATALAYER_API_KEY`), `run_url`, `snapshot_name`, plus creation options like `environment`, `gpu`, `cpu`, `memory`. @@ -648,6 +657,25 @@ config = SandboxConfig( sandbox = Sandbox.create(config=config) ``` +## Testing + +Run the local test suite: + +```bash +pytest tests/ +``` + +Required environment variables for tests: + +- None for the default local suite (`eval`, factory, model, and local Jupyter tests). + +Optional environment variables for cloud-integration smoke tests: + +- `DATALAYER_API_KEY`: required only when running Datalayer runtime smoke tests. +- `DATALAYER_RUN_URL`: optional custom Datalayer runtime URL. +- `DATALAYER_ENVIRONMENT`: optional environment override (for example `ai-agents-env`). +- `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`: required only if you add or run Modal integration tests. + ## CI Workflows This repository uses a reusable GitHub Actions workflow at `.github/workflows/reusable-python.yml`. diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index d8784f9..ecf7e3a 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -87,7 +87,6 @@ ) from .jupyter_sandbox import JupyterSandbox from .modal_sandbox import ModalSandbox -from .monty_sandbox import MontySandbox from .models import ( CodeError, Context, @@ -107,6 +106,7 @@ SnapshotInfo, TunnelInfo, ) +from .monty_sandbox import MontySandbox __all__ = [ # Models diff --git a/code_sandboxes/__version__.py b/code_sandboxes/__version__.py index 49955d2..c5b0b2d 100644 --- a/code_sandboxes/__version__.py +++ b/code_sandboxes/__version__.py @@ -3,4 +3,4 @@ """Code Sandboxes.""" -__version__ = "0.0.15" +__version__ = "0.0.16" diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index 0655991..b91680a 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -10,7 +10,7 @@ import uuid from abc import ABC, abstractmethod from collections.abc import AsyncIterator, Iterator -from typing import Any, Optional, Union +from typing import Any, Union from .commands import SandboxCommands from .filesystem import SandboxFilesystem @@ -67,26 +67,26 @@ class Sandbox(ABC): commands: Command execution operations. """ - def __init__(self, config: Optional[SandboxConfig] = None): + def __init__(self, config: SandboxConfig | None = None): """Initialize sandbox with configuration. Args: config: Sandbox configuration. Uses defaults if not provided. """ self.config = config or SandboxConfig() - self._info: Optional[SandboxInfo] = None + self._info: SandboxInfo | None = None self._started = False - self._default_context: Optional[Context] = None - self._files: Optional[SandboxFilesystem] = None - self._commands: Optional[SandboxCommands] = None + self._default_context: Context | None = None + self._files: SandboxFilesystem | None = None + self._commands: SandboxCommands | None = None self._tags: dict[str, str] = {} self._created_at: float = 0.0 - self._tool_caller: Optional[Any] = None # Tool caller function for MCP tools + self._tool_caller: Any | None = None # Tool caller function for MCP tools self._executing_event = threading.Event() # Set while code is running self._interrupt_requested = threading.Event() # Set to request interruption @property - def info(self) -> Optional[SandboxInfo]: + def info(self) -> SandboxInfo | None: """Get information about this sandbox.""" return self._info @@ -125,7 +125,7 @@ def _do_interrupt(self) -> bool: return True @property - def sandbox_id(self) -> Optional[str]: + def sandbox_id(self) -> str | None: """Get the sandbox ID.""" return self._info.id if self._info else None @@ -171,17 +171,17 @@ def set_tags(self, tags: dict[str, str]) -> None: def create( cls, variant: SandboxVariant | str = SandboxVariant.DATALAYER, - config: Optional[SandboxConfig] = None, - timeout: Optional[float] = None, - name: Optional[str] = None, - environment: Optional[str] = None, - gpu: Optional[str] = None, - cpu: Optional[float] = None, - memory: Optional[int] = None, - env: Optional[dict[str, str]] = None, - network_policy: Optional[str] = None, - allowed_hosts: Optional[list[str]] = None, - tags: Optional[dict[str, str]] = None, + config: SandboxConfig | None = None, + timeout: float | None = None, + name: str | None = None, + environment: str | None = None, + gpu: str | None = None, + cpu: float | None = None, + memory: int | None = None, + env: dict[str, str] | None = None, + network_policy: str | None = None, + allowed_hosts: list[str] | None = None, + tags: dict[str, str] | None = None, **kwargs, ) -> Sandbox: """Factory method to create a sandbox of the specified variant. @@ -350,7 +350,7 @@ def list_environments( @classmethod def list( cls, - tags: Optional[dict[str, str]] = None, + tags: dict[str, str] | None = None, **kwargs, ) -> Iterator[Sandbox]: """List all running sandboxes. @@ -416,13 +416,13 @@ def run_code( self, code: str, language: str = "python", - context: Optional[Context] = None, - on_stdout: Optional[OutputHandler[OutputMessage]] = None, - on_stderr: Optional[OutputHandler[OutputMessage]] = None, - on_result: Optional[OutputHandler[Result]] = None, - on_error: Optional[OutputHandler[CodeError]] = None, - envs: Optional[dict[str, str]] = None, - timeout: Optional[float] = None, + context: Context | None = None, + on_stdout: OutputHandler[OutputMessage] | None = None, + on_stderr: OutputHandler[OutputMessage] | None = None, + on_result: OutputHandler[Result] | None = None, + on_error: OutputHandler[CodeError] | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, ) -> ExecutionResult: """Execute code in the sandbox. @@ -447,13 +447,13 @@ async def run_code_async( self, code: str, language: str = "python", - context: Optional[Context] = None, - on_stdout: Optional[OutputHandler[OutputMessage]] = None, - on_stderr: Optional[OutputHandler[OutputMessage]] = None, - on_result: Optional[OutputHandler[Result]] = None, - on_error: Optional[OutputHandler[CodeError]] = None, - envs: Optional[dict[str, str]] = None, - timeout: Optional[float] = None, + context: Context | None = None, + on_stdout: OutputHandler[OutputMessage] | None = None, + on_stderr: OutputHandler[OutputMessage] | None = None, + on_result: OutputHandler[Result] | None = None, + on_error: OutputHandler[CodeError] | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, ) -> ExecutionResult: """Async version of run_code(). Default implementation calls sync version.""" return self.run_code( @@ -472,9 +472,9 @@ def run_code_streaming( self, code: str, language: str = "python", - context: Optional[Context] = None, - envs: Optional[dict[str, str]] = None, - timeout: Optional[float] = None, + context: Context | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, ) -> Iterator[Union[OutputMessage, Result, CodeError]]: """Execute code with streaming output. @@ -512,9 +512,9 @@ async def run_code_streaming_async( self, code: str, language: str = "python", - context: Optional[Context] = None, - envs: Optional[dict[str, str]] = None, - timeout: Optional[float] = None, + context: Context | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, ) -> AsyncIterator[Union[OutputMessage, Result, CodeError]]: """Async version of run_code_streaming().""" execution = await self.run_code_async( @@ -537,7 +537,7 @@ async def run_code_streaming_async( if execution.code_error: yield execution.code_error - def create_context(self, name: Optional[str] = None) -> Context: + def create_context(self, name: str | None = None) -> Context: """Create a new execution context. A context maintains state (variables, imports, etc.) between executions. @@ -551,7 +551,7 @@ def create_context(self, name: Optional[str] = None) -> Context: context_id = name or str(uuid.uuid4()) return Context(id=context_id, language="python", cwd=self.config.working_dir) - def get_variable(self, name: str, context: Optional[Context] = None) -> Any: + def get_variable(self, name: str, context: Context | None = None) -> Any: """Get a variable from the sandbox. Args: @@ -576,7 +576,7 @@ def get_variable(self, name: str, context: Optional[Context] = None) -> Any: raise VariableNotFoundError(name) return self._get_internal_variable("__result__", context) - def set_variable(self, name: str, value: Any, context: Optional[Context] = None) -> None: + def set_variable(self, name: str, value: Any, context: Context | None = None) -> None: """Set a variable in the sandbox. Args: @@ -586,7 +586,7 @@ def set_variable(self, name: str, value: Any, context: Optional[Context] = None) """ self._set_internal_variable(name, value, context) - def set_variables(self, variables: dict[str, Any], context: Optional[Context] = None) -> None: + def set_variables(self, variables: dict[str, Any], context: Context | None = None) -> None: """Set multiple variables in the sandbox. Args: @@ -628,19 +628,17 @@ def _setup_tool_caller(self) -> None: self._set_internal_variable("__call_tool__", self._tool_caller) @abstractmethod - def _get_internal_variable(self, name: str, context: Optional[Context] = None) -> Any: + def _get_internal_variable(self, name: str, context: Context | None = None) -> Any: """Internal method to get a variable. Must be implemented by subclasses.""" pass @abstractmethod - def _set_internal_variable( - self, name: str, value: Any, context: Optional[Context] = None - ) -> None: + def _set_internal_variable(self, name: str, value: Any, context: Context | None = None) -> None: """Internal method to set a variable. Must be implemented by subclasses.""" pass def install_packages( - self, packages: list[str], timeout: Optional[float] = None + self, packages: list[str], timeout: float | None = None ) -> ExecutionResult: """Install Python packages in the sandbox. diff --git a/code_sandboxes/client.py b/code_sandboxes/client.py index 7e61c9e..aefa587 100644 --- a/code_sandboxes/client.py +++ b/code_sandboxes/client.py @@ -44,7 +44,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional from .base import Sandbox from .commands import CommandResult @@ -88,14 +87,14 @@ class CodeExecutionOutcome: stdout: str = "" stderr: str = "" results: list[str] = field(default_factory=list) - error: Optional[str] = None - execution_error: Optional[str] = None - code_error: Optional[dict[str, str]] = None - exit_code: Optional[int] = None + error: str | None = None + execution_error: str | None = None + code_error: dict[str, str] | None = None + exit_code: int | None = None interrupted: bool = False @classmethod - def from_execution_result(cls, execution: ExecutionResult) -> "CodeExecutionOutcome": + def from_execution_result(cls, execution: ExecutionResult) -> CodeExecutionOutcome: """Build a normalized outcome from a raw :class:`ExecutionResult`.""" results: list[str] = [] for result in execution.results: @@ -103,7 +102,7 @@ def from_execution_result(cls, execution: ExecutionResult) -> "CodeExecutionOutc if text: results.append(text) - code_error_dict: Optional[dict[str, str]] = None + code_error_dict: dict[str, str] | None = None if execution.code_error is not None: code_error = execution.code_error code_error_dict = { @@ -114,7 +113,7 @@ def from_execution_result(cls, execution: ExecutionResult) -> "CodeExecutionOutc exit_code = getattr(execution, "exit_code", None) - error: Optional[str] = None + error: str | None = None if not execution.execution_ok: error = execution.execution_error or "Sandbox infrastructure failure" elif code_error_dict is not None: @@ -165,9 +164,9 @@ def __init__(self, sandbox: Sandbox, *, owns_sandbox: bool = False) -> None: def create( cls, variant: SandboxVariant | str = SandboxVariant.EVAL, - config: Optional[SandboxConfig] = None, + config: SandboxConfig | None = None, **kwargs, - ) -> "CodeSandboxClient": + ) -> CodeSandboxClient: """Create a client that owns a freshly created sandbox of ``variant``. Accepts the same keyword arguments as :meth:`Sandbox.create`. @@ -181,7 +180,7 @@ def sandbox(self) -> Sandbox: return self._sandbox @property - def variant(self) -> Optional[SandboxVariant]: + def variant(self) -> SandboxVariant | None: """The variant of the wrapped sandbox, if known.""" return getattr(self._sandbox.config, "variant", None) @@ -230,25 +229,23 @@ def execute_code( self, code: str, language: str = "python", - timeout: Optional[float] = None, - envs: Optional[dict[str, str]] = None, + timeout: float | None = None, + envs: dict[str, str] | None = None, ) -> CodeExecutionOutcome: """Execute code and return a normalized outcome. The sandbox is started automatically if needed. """ self.start() - execution = self._sandbox.run_code( - code, language=language, timeout=timeout, envs=envs - ) + execution = self._sandbox.run_code(code, language=language, timeout=timeout, envs=envs) return CodeExecutionOutcome.from_execution_result(execution) async def execute_code_async( self, code: str, language: str = "python", - timeout: Optional[float] = None, - envs: Optional[dict[str, str]] = None, + timeout: float | None = None, + envs: dict[str, str] | None = None, ) -> CodeExecutionOutcome: """Async variant of :meth:`execute_code`.""" await self.start_async() @@ -257,19 +254,19 @@ async def execute_code_async( ) return CodeExecutionOutcome.from_execution_result(execution) - def run_command(self, command: str, timeout: Optional[float] = None) -> CommandResult: + def run_command(self, command: str, timeout: float | None = None) -> CommandResult: """Run a shell command inside the sandbox.""" self.start() return self._sandbox.commands.run(command, timeout=timeout) - def __enter__(self) -> "CodeSandboxClient": + def __enter__(self) -> CodeSandboxClient: self.start() return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: self.close() - async def __aenter__(self) -> "CodeSandboxClient": + async def __aenter__(self) -> CodeSandboxClient: await self.start_async() return self diff --git a/code_sandboxes/colab_sandbox.py b/code_sandboxes/colab_sandbox.py index feced17..9c1782c 100644 --- a/code_sandboxes/colab_sandbox.py +++ b/code_sandboxes/colab_sandbox.py @@ -18,7 +18,6 @@ import logging import time import uuid -from typing import Optional from .base import Sandbox from .exceptions import SandboxConfigurationError, SandboxNotStartedError @@ -52,10 +51,10 @@ class ColabSandbox(Sandbox): def __init__( self, - config: Optional[SandboxConfig] = None, - server_url: Optional[str] = None, - kernel_id: Optional[str] = None, - proxy_token: Optional[str] = None, + config: SandboxConfig | None = None, + server_url: str | None = None, + kernel_id: str | None = None, + proxy_token: str | None = None, client_agent: str = "code-sandboxes", **kwargs, ): @@ -98,7 +97,7 @@ def start(self) -> None: from jupyter_kernel_client import ColabKernelClient except ImportError as exc: raise SandboxConfigurationError( - "jupyter-kernel-client>=0.10 is required for ColabSandbox. " + "jupyter-kernel-client>=0.12.0 is required for ColabSandbox. " "Install it with: pip install jupyter-kernel-client" ) from exc @@ -134,23 +133,23 @@ def stop(self) -> None: # Do not shut down the Colab kernel; we only disconnect. self._client.stop(shutdown_kernel=False) except Exception: - pass + logger.debug("Ignoring error while stopping Colab client", exc_info=True) self._client = None self._started = False if self._info: self._info.status = SandboxStatus.STOPPED - def run_code( + def run_code( # noqa: C901 self, code: str, language: str = "python", - context: Optional[Context] = None, - on_stdout: Optional[OutputHandler[OutputMessage]] = None, - on_stderr: Optional[OutputHandler[OutputMessage]] = None, - on_result: Optional[OutputHandler[Result]] = None, - on_error: Optional[OutputHandler[CodeError]] = None, - envs: Optional[dict[str, str]] = None, - timeout: Optional[float] = None, + context: Context | None = None, + on_stdout: OutputHandler[OutputMessage] | None = None, + on_stderr: OutputHandler[OutputMessage] | None = None, + on_result: OutputHandler[Result] | None = None, + on_error: OutputHandler[CodeError] | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, ) -> ExecutionResult: if not self._started or self._client is None: raise SandboxNotStartedError() @@ -173,7 +172,7 @@ def run_code( was_interrupted = self._interrupt_requested.is_set() self._interrupt_requested.clear() return ExecutionResult( - execution_ok=not was_interrupted, + execution_ok=False, execution_error=f"Failed to execute code: {e}" if not was_interrupted else None, started_at=started_at, completed_at=time.time(), @@ -184,8 +183,8 @@ def run_code( stdout_messages: list[OutputMessage] = [] stderr_messages: list[OutputMessage] = [] results: list[Result] = [] - code_error: Optional[CodeError] = None - exit_code: Optional[int] = None + code_error: CodeError | None = None + exit_code: int | None = None current_time = time.time() for output in reply.get("outputs", []): @@ -246,12 +245,12 @@ def run_code( interrupted=was_interrupted, ) - def _get_internal_variable(self, name: str, context: Optional[Context] = None): + def _get_internal_variable(self, name: str, context: Context | None = None): if not self._started or self._client is None: raise SandboxNotStartedError() return self._client.get_variable(name) - def _set_internal_variable(self, name: str, value, context: Optional[Context] = None) -> None: + def _set_internal_variable(self, name: str, value, context: Context | None = None) -> None: if not self._started or self._client is None: raise SandboxNotStartedError() self._client.set_variable(name, value) diff --git a/code_sandboxes/datalayer_sandbox.py b/code_sandboxes/datalayer_sandbox.py index 6c31c32..be262dd 100644 --- a/code_sandboxes/datalayer_sandbox.py +++ b/code_sandboxes/datalayer_sandbox.py @@ -125,7 +125,7 @@ def from_id(cls, sandbox_id: str, **kwargs) -> "DatalayerSandbox": sandbox = cls(**kwargs) sandbox._sandbox_id = sandbox_id # Connect to existing runtime - this would need runtime lookup - # For now, this is a placeholder that would need datalayer_core support + # For now, this is a placeholder that would need agent-runtimes support return sandbox @classmethod @@ -148,7 +148,7 @@ def list_all( DatalayerSandbox instances. """ try: - from datalayer_core import DatalayerClient + from agent_runtimes.client import AgentClient from datalayer_core.utils.urls import DatalayerURLs except ImportError: return @@ -156,9 +156,9 @@ def list_all( try: if run_url: urls = DatalayerURLs.from_run_url(run_url) - client = DatalayerClient(urls=urls, token=token) + client = AgentClient(urls=urls, api_key=token) else: - client = DatalayerClient(token=token) + client = AgentClient(api_key=token) runtimes = client.list_runtimes() @@ -190,7 +190,7 @@ def list_environments( run_url: Optional[str] = None, ) -> list[SandboxEnvironment]: try: - from datalayer_core import DatalayerClient + from agent_runtimes.client import AgentClient from datalayer_core.utils.urls import DatalayerURLs except ImportError: return [] @@ -198,9 +198,9 @@ def list_environments( try: if run_url: urls = DatalayerURLs.from_run_url(run_url) - client = DatalayerClient(urls=urls, token=token) + client = AgentClient(urls=urls, api_key=token) else: - client = DatalayerClient(token=token) + client = AgentClient(api_key=token) environments = client.list_environments() return [ @@ -230,26 +230,25 @@ def start(self) -> None: try: # Import here to avoid hard dependency - from datalayer_core import DatalayerClient + from agent_runtimes.client import AgentClient + from agent_runtimes.client.agent_client import DEFAULT_TIME_RESERVATION from datalayer_core.utils.urls import DatalayerURLs except ImportError as e: raise SandboxConfigurationError( - "datalayer_core package is required for DatalayerSandbox. " - "Install it with: pip install datalayer_core" + "agent-runtimes package is required for DatalayerSandbox. " + "Install it with: pip install code-sandboxes[datalayer]" ) from e try: # Create client with optional custom URL if self._run_url: urls = DatalayerURLs.from_run_url(self._run_url) - self._client = DatalayerClient(urls=urls, token=self._token) + self._client = AgentClient(urls=urls, api_key=self._token) else: - self._client = DatalayerClient(token=self._token) - - # Calculate time reservation - # Default to the platform default (10 minutes) unless max_lifetime is explicitly set - from datalayer_core.utils.defaults import DEFAULT_TIME_RESERVATION + self._client = AgentClient(api_key=self._token) + # Calculate time reservation. + # Default to the platform default (10 minutes) unless max_lifetime is explicitly set. default_max_lifetime = SandboxConfig().max_lifetime if self.config.max_lifetime != default_max_lifetime: lifetime_minutes = int(self.config.max_lifetime / 60) @@ -282,7 +281,10 @@ def start(self) -> None: ) # Start the runtime - self._runtime._start() + if hasattr(self._runtime, "start"): + self._runtime.start() + else: # pragma: no cover - compatibility fallback + self._runtime._start() self._default_context = self.create_context("default") @@ -328,7 +330,10 @@ def stop(self) -> None: try: if self._runtime: - self._runtime._stop() + if hasattr(self._runtime, "stop"): + self._runtime.stop() + else: # pragma: no cover - compatibility fallback + self._runtime._stop() except Exception: pass # Best effort cleanup diff --git a/code_sandboxes/docker_sandbox.py b/code_sandboxes/docker_sandbox.py index ebe8503..54cb09d 100644 --- a/code_sandboxes/docker_sandbox.py +++ b/code_sandboxes/docker_sandbox.py @@ -14,7 +14,6 @@ import tempfile import time import uuid -from typing import Optional import requests @@ -43,15 +42,15 @@ class DockerSandbox(Sandbox): def __init__( self, - config: Optional[SandboxConfig] = None, - image: Optional[str] = None, - token: Optional[str] = None, + config: SandboxConfig | None = None, + image: str | None = None, + token: str | None = None, host: str = "127.0.0.1", container_port: int = DEFAULT_PORT, - container_name: Optional[str] = None, + container_name: str | None = None, docker_client=None, auto_remove: bool = True, - workdir: Optional[str] = None, + workdir: str | None = None, **kwargs, ): super().__init__(config) @@ -66,8 +65,8 @@ def __init__( self._client = None self._sandbox_id = str(uuid.uuid4()) self._workdir = workdir - self._workdir_tmp: Optional[str] = None - self._server_url: Optional[str] = None + self._workdir_tmp: str | None = None + self._server_url: str | None = None self._extra_kwargs = kwargs @classmethod @@ -222,13 +221,13 @@ def run_code( self, code: str, language: str = "python", - context: Optional[Context] = None, - on_stdout: Optional[OutputHandler[OutputMessage]] = None, - on_stderr: Optional[OutputHandler[OutputMessage]] = None, - on_result: Optional[OutputHandler[Result]] = None, - on_error: Optional[OutputHandler[CodeError]] = None, - envs: Optional[dict[str, str]] = None, - timeout: Optional[float] = None, + context: Context | None = None, + on_stdout: OutputHandler[OutputMessage] | None = None, + on_stderr: OutputHandler[OutputMessage] | None = None, + on_result: OutputHandler[Result] | None = None, + on_error: OutputHandler[CodeError] | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, ) -> ExecutionResult: if not self._started or self._client is None: raise SandboxNotStartedError() @@ -257,8 +256,8 @@ def run_code( stdout_messages: list[OutputMessage] = [] stderr_messages: list[OutputMessage] = [] results: list[Result] = [] - code_error: Optional[CodeError] = None - exit_code: Optional[int] = None + code_error: CodeError | None = None + exit_code: int | None = None current_time = time.time() for output in reply.get("outputs", []): @@ -316,12 +315,12 @@ def run_code( completed_at=time.time(), ) - def _get_internal_variable(self, name: str, context: Optional[Context] = None): + def _get_internal_variable(self, name: str, context: Context | None = None): if not self._started or self._client is None: raise SandboxNotStartedError() return self._client.get_variable(name) - def _set_internal_variable(self, name: str, value, context: Optional[Context] = None) -> None: + def _set_internal_variable(self, name: str, value, context: Context | None = None) -> None: if not self._started or self._client is None: raise SandboxNotStartedError() self._client.set_variable(name, value) diff --git a/code_sandboxes/jupyter_sandbox.py b/code_sandboxes/jupyter_sandbox.py index 2190285..922215f 100644 --- a/code_sandboxes/jupyter_sandbox.py +++ b/code_sandboxes/jupyter_sandbox.py @@ -21,7 +21,6 @@ import time import uuid from pathlib import Path -from typing import Optional from urllib.parse import parse_qs, urlparse, urlunparse import requests @@ -54,12 +53,12 @@ class JupyterSandbox(Sandbox): def __init__( self, - config: Optional[SandboxConfig] = None, - server_url: Optional[str] = None, - token: Optional[str] = None, + config: SandboxConfig | None = None, + server_url: str | None = None, + token: str | None = None, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, - python_executable: Optional[str] = None, + python_executable: str | None = None, separate_process: bool = True, **kwargs, ): @@ -83,12 +82,12 @@ def __init__( self._python_executable = python_executable or os.environ.get("PYTHON", "python") self._separate_process = separate_process self._server_app = None - self._server_thread: Optional[threading.Thread] = None - self._server_process: Optional[subprocess.Popen] = None + self._server_thread: threading.Thread | None = None + self._server_process: subprocess.Popen | None = None self._client = None self._sandbox_id = str(uuid.uuid4()) - self._workdir: Optional[str] = None - self._workdir_tmp: Optional[str] = None + self._workdir: str | None = None + self._workdir_tmp: str | None = None self._extra_kwargs = kwargs self._owns_server = server_url is None @@ -447,13 +446,13 @@ def run_code( self, code: str, language: str = "python", - context: Optional[Context] = None, - on_stdout: Optional[OutputHandler[OutputMessage]] = None, - on_stderr: Optional[OutputHandler[OutputMessage]] = None, - on_result: Optional[OutputHandler[Result]] = None, - on_error: Optional[OutputHandler[CodeError]] = None, - envs: Optional[dict[str, str]] = None, - timeout: Optional[float] = None, + context: Context | None = None, + on_stdout: OutputHandler[OutputMessage] | None = None, + on_stderr: OutputHandler[OutputMessage] | None = None, + on_result: OutputHandler[Result] | None = None, + on_error: OutputHandler[CodeError] | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, ) -> ExecutionResult: if not self._started or self._client is None: raise SandboxNotStartedError() @@ -497,8 +496,8 @@ def run_code( stdout_messages: list[OutputMessage] = [] stderr_messages: list[OutputMessage] = [] results: list[Result] = [] - code_error: Optional[CodeError] = None - exit_code: Optional[int] = None + code_error: CodeError | None = None + exit_code: int | None = None current_time = time.time() for output in reply.get("outputs", []): @@ -562,12 +561,12 @@ def run_code( interrupted=was_interrupted, ) - def _get_internal_variable(self, name: str, context: Optional[Context] = None): + def _get_internal_variable(self, name: str, context: Context | None = None): if not self._started or self._client is None: raise SandboxNotStartedError() return self._client.get_variable(name) - def _set_internal_variable(self, name: str, value, context: Optional[Context] = None) -> None: + def _set_internal_variable(self, name: str, value, context: Context | None = None) -> None: if not self._started or self._client is None: raise SandboxNotStartedError() self._client.set_variable(name, value) diff --git a/code_sandboxes/modal_sandbox.py b/code_sandboxes/modal_sandbox.py index 56180ba..704a5af 100644 --- a/code_sandboxes/modal_sandbox.py +++ b/code_sandboxes/modal_sandbox.py @@ -19,7 +19,7 @@ import logging import time import uuid -from typing import Any, Optional +from typing import Any from .base import Sandbox from .exceptions import SandboxConfigurationError, SandboxNotStartedError @@ -56,10 +56,10 @@ class ModalSandbox(Sandbox): def __init__( self, - config: Optional[SandboxConfig] = None, + config: SandboxConfig | None = None, app_name: str = DEFAULT_APP_NAME, - image: Optional[Any] = None, - pip_packages: Optional[list[str]] = None, + image: Any | None = None, + pip_packages: list[str] | None = None, python_executable: str = "python", **kwargs, ): @@ -143,28 +143,28 @@ def stop(self) -> None: try: self._sandbox.terminate() except Exception: - pass + logger.debug("Ignoring error while terminating Modal sandbox", exc_info=True) try: self._sandbox.detach() except Exception: - pass + logger.debug("Ignoring error while detaching Modal sandbox", exc_info=True) self._sandbox = None self._app = None self._started = False if self._info: self._info.status = SandboxStatus.STOPPED - def run_code( + def run_code( # noqa: C901 self, code: str, language: str = "python", - context: Optional[Context] = None, - on_stdout: Optional[OutputHandler[OutputMessage]] = None, - on_stderr: Optional[OutputHandler[OutputMessage]] = None, - on_result: Optional[OutputHandler[Result]] = None, - on_error: Optional[OutputHandler[CodeError]] = None, - envs: Optional[dict[str, str]] = None, - timeout: Optional[float] = None, + context: Context | None = None, + on_stdout: OutputHandler[OutputMessage] | None = None, + on_stderr: OutputHandler[OutputMessage] | None = None, + on_result: OutputHandler[Result] | None = None, + on_error: OutputHandler[CodeError] | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, ) -> ExecutionResult: if not self._started or self._sandbox is None: raise SandboxNotStartedError() @@ -181,14 +181,14 @@ def run_code( stdout_messages: list[OutputMessage] = [] stderr_messages: list[OutputMessage] = [] - code_error: Optional[CodeError] = None + code_error: CodeError | None = None try: process = self._sandbox.exec( self._python_executable, "-c", code, - timeout=int(timeout or self.config.timeout), + timeout=timeout or self.config.timeout, ) stdout_text = process.stdout.read() stderr_text = process.stderr.read() @@ -215,7 +215,7 @@ def run_code( if on_stderr: on_stderr(msg) - exit_code = returncode + exit_code: int | None = None # A non-zero return code with stderr output indicates the user code # raised an exception. Surface it as a code error. if returncode not in (0, None) and stderr_text: @@ -225,6 +225,8 @@ def run_code( code_error = CodeError(name=name, value=value, traceback=stderr_text) if on_error: on_error(code_error) + elif returncode not in (0, None): + exit_code = int(returncode) return ExecutionResult( results=[], @@ -239,16 +241,16 @@ def run_code( ) def _do_interrupt(self) -> bool: - """Modal does not expose fine-grained interrupts; terminate the process.""" + """Modal does not support interrupts.""" return False - def _get_internal_variable(self, name: str, context: Optional[Context] = None): + def _get_internal_variable(self, name: str, context: Context | None = None): raise NotImplementedError( "ModalSandbox executes each snippet in a fresh process and does not " "support cross-call variable access." ) - def _set_internal_variable(self, name: str, value, context: Optional[Context] = None) -> None: + def _set_internal_variable(self, name: str, value, context: Context | None = None) -> None: raise NotImplementedError( "ModalSandbox executes each snippet in a fresh process and does not " "support cross-call variable access." diff --git a/code_sandboxes/monty_sandbox.py b/code_sandboxes/monty_sandbox.py index d5a3e73..0d5bbbe 100644 --- a/code_sandboxes/monty_sandbox.py +++ b/code_sandboxes/monty_sandbox.py @@ -23,7 +23,7 @@ import logging import time import uuid -from typing import Any, Optional +from typing import Any from .base import Sandbox from .exceptions import SandboxConfigurationError, SandboxNotStartedError @@ -60,11 +60,11 @@ class MontySandbox(Sandbox): def __init__( self, - config: Optional[SandboxConfig] = None, + config: SandboxConfig | None = None, type_check: bool = False, - type_check_stubs: Optional[str] = None, - external_functions: Optional[dict[str, Any]] = None, - limits: Optional[dict[str, Any]] = None, + type_check_stubs: str | None = None, + external_functions: dict[str, Any] | None = None, + limits: dict[str, Any] | None = None, **kwargs, ): super().__init__(config) @@ -142,17 +142,17 @@ def _coerce_text(value: Any) -> str: return value.decode("utf-8", errors="replace") return str(value) - def run_code( + def run_code( # noqa: C901 self, code: str, language: str = "python", - context: Optional[Context] = None, - on_stdout: Optional[OutputHandler[OutputMessage]] = None, - on_stderr: Optional[OutputHandler[OutputMessage]] = None, - on_result: Optional[OutputHandler[Result]] = None, - on_error: Optional[OutputHandler[CodeError]] = None, - envs: Optional[dict[str, str]] = None, - timeout: Optional[float] = None, + context: Context | None = None, + on_stdout: OutputHandler[OutputMessage] | None = None, + on_stderr: OutputHandler[OutputMessage] | None = None, + on_result: OutputHandler[Result] | None = None, + on_error: OutputHandler[CodeError] | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, ) -> ExecutionResult: if not self._started or self._repl is None: raise SandboxNotStartedError() @@ -166,7 +166,7 @@ def run_code( stdout_messages: list[OutputMessage] = [] stderr_messages: list[OutputMessage] = [] results: list[Result] = [] - code_error: Optional[CodeError] = None + code_error: CodeError | None = None # Fresh collector per call so we only capture this snippet's output. collector = self._collect_streams() @@ -224,12 +224,12 @@ def run_code( completed_at=time.time(), ) - def _get_internal_variable(self, name: str, context: Optional[Context] = None): + def _get_internal_variable(self, name: str, context: Context | None = None): if not self._started or self._repl is None: raise SandboxNotStartedError() return self._repl.feed_run(name) - def _set_internal_variable(self, name: str, value, context: Optional[Context] = None) -> None: + def _set_internal_variable(self, name: str, value, context: Context | None = None) -> None: if not self._started or self._repl is None: raise SandboxNotStartedError() self._repl.feed_run(f"{name} = {value!r}") diff --git a/docs/docs/sandboxes/index.mdx b/docs/docs/sandboxes/index.mdx index 430a4a0..9709da7 100644 --- a/docs/docs/sandboxes/index.mdx +++ b/docs/docs/sandboxes/index.mdx @@ -271,7 +271,7 @@ with Sandbox.create( Cloud-based execution with full isolation, GPU support, snapshots, and persistence. -- **Requirements:** `code-sandboxes[datalayer]`. +- **Requirements:** `code-sandboxes[datalayer]` (installs `agent_runtimes`). - **Parameters:** `token` (defaults to `DATALAYER_API_KEY`), `run_url`, `snapshot_name`, plus creation options like `environment`, `gpu`, `cpu`, `memory`. diff --git a/pyproject.toml b/pyproject.toml index e389c56..7a46c57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,23 +21,22 @@ classifiers = [ "Programming Language :: Python :: 3", ] dependencies = [ - "pydantic>=2.0", - "datalayer-core", + "jupyter-kernel-client>=0.12.0", "jupyter-server", - "jupyter-kernel-client>=0.11.0", "jupyter-server-client", + "pydantic>=2.0", ] [project.optional-dependencies] -datalayer = ["datalayer_core"] +datalayer = ["agent_runtimes>=1.0.16"] docker = ["docker>=6.0"] -colab = ["jupyter-kernel-client>=0.10"] +colab = ["jupyter-kernel-client>=0.12.0"] monty = ["pydantic-monty"] modal = ["modal>=0.64"] all = [ - "datalayer_core", + "agent_runtimes", "docker>=6.0", - "jupyter-kernel-client>=0.10", + "jupyter-kernel-client>=0.12.0", "pydantic-monty", "modal>=0.64", ] diff --git a/tests/test_factory.py b/tests/test_factory.py index ca17ed8..14bebfb 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -7,9 +7,14 @@ import pytest from code_sandboxes.base import Sandbox, SandboxVariant +from code_sandboxes.colab_sandbox import ColabSandbox +from code_sandboxes.datalayer_sandbox import DatalayerSandbox +from code_sandboxes.docker_sandbox import DockerSandbox from code_sandboxes.eval_sandbox import EvalSandbox from code_sandboxes.jupyter_sandbox import JupyterSandbox +from code_sandboxes.modal_sandbox import ModalSandbox from code_sandboxes.models import SandboxConfig +from code_sandboxes.monty_sandbox import MontySandbox class TestSandboxFactory: @@ -56,3 +61,52 @@ def test_create_invalid_variant(self): """Test error for invalid variant.""" with pytest.raises(ValueError): Sandbox.create(variant="invalid-variant") + + @pytest.mark.parametrize( + "variant,expected_type", + [ + ("eval", EvalSandbox), + ("jupyter", JupyterSandbox), + ("docker", DockerSandbox), + ("datalayer", DatalayerSandbox), + ("colab", ColabSandbox), + ("monty", MontySandbox), + ("modal", ModalSandbox), + ], + ) + def test_create_all_supported_variants(self, variant, expected_type): + """Test that all supported variants resolve to the expected sandbox class.""" + sandbox = Sandbox.create(variant=variant) + assert isinstance(sandbox, expected_type) + + def test_create_default_variant_is_datalayer(self): + """Test that omitting variant uses the datalayer sandbox by default.""" + sandbox = Sandbox.create() + assert isinstance(sandbox, DatalayerSandbox) + + def test_create_colab_forwards_connection_kwargs(self): + """Test that Colab-specific connection kwargs are propagated.""" + sandbox = Sandbox.create( + variant="colab", + server_url="https://colab-host.example", + kernel_id="kernel-id", + proxy_token="proxy-token", # noqa: S106 + client_agent="agent-name", + ) + assert isinstance(sandbox, ColabSandbox) + assert sandbox._server_url == "https://colab-host.example" + assert sandbox._kernel_id == "kernel-id" + assert sandbox._proxy_token == "proxy-token" # noqa: S105 + + def test_create_datalayer_forwards_runtime_kwargs(self): + """Test that datalayer-specific kwargs are propagated.""" + sandbox = Sandbox.create( + variant="datalayer", + token="api-token", # noqa: S106 + run_url="https://run.example", + snapshot_name="snap-1", + ) + assert isinstance(sandbox, DatalayerSandbox) + assert sandbox._token == "api-token" # noqa: S105 + assert sandbox._run_url == "https://run.example" + assert sandbox._snapshot_name == "snap-1" diff --git a/tests/test_modal_colab_sandbox.py b/tests/test_modal_colab_sandbox.py new file mode 100644 index 0000000..36afd2c --- /dev/null +++ b/tests/test_modal_colab_sandbox.py @@ -0,0 +1,88 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Unit tests for Modal/Colab sandbox execution edge cases.""" + +from unittest.mock import MagicMock + +from code_sandboxes.colab_sandbox import ColabSandbox +from code_sandboxes.modal_sandbox import ModalSandbox +from code_sandboxes.models import SandboxConfig + + +class _FakeStream: + def __init__(self, text: str): + self._text = text + + def read(self): + return self._text + + +class _FakeProcess: + def __init__(self, stdout: str, stderr: str, returncode: int): + self.stdout = _FakeStream(stdout) + self.stderr = _FakeStream(stderr) + self.returncode = returncode + + def wait(self): + return None + + +def _started_modal_with_process(process: _FakeProcess) -> ModalSandbox: + sandbox = ModalSandbox(config=SandboxConfig(timeout=10.0)) + sandbox._started = True + sandbox._sandbox = MagicMock() + sandbox._sandbox.exec.return_value = process + return sandbox + + +def test_modal_preserves_sub_second_timeout(): + """The timeout forwarded to Modal should preserve float precision.""" + sandbox = _started_modal_with_process(_FakeProcess(stdout="", stderr="", returncode=0)) + + sandbox.run_code("print('ok')", timeout=0.5) + + assert sandbox._sandbox.exec.call_args.kwargs["timeout"] == 0.5 + + +def test_modal_code_error_does_not_set_exit_code(): + """Python exceptions should be surfaced as code_error, not exit_code.""" + sandbox = _started_modal_with_process( + _FakeProcess(stdout="", stderr="Traceback\nValueError: boom\n", returncode=1) + ) + + result = sandbox.run_code("raise ValueError('boom')") + + assert result.code_error is not None + assert result.code_error.name == "ValueError" + assert result.exit_code is None + + +def test_modal_nonzero_return_without_stderr_sets_exit_code(): + """A non-zero return without Python traceback should set exit_code.""" + sandbox = _started_modal_with_process(_FakeProcess(stdout="", stderr="", returncode=2)) + + result = sandbox.run_code("import sys; sys.exit(2)") + + assert result.code_error is None + assert result.exit_code == 2 + + +def test_colab_execute_exception_sets_execution_ok_false(): + """Infrastructure execute errors must set execution_ok to False.""" + sandbox = ColabSandbox( + config=SandboxConfig(timeout=10.0), + server_url="https://colab-host.example", + kernel_id="kernel-id", + proxy_token="proxy-token", # noqa: S106 + ) + sandbox._started = True + sandbox._client = MagicMock() + sandbox._client.execute.side_effect = RuntimeError("connection dropped") + + result = sandbox.run_code("print('ok')") + + assert result.execution_ok is False + assert result.execution_error is not None + assert "Failed to execute code" in result.execution_error diff --git a/tests/test_models.py b/tests/test_models.py index a4ce0cc..4bb2bd3 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -45,6 +45,9 @@ def test_sandbox_variant_enum(self): assert SandboxVariantEnum.DOCKER.value == "docker" assert SandboxVariantEnum.JUPYTER.value == "jupyter" assert SandboxVariantEnum.DATALAYER.value == "datalayer" + assert SandboxVariantEnum.COLAB.value == "colab" + assert SandboxVariantEnum.MONTY.value == "monty" + assert SandboxVariantEnum.MODAL.value == "modal" def test_gpu_type_enum(self): """Test GPUType enum values.""" From 9bae748a22caa8e50bbe361213f68490a4603293 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 15:32:24 +0200 Subject: [PATCH 06/21] example --- README.md | 54 ++++++++ code_sandboxes/cli.py | 203 ++++++++++++++++++++++++++++ docs/docs/cli/_category_.yml | 2 + docs/docs/cli/index.mdx | 59 ++++++++ docs/docs/examples/index.mdx | 38 +++++- docs/docs/sandboxes/index.mdx | 29 ++++ examples/Makefile | 21 ++- examples/README.md | 50 +++++-- examples/colab_sandbox_example.py | 51 +++++++ examples/docker_sandbox_example.py | 12 +- examples/eval_sandbox_example.py | 2 +- examples/jupyter_sandbox_example.py | 2 +- examples/modal_sandbox_example.py | 52 +++++++ examples/monty_sandbox_example.py | 38 ++++++ pyproject.toml | 4 + tests/test_cli_repl.py | 72 ++++++++++ 16 files changed, 662 insertions(+), 27 deletions(-) create mode 100644 code_sandboxes/cli.py create mode 100644 docs/docs/cli/_category_.yml create mode 100644 docs/docs/cli/index.mdx create mode 100644 examples/colab_sandbox_example.py create mode 100644 examples/modal_sandbox_example.py create mode 100644 examples/monty_sandbox_example.py create mode 100644 tests/test_cli_repl.py diff --git a/README.md b/README.md index 4734214..b11c3b9 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,29 @@ with Sandbox.create() as sandbox: ) ``` +## CLI REPL + +`code-sandboxes` includes a Typer-based CLI that launches an interactive REPL +against a selected sandbox variant and always terminates created resources on exit. + +```bash +code-sandboxes repl --variant jupyter +code-sandboxes repl --variant monty +code-sandboxes repl --variant modal +code-sandboxes repl --variant colab +``` + +If `--variant` is omitted, the CLI prompts for one. + +Variant notes: + +- `jupyter`: starts a managed local Jupyter server on a random port. +- `monty`: starts a Monty REPL-backed sandbox. +- `modal`: starts a Modal sandbox container. +- `colab`: prompts for runtime URL, kernel ID, and proxy token. + +Exit with `:exit`, `:quit`, or Ctrl+D. + ## Backend Setup Guides Each backend has its own installation, credential, and parameter requirements. @@ -485,6 +508,9 @@ pip install code-sandboxes[modal] modal token new ``` + This is enough for local use: the Modal SDK reads credentials from + `~/.modal.toml` automatically. + 1. Alternatively, create a token in the Modal dashboard (**Settings → API Tokens**) and export it as environment variables: @@ -493,6 +519,34 @@ pip install code-sandboxes[modal] | `MODAL_TOKEN_ID` | Modal token id (starts with `ak-`) | | `MODAL_TOKEN_SECRET` | Modal token secret (starts with `as-`) | +**Do you need both `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`?** + +- For environment-based auth (CI/CD, containers, hosted runners): **yes**, + you need both values because Modal authenticates with a token pair + (public id + secret). +- For local development with `modal token new`: **not necessarily**. The SDK can + authenticate directly from `~/.modal.toml`. + +If you need to export environment variables from your local Modal config, you can +read them from `~/.modal.toml`: + +```bash +python - <<'PY' +import pathlib +import tomllib + +cfg = tomllib.loads(pathlib.Path("~/.modal.toml").expanduser().read_text()) +profile = cfg.get("default", cfg) +token_id = profile.get("token_id") +token_secret = profile.get("token_secret") +if token_id and token_secret: + print(f"export MODAL_TOKEN_ID={token_id}") + print(f"export MODAL_TOKEN_SECRET={token_secret}") +else: + raise SystemExit("Could not find token_id/token_secret in ~/.modal.toml") +PY +``` + **Parameters:** `app_name`, `image` (a prebuilt `modal.Image`), `pip_packages` (extra packages for the default image), `python_executable`. diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py new file mode 100644 index 0000000..57a0578 --- /dev/null +++ b/code_sandboxes/cli.py @@ -0,0 +1,203 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Typer CLI for interactive sandbox REPL sessions.""" + +from __future__ import annotations + +from typing import Any + +import click +import typer + +from . import Sandbox +from .models import Result + +app = typer.Typer(help="Interactive REPL for code-sandboxes variants.") + +_SUPPORTED_REPL_VARIANTS = { + "jupyter", + "docker", + "eval", + "monty", + "colab", + "modal", + "datalayer", +} + +_EXIT_COMMANDS = {":exit", ":quit", "exit", "quit"} + + +@app.callback() +def _root() -> None: + """Code sandboxes CLI.""" + return + + +def _print_result(result: Any) -> None: + if not getattr(result, "execution_ok", True): + msg = getattr(result, "execution_error", None) or "Execution failed" + typer.secho(msg, fg=typer.colors.RED) + return + + for line in getattr(result, "stdout", "").splitlines(): + typer.echo(line) + + for line in getattr(result, "stderr", "").splitlines(): + typer.secho(line, fg=typer.colors.YELLOW) + + code_error = getattr(result, "code_error", None) + if code_error is not None: + typer.secho(f"{code_error.name}: {code_error.value}", fg=typer.colors.RED) + + # Prefer the main result and avoid duplicating stdout. + text = None + results = getattr(result, "results", []) + for item in results: + if isinstance(item, Result) and item.is_main_result: + text = item.text + break + if text: + typer.echo(text) + + +def _resolve_variant(variant: str | None) -> str: + if variant: + selected = variant.strip().lower() + else: + selected = typer.prompt( + "Sandbox variant", + default="jupyter", + show_default=True, + type=click.Choice(sorted(_SUPPORTED_REPL_VARIANTS), case_sensitive=False), + ) + selected = selected.strip().lower() + + if selected not in _SUPPORTED_REPL_VARIANTS: + raise typer.BadParameter( + f"Unsupported variant: {selected}. Supported values: " + + ", ".join(sorted(_SUPPORTED_REPL_VARIANTS)) + ) + return selected + + +def _resolve_variant_kwargs( + variant: str, + server_url: str | None, + kernel_id: str | None, + proxy_token: str | None, + token: str | None, + run_url: str | None, +) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + if variant == "jupyter": + # Match `jupyter console` behavior by launching local Jupyter on random port. + kwargs["port"] = 0 + + if variant == "colab": + kwargs["server_url"] = server_url or typer.prompt("Colab runtime URL (RUNTIME_URL)") + kwargs["kernel_id"] = kernel_id or typer.prompt("Colab kernel id (RUNTIME_ID)") + kwargs["proxy_token"] = proxy_token or typer.prompt( + "Colab runtime proxy token (RUNTIME_PROXY_TOKEN)", + hide_input=True, + ) + + if variant == "datalayer": + if token: + kwargs["token"] = token + if run_url: + kwargs["run_url"] = run_url + + return kwargs + + +@app.command() +def repl( + variant: str | None = typer.Option( + None, + "--variant", + "-v", + help="Sandbox variant (jupyter, docker, eval, monty, colab, modal, datalayer).", + ), + timeout: float = typer.Option(60.0, help="Default code execution timeout (seconds)."), + environment: str | None = typer.Option( + None, + help="Sandbox environment (used by variants such as datalayer).", + ), + server_url: str | None = typer.Option(None, help="Colab runtime URL."), + kernel_id: str | None = typer.Option(None, help="Colab kernel ID."), + proxy_token: str | None = typer.Option(None, help="Colab runtime proxy token."), + token: str | None = typer.Option(None, help="Datalayer API token override."), + run_url: str | None = typer.Option(None, help="Datalayer run URL override."), +) -> None: + """Launch an interactive REPL against the selected sandbox variant. + + The sandbox is always terminated when this command exits. + """ + + selected_variant = _resolve_variant(variant) + sandbox_kwargs = _resolve_variant_kwargs( + selected_variant, + server_url=server_url, + kernel_id=kernel_id, + proxy_token=proxy_token, + token=token, + run_url=run_url, + ) + + typer.secho(f"Starting sandbox variant: {selected_variant}", fg=typer.colors.CYAN) + + try: + with Sandbox.create( + variant=selected_variant, + timeout=timeout, + environment=environment, + **sandbox_kwargs, + ) as sandbox: + sandbox_id = sandbox.sandbox_id or "" + typer.secho( + f"Sandbox started (id={sandbox_id}). Type code and press Enter.", + fg=typer.colors.GREEN, + ) + typer.echo("Use :exit or Ctrl+D to terminate.") + + while True: + try: + code = input(">>> ") + except EOFError: + typer.echo("") + break + except KeyboardInterrupt: + typer.echo("\n(Interrupted. Type :exit to quit.)") + continue + + if not code.strip(): + continue + if code.strip() in _EXIT_COMMANDS: + break + + try: + result = sandbox.run_code(code) + except KeyboardInterrupt: + typer.echo("\n(Execution interrupted.)") + continue + except Exception as exc: + typer.secho(f"Execution failed: {exc}", fg=typer.colors.RED) + continue + + _print_result(result) + except Exception as exc: + typer.secho(f"Failed to start REPL: {exc}", fg=typer.colors.RED) + raise typer.Exit(code=1) from None + + typer.secho("Sandbox terminated.", fg=typer.colors.GREEN) + + +def main() -> None: + app() + + +if __name__ == "__main__": + main() diff --git a/docs/docs/cli/_category_.yml b/docs/docs/cli/_category_.yml new file mode 100644 index 0000000..198c932 --- /dev/null +++ b/docs/docs/cli/_category_.yml @@ -0,0 +1,2 @@ +label: "CLI" +position: 7 diff --git a/docs/docs/cli/index.mdx b/docs/docs/cli/index.mdx new file mode 100644 index 0000000..c23c6a1 --- /dev/null +++ b/docs/docs/cli/index.mdx @@ -0,0 +1,59 @@ +--- +title: CLI REPL +sidebar_position: 1 +--- + +# CLI REPL + +Code Sandboxes provides a Typer-based CLI that launches an interactive REPL +against a selected sandbox variant. + +```bash +code-sandboxes repl --variant jupyter +``` + +## Variant Selection + +You can either pass `--variant` directly or omit it and choose interactively. + +Supported variants: + +- `jupyter` +- `docker` +- `eval` +- `monty` +- `colab` +- `modal` +- `datalayer` + +## Variant-specific Behavior + +- `jupyter`: starts a managed local Jupyter server on a random port. +- `monty`: starts a Monty REPL-backed sandbox. +- `modal`: starts a Modal sandbox container. +- `colab`: prompts for runtime URL, kernel ID, and proxy token. + +## Usage + +```bash +# Interactive variant prompt +code-sandboxes repl + +# Explicit variant +code-sandboxes repl --variant monty + +# Datalayer with overrides +code-sandboxes repl --variant datalayer --token "$DATALAYER_API_KEY" --run-url "https://prod1.datalayer.run" +``` + +## Exiting and Cleanup + +Use any of the following to exit: + +- `:exit` +- `:quit` +- `exit` +- `quit` +- `Ctrl+D` + +On exit, the created sandbox resource is terminated automatically. diff --git a/docs/docs/examples/index.mdx b/docs/docs/examples/index.mdx index c6ca50e..665b993 100644 --- a/docs/docs/examples/index.mdx +++ b/docs/docs/examples/index.mdx @@ -5,11 +5,11 @@ sidebar_position: 6 # Examples -Run the examples from the examples directory. +Run the examples from the `examples/` directory in this repository. ## Eval -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/local_eval_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/eval_sandbox_example.py ```bash make eval @@ -17,7 +17,7 @@ make eval ## Docker -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/local_docker_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/docker_sandbox_example.py ```bash make docker @@ -25,18 +25,44 @@ make docker ## Jupyter -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/local_jupyter_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/jupyter_sandbox_example.py ```bash make jupyter ``` -## Datalayer Runtime +## Monty -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/datalayer_runtime_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/monty_sandbox_example.py + +```bash +make monty +``` + +## Colab + +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/colab_sandbox_example.py + +```bash +make colab +``` + +## Modal + +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/modal_sandbox_example.py + +```bash +make modal +``` + +## Datalayer + +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/datalayer_sandbox_example.py ```bash make datalayer ``` See the Makefile for all targets: https://github.com/datalayer/code-sandboxes/blob/main/examples/Makefile + +For an interactive command-line REPL across variants, see [CLI REPL](/cli). diff --git a/docs/docs/sandboxes/index.mdx b/docs/docs/sandboxes/index.mdx index 9709da7..8b25ddc 100644 --- a/docs/docs/sandboxes/index.mdx +++ b/docs/docs/sandboxes/index.mdx @@ -255,9 +255,38 @@ state). Configure the image with additional pip packages as needed. ```bash modal token new ``` + This is enough for local use: the Modal SDK reads credentials from + `~/.modal.toml` automatically. 3. Alternatively, create a token in the Modal dashboard (**Settings → API Tokens**) and export `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`. +**Do you need both `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`?** + +- For environment-based auth (CI/CD, containers, hosted runners): **yes**, + you need both values because Modal authenticates with a token pair + (public id + secret). +- For local development with `modal token new`: **not necessarily**. The SDK can + authenticate directly from `~/.modal.toml`. + +If you need environment variables, you can export them from your local config: + +```bash +python - <<'PY' +import pathlib +import tomllib + +cfg = tomllib.loads(pathlib.Path("~/.modal.toml").expanduser().read_text()) +profile = cfg.get("default", cfg) +token_id = profile.get("token_id") +token_secret = profile.get("token_secret") +if token_id and token_secret: + print(f"export MODAL_TOKEN_ID={token_id}") + print(f"export MODAL_TOKEN_SECRET={token_secret}") +else: + raise SystemExit("Could not find token_id/token_secret in ~/.modal.toml") +PY +``` + ```python with Sandbox.create( variant="modal", diff --git a/examples/Makefile b/examples/Makefile index ba466f5..c4febae 100644 --- a/examples/Makefile +++ b/examples/Makefile @@ -2,18 +2,27 @@ PYTHON ?= python -.PHONY: all eval docker jupyter datalayer +.PHONY: all eval docker jupyter monty colab modal datalayer -all: eval docker jupyter datalayer +all: eval docker jupyter monty colab modal datalayer eval: - $(PYTHON) local_eval_example.py + $(PYTHON) eval_sandbox_example.py docker: - $(PYTHON) local_docker_example.py + $(PYTHON) docker_sandbox_example.py jupyter: - $(PYTHON) local_jupyter_example.py + $(PYTHON) jupyter_sandbox_example.py + +monty: + $(PYTHON) monty_sandbox_example.py + +colab: + $(PYTHON) colab_sandbox_example.py + +modal: + $(PYTHON) modal_sandbox_example.py datalayer: - $(PYTHON) datalayer_runtime_example.py + $(PYTHON) datalayer_sandbox_example.py diff --git a/examples/README.md b/examples/README.md index 90a8a5a..caef9b1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,18 +4,50 @@ ~ BSD 3-Clause License --> -# Code Sandboxes Examples +[![Datalayer](https://assets.datalayer.tech/datalayer-25.svg)](https://datalayer.io) -Run any example from the code-sandboxes package root: +[![Become a Sponsor](https://img.shields.io/static/v1?label=Become%20a%20Sponsor&message=%E2%9D%A4&logo=GitHub&style=flat&color=1ABC9C)](https://github.com/sponsors/datalayer) + +# { } Code Sandboxes Examples + +Supported sandbox variants: + +- `jupyter` +- `docker` +- `eval` +- `monty` +- `colab` +- `modal` +- `datalayer` + +Run examples from the `examples/` directory: + +```bash +python eval_sandbox_example.py +python jupyter_sandbox_example.py +python docker_sandbox_example.py +python monty_sandbox_example.py +python colab_sandbox_example.py +python modal_sandbox_example.py +python datalayer_sandbox_example.py +``` + +You can also run via Make targets: ```bash -python examples/local_eval_example.py -python examples/local_docker_example.py -python examples/datalayer_runtime_example.py +make eval +make jupyter +make docker +make monty +make colab +make modal +make datalayer ``` -Notes: +Notes by variant: -- `docker` requires Docker support and a `DockerSandbox` implementation. -- Build the image with: `docker build -t code-sandboxes-jupyter:latest -f docker/Dockerfile .` -- `datalayer` requires Datalayer runtime credentials/config. +- `docker`: requires Docker support and a Docker image (for example `code-sandboxes-jupyter:latest`). +- `monty`: requires `code-sandboxes[monty]` (`pydantic-monty`). +- `colab`: requires `RUNTIME_URL`, `RUNTIME_ID`, and `RUNTIME_PROXY_TOKEN`. +- `modal`: requires `MODAL_TOKEN_ID`/`MODAL_TOKEN_SECRET` or `~/.modal.toml`. +- `datalayer`: requires Datalayer runtime credentials/config. diff --git a/examples/colab_sandbox_example.py b/examples/colab_sandbox_example.py new file mode 100644 index 0000000..0f80ca8 --- /dev/null +++ b/examples/colab_sandbox_example.py @@ -0,0 +1,51 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""Example: colab sandbox (Google Colab runtime). + +Run with: + RUNTIME_URL=... RUNTIME_ID=... RUNTIME_PROXY_TOKEN=... \\ + python examples/colab_sandbox_example.py +""" + +import os + +from code_sandboxes import Sandbox + + +def _require(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"Missing required environment variable: {name}") + return value + + +def main() -> None: + try: + runtime_url = _require("RUNTIME_URL") + runtime_id = _require("RUNTIME_ID") + runtime_proxy_token = _require("RUNTIME_PROXY_TOKEN") + + with Sandbox.create( + variant="colab", + timeout=60, + server_url=runtime_url, + kernel_id=runtime_id, + proxy_token=runtime_proxy_token, + ) as sandbox: + sandbox.run_code("x = 40") + result = sandbox.run_code("x + 2") + print("result:", result.text) + + result = sandbox.run_code("print('hello from colab')") + print("stdout:", result.stdout) + except Exception as exc: + print("colab example failed:", exc) + print( + "Hint: export RUNTIME_URL, RUNTIME_ID, and RUNTIME_PROXY_TOKEN " + "from an active Colab runtime session." + ) + + +if __name__ == "__main__": + main() diff --git a/examples/docker_sandbox_example.py b/examples/docker_sandbox_example.py index b97ad88..3312ba1 100644 --- a/examples/docker_sandbox_example.py +++ b/examples/docker_sandbox_example.py @@ -4,7 +4,7 @@ """Example: docker sandbox (container isolation). Run with: - python examples/local_docker_example.py + python examples/docker_sandbox_example.py Note: This requires Docker support and the `datalayer/code-sandboxes:latest` image. Build it with: make -C .. build-docker @@ -18,12 +18,16 @@ def main() -> None: with Sandbox.create( variant="docker", timeout=30, - image="datalayer/code-sandboxes:latest", + image="code-sandboxes-jupyter:latest", ) as sandbox: result = sandbox.run_code("print('hello from docker')") print("stdout:", result.stdout) - result = sandbox.run_code("fail") - print("stderr:", result.error) + error_result = sandbox.run_code("raise RuntimeError('boom')") + if error_result.code_error: + print( + "code_error:", + f"{error_result.code_error.name}: {error_result.code_error.value}", + ) cmd = sandbox.commands.run("python", "-c", "print(123)") print("cmd:", cmd.stdout.strip()) except ModuleNotFoundError as exc: diff --git a/examples/eval_sandbox_example.py b/examples/eval_sandbox_example.py index c11e91c..cf692fa 100644 --- a/examples/eval_sandbox_example.py +++ b/examples/eval_sandbox_example.py @@ -4,7 +4,7 @@ """Example: eval sandbox (no isolation). Run with: - python examples/local_eval_example.py + python examples/eval_sandbox_example.py """ from code_sandboxes import Sandbox diff --git a/examples/jupyter_sandbox_example.py b/examples/jupyter_sandbox_example.py index f5afd94..551e142 100644 --- a/examples/jupyter_sandbox_example.py +++ b/examples/jupyter_sandbox_example.py @@ -4,7 +4,7 @@ """Example: jupyter sandbox (Jupyter kernel isolation with persistent state). Run with: - python examples/local_jupyter_example.py + python examples/jupyter_sandbox_example.py Note: This requires jupyter_server and jupyter-kernel-client. """ diff --git a/examples/modal_sandbox_example.py b/examples/modal_sandbox_example.py new file mode 100644 index 0000000..cf16ccd --- /dev/null +++ b/examples/modal_sandbox_example.py @@ -0,0 +1,52 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""Example: modal sandbox (cloud container execution). + +Run with: + python examples/modal_sandbox_example.py + +Auth options: +- `modal token new` (writes ~/.modal.toml), or +- set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET. +""" + +import os +from pathlib import Path + +from code_sandboxes import Sandbox + + +def _has_modal_auth() -> bool: + if os.environ.get("MODAL_TOKEN_ID") and os.environ.get("MODAL_TOKEN_SECRET"): + return True + return Path.home().joinpath(".modal.toml").exists() + + +def main() -> None: + if not _has_modal_auth(): + print("Modal auth not found.") + print("Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET or run: modal token new") + return + + try: + with Sandbox.create( + variant="modal", + timeout=60, + pip_packages=["numpy"], + ) as sandbox: + result = sandbox.run_code("import numpy as np; print(int(np.arange(5).sum()))") + print("stdout:", result.stdout.strip()) + + error_result = sandbox.run_code("raise RuntimeError('modal failure example')") + if error_result.code_error: + print( + "code_error:", + f"{error_result.code_error.name}: {error_result.code_error.value}", + ) + except Exception as exc: + print("modal example failed:", exc) + + +if __name__ == "__main__": + main() diff --git a/examples/monty_sandbox_example.py b/examples/monty_sandbox_example.py new file mode 100644 index 0000000..9c9fd6c --- /dev/null +++ b/examples/monty_sandbox_example.py @@ -0,0 +1,38 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""Example: monty sandbox (secure in-process interpreter). + +Run with: + python examples/monty_sandbox_example.py + +Note: This requires code-sandboxes[monty] / pydantic-monty. +""" + +from code_sandboxes import Sandbox + + +def main() -> None: + try: + with Sandbox.create(variant="monty", timeout=30) as sandbox: + sandbox.run_code("x = 21") + result = sandbox.run_code("x * 2") + print("result:", result.text) + + result = sandbox.run_code("print('hello from monty')") + print("stdout:", result.stdout) + + error_result = sandbox.run_code("raise ValueError('monty failure example')") + if error_result.code_error: + print( + "code_error:", + f"{error_result.code_error.name}: {error_result.code_error.value}", + ) + except ModuleNotFoundError as exc: + print("monty sandbox is not available:", exc) + except Exception as exc: + print("monty example failed:", exc) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 7a46c57..91fafa0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,12 @@ dependencies = [ "jupyter-server", "jupyter-server-client", "pydantic>=2.0", + "typer>=0.12.0", ] +[project.scripts] +code-sandboxes = "code_sandboxes.cli:main" + [project.optional-dependencies] datalayer = ["agent_runtimes>=1.0.16"] docker = ["docker>=6.0"] diff --git a/tests/test_cli_repl.py b/tests/test_cli_repl.py new file mode 100644 index 0000000..a86d31b --- /dev/null +++ b/tests/test_cli_repl.py @@ -0,0 +1,72 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Tests for the Typer REPL CLI.""" + +from __future__ import annotations + +from typer.testing import CliRunner + +from code_sandboxes import cli as sandbox_cli +from code_sandboxes.models import ExecutionResult, Logs, Result + + +class _FakeSandbox: + def __init__(self): + self.sandbox_id = "sandbox-123" + self.exited = False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.exited = True + + def run_code(self, code: str): + return ExecutionResult( + results=[Result(data={"text/plain": f"echo:{code}"}, is_main_result=True)], + logs=Logs(), + execution_ok=True, + ) + + +def test_repl_jupyter_variant_uses_random_port(monkeypatch): + runner = CliRunner() + captured: dict = {} + fake_sandbox = _FakeSandbox() + + def _fake_create(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return fake_sandbox + + monkeypatch.setattr(sandbox_cli.Sandbox, "create", staticmethod(_fake_create)) + + result = runner.invoke(sandbox_cli.app, ["repl", "--variant", "jupyter"], input=":exit\n") + + assert result.exit_code == 0 + assert captured["kwargs"]["variant"] == "jupyter" + assert captured["kwargs"]["port"] == 0 + assert fake_sandbox.exited is True + + +def test_repl_colab_prompts_and_forwards_credentials(monkeypatch): + runner = CliRunner() + captured: dict = {} + + def _fake_create(*args, **kwargs): + captured["kwargs"] = kwargs + return _FakeSandbox() + + monkeypatch.setattr(sandbox_cli.Sandbox, "create", staticmethod(_fake_create)) + + # Prompts: server_url, kernel_id, proxy_token, then repl command. + user_input = "https://colab-host.example\nkernel-abc\nproxy-xyz\n:exit\n" + result = runner.invoke(sandbox_cli.app, ["repl", "--variant", "colab"], input=user_input) + + assert result.exit_code == 0 + assert captured["kwargs"]["variant"] == "colab" + assert captured["kwargs"]["server_url"] == "https://colab-host.example" + assert captured["kwargs"]["kernel_id"] == "kernel-abc" + assert captured["kwargs"]["proxy_token"] == "proxy-xyz" # noqa: S105 From 1e174518f53959c8b7e3e720f2a528db44dabef7 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 16:41:12 +0200 Subject: [PATCH 07/21] colab --- CHANGELOG.md | 4 ++ README.md | 31 ++++++++++--- code_sandboxes/cli.py | 74 ++++++++++++++++++++----------- code_sandboxes/colab_sandbox.py | 42 ++++++++++++++++-- code_sandboxes/modal_sandbox.py | 14 +++++- docs/docs/cli/index.mdx | 10 +++-- pyproject.toml | 3 +- tests/test_cli_repl.py | 17 +++++++ tests/test_modal_colab_sandbox.py | 63 ++++++++++++++++++++++++-- 9 files changed, 214 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bff2ed4..e5096bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +- Added `ColabSandbox(use_browser_bridge=True)` to obtain the Colab runtime + connection details (`server_url` / `kernel_id` / `proxy_token`) from an + authenticated Colab browser session via `jupyter-kernel-client`'s browser + bridge, instead of requiring them to be supplied manually. - Breaking change: sandbox variant names are `eval`, `docker`, `jupyter`, and `datalayer`. - Removed support for the older `local-*` variant names from the public API and documentation. - Clarified in the documentation that `Sandbox.create()` defaults to `datalayer`. diff --git a/README.md b/README.md index b11c3b9..116cd89 100644 --- a/README.md +++ b/README.md @@ -217,14 +217,15 @@ with Sandbox.create() as sandbox: ## CLI REPL -`code-sandboxes` includes a Typer-based CLI that launches an interactive REPL +`sandbox` includes a Typer-based CLI that launches an interactive REPL against a selected sandbox variant and always terminates created resources on exit. +The `code-sandboxes` command remains available as an alias. ```bash -code-sandboxes repl --variant jupyter -code-sandboxes repl --variant monty -code-sandboxes repl --variant modal -code-sandboxes repl --variant colab +sandbox repl --variant jupyter +sandbox repl --variant monty +sandbox repl --variant modal +sandbox repl --variant colab ``` If `--variant` is omitted, the CLI prompts for one. @@ -486,6 +487,26 @@ with Sandbox.create( print(sandbox.run_code("x + 2").text) # 42 ``` +**Browser bridge (no manual DevTools):** instead of copying the values by hand, +set `use_browser_bridge=True` to obtain them from an authenticated Colab browser +session. This reuses `jupyter-kernel-client`'s browser bridge: a short-lived +localhost WebSocket server is opened with a one-time token, a Colab page is +launched, and the authenticated tab posts the runtime `server_url` / +`kernel_id` / `proxy_token` back to the process. Google credentials never leave +the browser. + +```python +from code_sandboxes import Sandbox + +with Sandbox.create(variant="colab", use_browser_bridge=True) as sandbox: + print(sandbox.run_code("print(1 + 1)").text) +``` + +Install the bridge extra with `pip install 'jupyter-kernel-client[bridge]'`. The +browser side must run a cooperating page/extension/userscript that reads the +token and port from the launch URL and sends the payload (see the +`jupyter-kernel-client` README for the contract). + ### 6. Modal Runs code in a [Modal](https://modal.com/docs/guide) cloud sandbox, providing diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py index 57a0578..08e3b1d 100644 --- a/code_sandboxes/cli.py +++ b/code_sandboxes/cli.py @@ -29,10 +29,11 @@ _EXIT_COMMANDS = {":exit", ":quit", "exit", "quit"} -@app.callback() -def _root() -> None: +@app.callback(invoke_without_command=True) +def _root(ctx: typer.Context) -> None: """Code sandboxes CLI.""" - return + if ctx.invoked_subcommand is None: + _run_repl(variant="jupyter") def _print_result(result: Any) -> None: @@ -113,30 +114,16 @@ def _resolve_variant_kwargs( return kwargs -@app.command() -def repl( - variant: str | None = typer.Option( - None, - "--variant", - "-v", - help="Sandbox variant (jupyter, docker, eval, monty, colab, modal, datalayer).", - ), - timeout: float = typer.Option(60.0, help="Default code execution timeout (seconds)."), - environment: str | None = typer.Option( - None, - help="Sandbox environment (used by variants such as datalayer).", - ), - server_url: str | None = typer.Option(None, help="Colab runtime URL."), - kernel_id: str | None = typer.Option(None, help="Colab kernel ID."), - proxy_token: str | None = typer.Option(None, help="Colab runtime proxy token."), - token: str | None = typer.Option(None, help="Datalayer API token override."), - run_url: str | None = typer.Option(None, help="Datalayer run URL override."), +def _run_repl( + variant: str | None = None, + timeout: float = 60.0, + environment: str | None = None, + server_url: str | None = None, + kernel_id: str | None = None, + proxy_token: str | None = None, + token: str | None = None, + run_url: str | None = None, ) -> None: - """Launch an interactive REPL against the selected sandbox variant. - - The sandbox is always terminated when this command exits. - """ - selected_variant = _resolve_variant(variant) sandbox_kwargs = _resolve_variant_kwargs( selected_variant, @@ -195,6 +182,41 @@ def repl( typer.secho("Sandbox terminated.", fg=typer.colors.GREEN) +@app.command() +def repl( + variant: str | None = typer.Option( + None, + "--variant", + "-v", + help="Sandbox variant (jupyter, docker, eval, monty, colab, modal, datalayer).", + ), + timeout: float = typer.Option(60.0, help="Default code execution timeout (seconds)."), + environment: str | None = typer.Option( + None, + help="Sandbox environment (used by variants such as datalayer).", + ), + server_url: str | None = typer.Option(None, help="Colab runtime URL."), + kernel_id: str | None = typer.Option(None, help="Colab kernel ID."), + proxy_token: str | None = typer.Option(None, help="Colab runtime proxy token."), + token: str | None = typer.Option(None, help="Datalayer API token override."), + run_url: str | None = typer.Option(None, help="Datalayer run URL override."), +) -> None: + """Launch an interactive REPL against the selected sandbox variant. + + The sandbox is always terminated when this command exits. + """ + _run_repl( + variant=variant, + timeout=timeout, + environment=environment, + server_url=server_url, + kernel_id=kernel_id, + proxy_token=proxy_token, + token=token, + run_url=run_url, + ) + + def main() -> None: app() diff --git a/code_sandboxes/colab_sandbox.py b/code_sandboxes/colab_sandbox.py index 9c1782c..633a287 100644 --- a/code_sandboxes/colab_sandbox.py +++ b/code_sandboxes/colab_sandbox.py @@ -56,6 +56,8 @@ def __init__( kernel_id: str | None = None, proxy_token: str | None = None, client_agent: str = "code-sandboxes", + use_browser_bridge: bool = False, + bridge_timeout: float = 60.0, **kwargs, ): super().__init__(config) @@ -65,6 +67,10 @@ def __init__( self._kernel_id = kernel_id or extras.get("kernel_id") self._proxy_token = proxy_token or extras.get("proxy_token") self._client_agent = client_agent + self._use_browser_bridge = use_browser_bridge or bool( + extras.get("use_browser_bridge") + ) + self._bridge_timeout = bridge_timeout self._client = None self._sandbox_id = str(uuid.uuid4()) self._extra_kwargs = kwargs @@ -87,10 +93,19 @@ def start(self) -> None: if self._started: return - if not self._server_url or not self._kernel_id or not self._proxy_token: + # Obtain the runtime details from an authenticated browser session when + # they were not supplied and the browser bridge is enabled. + if self._use_browser_bridge and ( + not self._server_url or not self._proxy_token + ): + self._acquire_via_browser_bridge() + + if not self._server_url or not self._proxy_token: raise SandboxConfigurationError( - "ColabSandbox requires 'server_url', 'kernel_id' and 'proxy_token'. " - "These are typically obtained from a Colab runtime assignment API." + "ColabSandbox requires 'server_url' and 'proxy_token' (and " + "optionally 'kernel_id'). Provide them directly, or set " + "use_browser_bridge=True to obtain them from an authenticated " + "Colab browser session." ) try: @@ -121,6 +136,27 @@ def start(self) -> None: ) self._started = True + def _acquire_via_browser_bridge(self) -> None: + """Fill in runtime details from an authenticated Colab browser session. + + Uses ``jupyter-kernel-client``'s reusable browser bridge: a local + WebSocket server is opened, the Colab page is launched with a one-time + token, and the authenticated browser posts the runtime + ``server_url`` / ``kernel_id`` / ``proxy_token`` back to this process. + """ + try: + from jupyter_kernel_client import request_colab_connection + except ImportError as exc: + raise SandboxConfigurationError( + "The browser bridge requires jupyter-kernel-client[bridge]. " + "Install it with: pip install 'jupyter-kernel-client[bridge]'" + ) from exc + + info = request_colab_connection(timeout=self._bridge_timeout) + self._server_url = self._server_url or info.server_url + self._proxy_token = self._proxy_token or info.proxy_token + self._kernel_id = self._kernel_id or info.kernel_id + def _setup_tool_caller(self) -> None: """Keep tool calling on the client side for Colab sandboxes.""" return diff --git a/code_sandboxes/modal_sandbox.py b/code_sandboxes/modal_sandbox.py index 704a5af..5b9d43e 100644 --- a/code_sandboxes/modal_sandbox.py +++ b/code_sandboxes/modal_sandbox.py @@ -16,6 +16,7 @@ from __future__ import annotations +import math import logging import time import uuid @@ -40,6 +41,13 @@ logger = logging.getLogger(__name__) DEFAULT_APP_NAME = "code-sandboxes" +DEFAULT_MODAL_PYTHON_VERSION = "3.12" + + +def _modal_exec_timeout_seconds(timeout: float | None, default: float) -> int: + """Return a Modal-compatible timeout in integer seconds.""" + value = timeout if timeout is not None else default + return max(1, int(math.ceil(value))) class ModalSandbox(Sandbox): @@ -60,6 +68,7 @@ def __init__( app_name: str = DEFAULT_APP_NAME, image: Any | None = None, pip_packages: list[str] | None = None, + python_version: str = DEFAULT_MODAL_PYTHON_VERSION, python_executable: str = "python", **kwargs, ): @@ -67,6 +76,7 @@ def __init__( self._app_name = app_name self._image = image self._pip_packages = pip_packages or [] + self._python_version = python_version self._python_executable = python_executable self._app = None self._sandbox = None @@ -103,7 +113,7 @@ def start(self) -> None: image = self._image if image is None: - image = modal.Image.debian_slim() + image = modal.Image.debian_slim(python_version=self._python_version) if self._pip_packages: image = image.pip_install(*self._pip_packages) @@ -188,7 +198,7 @@ def run_code( # noqa: C901 self._python_executable, "-c", code, - timeout=timeout or self.config.timeout, + timeout=_modal_exec_timeout_seconds(timeout, self.config.timeout), ) stdout_text = process.stdout.read() stderr_text = process.stderr.read() diff --git a/docs/docs/cli/index.mdx b/docs/docs/cli/index.mdx index c23c6a1..b0ad1d8 100644 --- a/docs/docs/cli/index.mdx +++ b/docs/docs/cli/index.mdx @@ -9,9 +9,11 @@ Code Sandboxes provides a Typer-based CLI that launches an interactive REPL against a selected sandbox variant. ```bash -code-sandboxes repl --variant jupyter +sandbox repl --variant jupyter ``` +`code-sandboxes` remains available as an alias. + ## Variant Selection You can either pass `--variant` directly or omit it and choose interactively. @@ -37,13 +39,13 @@ Supported variants: ```bash # Interactive variant prompt -code-sandboxes repl +sandbox repl # Explicit variant -code-sandboxes repl --variant monty +sandbox repl --variant monty # Datalayer with overrides -code-sandboxes repl --variant datalayer --token "$DATALAYER_API_KEY" --run-url "https://prod1.datalayer.run" +sandbox repl --variant datalayer --token "$DATALAYER_API_KEY" --run-url "https://prod1.datalayer.run" ``` ## Exiting and Cleanup diff --git a/pyproject.toml b/pyproject.toml index 91fafa0..7ff7c26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3", ] dependencies = [ - "jupyter-kernel-client>=0.12.0", + "jupyter-kernel-client>=0.13.0", "jupyter-server", "jupyter-server-client", "pydantic>=2.0", @@ -30,6 +30,7 @@ dependencies = [ [project.scripts] code-sandboxes = "code_sandboxes.cli:main" +sandbox = "code_sandboxes.cli:main" [project.optional-dependencies] datalayer = ["agent_runtimes>=1.0.16"] diff --git a/tests/test_cli_repl.py b/tests/test_cli_repl.py index a86d31b..3fcca90 100644 --- a/tests/test_cli_repl.py +++ b/tests/test_cli_repl.py @@ -70,3 +70,20 @@ def _fake_create(*args, **kwargs): assert captured["kwargs"]["server_url"] == "https://colab-host.example" assert captured["kwargs"]["kernel_id"] == "kernel-abc" assert captured["kwargs"]["proxy_token"] == "proxy-xyz" # noqa: S105 + + +def test_root_defaults_to_jupyter_repl(monkeypatch): + runner = CliRunner() + captured: dict = {} + + def _fake_create(*args, **kwargs): + captured["kwargs"] = kwargs + return _FakeSandbox() + + monkeypatch.setattr(sandbox_cli.Sandbox, "create", staticmethod(_fake_create)) + + result = runner.invoke(sandbox_cli.app, [], input=":exit\n") + + assert result.exit_code == 0 + assert captured["kwargs"]["variant"] == "jupyter" + assert captured["kwargs"]["port"] == 0 diff --git a/tests/test_modal_colab_sandbox.py b/tests/test_modal_colab_sandbox.py index 36afd2c..84b12f1 100644 --- a/tests/test_modal_colab_sandbox.py +++ b/tests/test_modal_colab_sandbox.py @@ -4,6 +4,7 @@ """Unit tests for Modal/Colab sandbox execution edge cases.""" +import sys from unittest.mock import MagicMock from code_sandboxes.colab_sandbox import ColabSandbox @@ -37,13 +38,13 @@ def _started_modal_with_process(process: _FakeProcess) -> ModalSandbox: return sandbox -def test_modal_preserves_sub_second_timeout(): - """The timeout forwarded to Modal should preserve float precision.""" +def test_modal_sub_second_timeout_is_rounded_for_modal_exec(): + """Modal exec expects integer seconds, so sub-second values are rounded up.""" sandbox = _started_modal_with_process(_FakeProcess(stdout="", stderr="", returncode=0)) sandbox.run_code("print('ok')", timeout=0.5) - assert sandbox._sandbox.exec.call_args.kwargs["timeout"] == 0.5 + assert sandbox._sandbox.exec.call_args.kwargs["timeout"] == 1 def test_modal_code_error_does_not_set_exit_code(): @@ -86,3 +87,59 @@ def test_colab_execute_exception_sets_execution_ok_false(): assert result.execution_ok is False assert result.execution_error is not None assert "Failed to execute code" in result.execution_error + + +def test_modal_start_uses_supported_default_python_version(monkeypatch): + """Default Modal image should pin a Modal-supported Python series.""" + + class _FakeImage: + def pip_install(self, *_args): + return self + + class _FakeApp: + pass + + class _FakeSandboxObj: + object_id = "modal-object-id" + + def terminate(self): + return None + + def detach(self): + return None + + captured: dict = {} + + class _FakeModal: + class App: + @staticmethod + def lookup(_name, create_if_missing=False): + assert create_if_missing is True + return _FakeApp() + + class Image: + @staticmethod + def debian_slim(*, python_version): + captured["python_version"] = python_version + return _FakeImage() + + class Secret: + @staticmethod + def from_dict(_values): + return object() + + class Sandbox: + @staticmethod + def create(**kwargs): + captured["create_kwargs"] = kwargs + return _FakeSandboxObj() + + monkeypatch.setitem(sys.modules, "modal", _FakeModal) + + sandbox = ModalSandbox(config=SandboxConfig(timeout=10.0, max_lifetime=30.0)) + sandbox.start() + + assert captured["python_version"] == "3.12" + assert sandbox.is_started is True + + sandbox.stop() From 59af2dc866294f3b522241999500da45d56d523a Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 16:45:56 +0200 Subject: [PATCH 08/21] fix: build --- code_sandboxes/cli.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py index 08e3b1d..070ce36 100644 --- a/code_sandboxes/cli.py +++ b/code_sandboxes/cli.py @@ -8,7 +8,6 @@ from typing import Any -import click import typer from . import Sandbox @@ -71,7 +70,6 @@ def _resolve_variant(variant: str | None) -> str: "Sandbox variant", default="jupyter", show_default=True, - type=click.Choice(sorted(_SUPPORTED_REPL_VARIANTS), case_sensitive=False), ) selected = selected.strip().lower() From e939a595e9b410f2585acf135004792cc140b2dc Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 16:51:46 +0200 Subject: [PATCH 09/21] lint --- code_sandboxes/colab_sandbox.py | 8 ++------ code_sandboxes/modal_sandbox.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/code_sandboxes/colab_sandbox.py b/code_sandboxes/colab_sandbox.py index 633a287..f85ab24 100644 --- a/code_sandboxes/colab_sandbox.py +++ b/code_sandboxes/colab_sandbox.py @@ -67,9 +67,7 @@ def __init__( self._kernel_id = kernel_id or extras.get("kernel_id") self._proxy_token = proxy_token or extras.get("proxy_token") self._client_agent = client_agent - self._use_browser_bridge = use_browser_bridge or bool( - extras.get("use_browser_bridge") - ) + self._use_browser_bridge = use_browser_bridge or bool(extras.get("use_browser_bridge")) self._bridge_timeout = bridge_timeout self._client = None self._sandbox_id = str(uuid.uuid4()) @@ -95,9 +93,7 @@ def start(self) -> None: # Obtain the runtime details from an authenticated browser session when # they were not supplied and the browser bridge is enabled. - if self._use_browser_bridge and ( - not self._server_url or not self._proxy_token - ): + if self._use_browser_bridge and (not self._server_url or not self._proxy_token): self._acquire_via_browser_bridge() if not self._server_url or not self._proxy_token: diff --git a/code_sandboxes/modal_sandbox.py b/code_sandboxes/modal_sandbox.py index 5b9d43e..4fd80de 100644 --- a/code_sandboxes/modal_sandbox.py +++ b/code_sandboxes/modal_sandbox.py @@ -16,8 +16,8 @@ from __future__ import annotations -import math import logging +import math import time import uuid from typing import Any From 2ac13922e575f880e01a8a6bd87a4526cb5ca247 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 19:10:03 +0200 Subject: [PATCH 10/21] add gpu --- code_sandboxes/cli.py | 12 +++++++ code_sandboxes/modal_sandbox.py | 38 ++++++++++++++++++++ tests/test_cli_repl.py | 21 +++++++++++ tests/test_modal_colab_sandbox.py | 60 +++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+) diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py index 070ce36..595897f 100644 --- a/code_sandboxes/cli.py +++ b/code_sandboxes/cli.py @@ -88,6 +88,7 @@ def _resolve_variant_kwargs( proxy_token: str | None, token: str | None, run_url: str | None, + gpu: str | None, ) -> dict[str, Any]: kwargs: dict[str, Any] = {} @@ -109,6 +110,9 @@ def _resolve_variant_kwargs( if run_url: kwargs["run_url"] = run_url + if variant in {"modal", "datalayer"} and gpu: + kwargs["gpu"] = gpu + return kwargs @@ -121,6 +125,7 @@ def _run_repl( proxy_token: str | None = None, token: str | None = None, run_url: str | None = None, + gpu: str | None = None, ) -> None: selected_variant = _resolve_variant(variant) sandbox_kwargs = _resolve_variant_kwargs( @@ -130,6 +135,7 @@ def _run_repl( proxy_token=proxy_token, token=token, run_url=run_url, + gpu=gpu, ) typer.secho(f"Starting sandbox variant: {selected_variant}", fg=typer.colors.CYAN) @@ -198,6 +204,11 @@ def repl( proxy_token: str | None = typer.Option(None, help="Colab runtime proxy token."), token: str | None = typer.Option(None, help="Datalayer API token override."), run_url: str | None = typer.Option(None, help="Datalayer run URL override."), + gpu: str | None = typer.Option( + None, + "--gpu", + help="GPU flavor for supported variants (e.g., modal/datalayer: T4, A10G, A100, H100).", + ), ) -> None: """Launch an interactive REPL against the selected sandbox variant. @@ -212,6 +223,7 @@ def repl( proxy_token=proxy_token, token=token, run_url=run_url, + gpu=gpu, ) diff --git a/code_sandboxes/modal_sandbox.py b/code_sandboxes/modal_sandbox.py index 4fd80de..dba612e 100644 --- a/code_sandboxes/modal_sandbox.py +++ b/code_sandboxes/modal_sandbox.py @@ -44,6 +44,42 @@ DEFAULT_MODAL_PYTHON_VERSION = "3.12" +def _resolve_modal_gpu(gpu_flavor: str, modal_module: Any) -> Any: + """Resolve a GPU flavor string to a Modal GPU spec when possible. + + Falls back to the raw string when no structured constructor is available. + """ + flavor = gpu_flavor.strip() + gpu_ns = getattr(modal_module, "gpu", None) + if gpu_ns is None: + return flavor + + normalized = flavor.upper().replace("_", "-") + + if normalized == "A100-80GB" and hasattr(gpu_ns, "A100"): + try: + return gpu_ns.A100(size="80GB") + except Exception: + return flavor + + attr_by_flavor = { + "T4": "T4", + "L4": "L4", + "A10G": "A10G", + "A100": "A100", + "H100": "H100", + } + attr_name = attr_by_flavor.get(normalized) + if not attr_name or not hasattr(gpu_ns, attr_name): + return flavor + + candidate = getattr(gpu_ns, attr_name) + try: + return candidate() + except TypeError: + return candidate + + def _modal_exec_timeout_seconds(timeout: float | None, default: float) -> int: """Return a Modal-compatible timeout in integer seconds.""" value = timeout if timeout is not None else default @@ -126,6 +162,8 @@ def start(self) -> None: "image": image, "timeout": int(self.config.max_lifetime), } + if self.config.gpu: + create_kwargs["gpu"] = _resolve_modal_gpu(self.config.gpu, modal) if secrets: create_kwargs["secrets"] = secrets diff --git a/tests/test_cli_repl.py b/tests/test_cli_repl.py index 3fcca90..2319474 100644 --- a/tests/test_cli_repl.py +++ b/tests/test_cli_repl.py @@ -87,3 +87,24 @@ def _fake_create(*args, **kwargs): assert result.exit_code == 0 assert captured["kwargs"]["variant"] == "jupyter" assert captured["kwargs"]["port"] == 0 + + +def test_repl_modal_gpu_is_forwarded(monkeypatch): + runner = CliRunner() + captured: dict = {} + + def _fake_create(*args, **kwargs): + captured["kwargs"] = kwargs + return _FakeSandbox() + + monkeypatch.setattr(sandbox_cli.Sandbox, "create", staticmethod(_fake_create)) + + result = runner.invoke( + sandbox_cli.app, + ["repl", "--variant", "modal", "--gpu", "A100"], + input=":exit\n", + ) + + assert result.exit_code == 0 + assert captured["kwargs"]["variant"] == "modal" + assert captured["kwargs"]["gpu"] == "A100" diff --git a/tests/test_modal_colab_sandbox.py b/tests/test_modal_colab_sandbox.py index 84b12f1..f72077f 100644 --- a/tests/test_modal_colab_sandbox.py +++ b/tests/test_modal_colab_sandbox.py @@ -143,3 +143,63 @@ def create(**kwargs): assert sandbox.is_started is True sandbox.stop() + + +def test_modal_start_forwards_gpu_flavor(monkeypatch): + """Configured GPU flavor should be propagated to Modal Sandbox.create.""" + + class _FakeImage: + def pip_install(self, *_args): + return self + + class _FakeApp: + pass + + class _FakeSandboxObj: + object_id = "modal-object-id" + + def terminate(self): + return None + + def detach(self): + return None + + captured: dict = {} + + class _FakeModal: + class App: + @staticmethod + def lookup(_name, create_if_missing=False): + assert create_if_missing is True + return _FakeApp() + + class Image: + @staticmethod + def debian_slim(*, python_version): + captured["python_version"] = python_version + return _FakeImage() + + class Secret: + @staticmethod + def from_dict(_values): + return object() + + class gpu: + @staticmethod + def A100(): + return "GPU_A100" + + class Sandbox: + @staticmethod + def create(**kwargs): + captured["create_kwargs"] = kwargs + return _FakeSandboxObj() + + monkeypatch.setitem(sys.modules, "modal", _FakeModal) + + sandbox = ModalSandbox(config=SandboxConfig(timeout=10.0, max_lifetime=30.0, gpu="A100")) + sandbox.start() + + assert captured["create_kwargs"]["gpu"] == "GPU_A100" + + sandbox.stop() From 8ef278ed31a48a93a37d751773d618981bbf6ec3 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 19:30:31 +0200 Subject: [PATCH 11/21] examples: add modal-gpu target and GPU-aware modal example --- examples/Makefile | 8 +++++- examples/modal_sandbox_example.py | 48 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/examples/Makefile b/examples/Makefile index c4febae..c2a6cc4 100644 --- a/examples/Makefile +++ b/examples/Makefile @@ -2,7 +2,7 @@ PYTHON ?= python -.PHONY: all eval docker jupyter monty colab modal datalayer +.PHONY: all eval docker jupyter monty colab modal modal-gpu datalayer all: eval docker jupyter monty colab modal datalayer @@ -24,5 +24,11 @@ colab: modal: $(PYTHON) modal_sandbox_example.py +modal-gpu: + @echo "Preparing Modal GPU sandbox run..." + @echo "Run this snippet to confirm GPU availability inside your runtime:" + @printf '%s\n' 'python - <<'\''PY'\''' 'import shutil, subprocess' 'print("nvidia-smi available:", shutil.which("nvidia-smi") is not None)' 'if shutil.which("nvidia-smi"):' ' print(subprocess.run(["nvidia-smi", "-L"], check=False, capture_output=True, text=True).stdout or "(no output)")' 'try:' ' import torch # type: ignore' ' print("torch:", torch.__version__)' ' print("cuda available:", torch.cuda.is_available())' ' print("cuda device count:", torch.cuda.device_count())' 'except Exception as exc:' ' print("torch check unavailable:", exc)' 'PY' + MODAL_GPU=$${MODAL_GPU:-T4} $(PYTHON) modal_sandbox_example.py --gpu "$${MODAL_GPU:-T4}" + datalayer: $(PYTHON) datalayer_sandbox_example.py diff --git a/examples/modal_sandbox_example.py b/examples/modal_sandbox_example.py index cf16ccd..15fd7d1 100644 --- a/examples/modal_sandbox_example.py +++ b/examples/modal_sandbox_example.py @@ -9,8 +9,13 @@ Auth options: - `modal token new` (writes ~/.modal.toml), or - set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET. + +GPU options: +- pass `--gpu T4` (or A10G/A100/H100), or +- set MODAL_GPU in the environment. """ +import argparse import os from pathlib import Path @@ -23,21 +28,64 @@ def _has_modal_auth() -> bool: return Path.home().joinpath(".modal.toml").exists() +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the modal sandbox example.") + parser.add_argument( + "--gpu", + default=os.environ.get("MODAL_GPU"), + help="Optional GPU flavor (for example: T4, A10G, A100, H100).", + ) + return parser.parse_args() + + +def _gpu_probe_code() -> str: + return """ +import shutil +import subprocess + +print("nvidia-smi available:", shutil.which("nvidia-smi") is not None) +if shutil.which("nvidia-smi"): + result = subprocess.run(["nvidia-smi", "-L"], check=False, capture_output=True, text=True) + print(result.stdout or "(no nvidia-smi output)") + +try: + import torch # type: ignore + print("torch:", torch.__version__) + print("cuda available:", torch.cuda.is_available()) + print("cuda device count:", torch.cuda.device_count()) +except Exception as exc: + print("torch check unavailable:", exc) +""" + + def main() -> None: + args = _parse_args() + if not _has_modal_auth(): print("Modal auth not found.") print("Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET or run: modal token new") return + gpu = args.gpu + if gpu: + print(f"Launching modal sandbox with GPU flavor: {gpu}") + else: + print("Launching modal sandbox without GPU.") + try: with Sandbox.create( variant="modal", timeout=60, + gpu=gpu, pip_packages=["numpy"], ) as sandbox: result = sandbox.run_code("import numpy as np; print(int(np.arange(5).sum()))") print("stdout:", result.stdout.strip()) + if gpu: + gpu_result = sandbox.run_code(_gpu_probe_code()) + print("gpu_probe:\n", gpu_result.stdout.strip()) + error_result = sandbox.run_code("raise RuntimeError('modal failure example')") if error_result.code_error: print( From 1e7797b32aaa7e8b6ae62857d1629528a81a9241 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 20:21:40 +0200 Subject: [PATCH 12/21] bump --- code_sandboxes/__version__.py | 2 +- pyproject.toml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/code_sandboxes/__version__.py b/code_sandboxes/__version__.py index c5b0b2d..8cbe513 100644 --- a/code_sandboxes/__version__.py +++ b/code_sandboxes/__version__.py @@ -3,4 +3,4 @@ """Code Sandboxes.""" -__version__ = "0.0.16" +__version__ = "0.0.17" diff --git a/pyproject.toml b/pyproject.toml index 7ff7c26..7863fb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3", ] dependencies = [ - "jupyter-kernel-client>=0.13.0", + "jupyter-kernel-client", "jupyter-server", "jupyter-server-client", "pydantic>=2.0", @@ -35,13 +35,13 @@ sandbox = "code_sandboxes.cli:main" [project.optional-dependencies] datalayer = ["agent_runtimes>=1.0.16"] docker = ["docker>=6.0"] -colab = ["jupyter-kernel-client>=0.12.0"] +colab = ["jupyter-kernel-client"] monty = ["pydantic-monty"] modal = ["modal>=0.64"] all = [ "agent_runtimes", "docker>=6.0", - "jupyter-kernel-client>=0.12.0", + "jupyter-kernel-client", "pydantic-monty", "modal>=0.64", ] From 374a9538a6fb8ddaa29efe6c86e212652ad441e5 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 20:27:32 +0200 Subject: [PATCH 13/21] tests: fix Ruff naming in modal GPU stub --- tests/test_modal_colab_sandbox.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_modal_colab_sandbox.py b/tests/test_modal_colab_sandbox.py index f72077f..c616966 100644 --- a/tests/test_modal_colab_sandbox.py +++ b/tests/test_modal_colab_sandbox.py @@ -164,6 +164,13 @@ def terminate(self): def detach(self): return None + class _FakeGpu: + @staticmethod + def a100(): + return "GPU_A100" + + _FakeGpu.A100 = staticmethod(_FakeGpu.a100) + captured: dict = {} class _FakeModal: @@ -184,17 +191,14 @@ class Secret: def from_dict(_values): return object() - class gpu: - @staticmethod - def A100(): - return "GPU_A100" - class Sandbox: @staticmethod def create(**kwargs): captured["create_kwargs"] = kwargs return _FakeSandboxObj() + _FakeModal.gpu = _FakeGpu + monkeypatch.setitem(sys.modules, "modal", _FakeModal) sandbox = ModalSandbox(config=SandboxConfig(timeout=10.0, max_lifetime=30.0, gpu="A100")) From 801a35e94f86310cfd8e14272938472239cec057 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 20:29:12 +0200 Subject: [PATCH 14/21] examples: make modal honor MODAL_GPU env var --- examples/Makefile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/Makefile b/examples/Makefile index c2a6cc4..176fe5f 100644 --- a/examples/Makefile +++ b/examples/Makefile @@ -22,7 +22,12 @@ colab: $(PYTHON) colab_sandbox_example.py modal: - $(PYTHON) modal_sandbox_example.py + @if [ -n "$$MODAL_GPU" ]; then \ + echo "Running modal example with GPU flavor: $$MODAL_GPU"; \ + $(PYTHON) modal_sandbox_example.py --gpu "$$MODAL_GPU"; \ + else \ + $(PYTHON) modal_sandbox_example.py; \ + fi modal-gpu: @echo "Preparing Modal GPU sandbox run..." From 708398dfcb2f9052d90a0afe0e7f46ae9e1986bc Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 20:30:48 +0200 Subject: [PATCH 15/21] fix: use ExecutionResult fields in filesystem and command helpers --- code_sandboxes/commands.py | 8 ++++---- code_sandboxes/filesystem.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/code_sandboxes/commands.py b/code_sandboxes/commands.py index 6144f1a..cdb9e93 100644 --- a/code_sandboxes/commands.py +++ b/code_sandboxes/commands.py @@ -250,11 +250,11 @@ def run( execution = self._sandbox.run_code(code, timeout=timeout) - if execution.error: + if not execution.execution_ok: return CommandResult( exit_code=-1, stdout="", - stderr=str(execution.error), + stderr=execution.execution_error or "Sandbox execution failed", duration=time.time() - start_time, ) @@ -398,11 +398,11 @@ def run_script( execution = self._sandbox.run_code(code, timeout=timeout) - if execution.error: + if not execution.execution_ok: return CommandResult( exit_code=-1, stdout="", - stderr=str(execution.error), + stderr=execution.execution_error or "Sandbox execution failed", duration=time.time() - start_time, ) diff --git a/code_sandboxes/filesystem.py b/code_sandboxes/filesystem.py index 46598e8..1f56902 100644 --- a/code_sandboxes/filesystem.py +++ b/code_sandboxes/filesystem.py @@ -121,7 +121,7 @@ def read(self, path: str) -> str: with open({path!r}, 'r') as f: __file_content__ = f.read() """) - if execution.error: + if (not execution.execution_ok) or execution.code_error: raise FileNotFoundError(f"Could not read file: {path}") return self._sandbox.get_variable("__file_content__") @@ -205,7 +205,7 @@ def list(self, path: str = "/") -> list[FileInfo]: except OSError: pass """) - if execution.error: + if (not execution.execution_ok) or execution.code_error: raise FileNotFoundError(f"Could not list directory: {path}") contents = self._sandbox.get_variable("__dir_contents__") @@ -359,7 +359,7 @@ def get_info(self, path: str) -> FileInfo: 'permissions': oct(st.st_mode)[-3:], }} """) - if execution.error: + if (not execution.execution_ok) or execution.code_error: raise FileNotFoundError(f"Could not get info for: {path}") info = self._sandbox.get_variable("__file_info__") From 5795c5e460dc69d5be6f8e717f8141f533f64bb6 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 20:32:16 +0200 Subject: [PATCH 16/21] examples: split exec scripts and add variant REPL examples --- examples/README.md | 24 +++++++- examples/{ => exec}/Makefile | 0 examples/{ => exec}/colab_sandbox_example.py | 0 .../{ => exec}/datalayer_sandbox_example.py | 0 examples/{ => exec}/docker_sandbox_example.py | 0 examples/{ => exec}/eval_sandbox_example.py | 0 .../{ => exec}/jupyter_sandbox_example.py | 0 examples/{ => exec}/modal_sandbox_example.py | 0 examples/{ => exec}/monty_sandbox_example.py | 0 examples/repl/Makefile | 39 +++++++++++++ examples/repl/colab_sandbox_example.py | 43 ++++++++++++++ examples/repl/datalayer_sandbox_example.py | 31 ++++++++++ examples/repl/docker_sandbox_example.py | 26 +++++++++ examples/repl/eval_sandbox_example.py | 17 ++++++ examples/repl/jupyter_sandbox_example.py | 22 ++++++++ examples/repl/modal_sandbox_example.py | 56 +++++++++++++++++++ examples/repl/monty_sandbox_example.py | 22 ++++++++ examples/repl/repl_common.py | 48 ++++++++++++++++ 18 files changed, 326 insertions(+), 2 deletions(-) rename examples/{ => exec}/Makefile (100%) rename examples/{ => exec}/colab_sandbox_example.py (100%) rename examples/{ => exec}/datalayer_sandbox_example.py (100%) rename examples/{ => exec}/docker_sandbox_example.py (100%) rename examples/{ => exec}/eval_sandbox_example.py (100%) rename examples/{ => exec}/jupyter_sandbox_example.py (100%) rename examples/{ => exec}/modal_sandbox_example.py (100%) rename examples/{ => exec}/monty_sandbox_example.py (100%) create mode 100644 examples/repl/Makefile create mode 100644 examples/repl/colab_sandbox_example.py create mode 100644 examples/repl/datalayer_sandbox_example.py create mode 100644 examples/repl/docker_sandbox_example.py create mode 100644 examples/repl/eval_sandbox_example.py create mode 100644 examples/repl/jupyter_sandbox_example.py create mode 100644 examples/repl/modal_sandbox_example.py create mode 100644 examples/repl/monty_sandbox_example.py create mode 100644 examples/repl/repl_common.py diff --git a/examples/README.md b/examples/README.md index caef9b1..7aaeb97 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,6 +10,11 @@ # { } Code Sandboxes Examples +This folder now contains two example sets: + +- `exec/`: one-shot execution examples (run a predefined script and exit). +- `repl/`: interactive REPL examples (run ad-hoc code in a loop). + Supported sandbox variants: - `jupyter` @@ -20,9 +25,10 @@ Supported sandbox variants: - `modal` - `datalayer` -Run examples from the `examples/` directory: +Run one-shot examples from `examples/exec/`: ```bash +cd exec python eval_sandbox_example.py python jupyter_sandbox_example.py python docker_sandbox_example.py @@ -32,9 +38,23 @@ python modal_sandbox_example.py python datalayer_sandbox_example.py ``` -You can also run via Make targets: +Or run one-shot examples via Make targets: + +```bash +cd exec +make eval +make jupyter +make docker +make monty +make colab +make modal +make datalayer +``` + +Run REPL examples from `examples/repl/`: ```bash +cd repl make eval make jupyter make docker diff --git a/examples/Makefile b/examples/exec/Makefile similarity index 100% rename from examples/Makefile rename to examples/exec/Makefile diff --git a/examples/colab_sandbox_example.py b/examples/exec/colab_sandbox_example.py similarity index 100% rename from examples/colab_sandbox_example.py rename to examples/exec/colab_sandbox_example.py diff --git a/examples/datalayer_sandbox_example.py b/examples/exec/datalayer_sandbox_example.py similarity index 100% rename from examples/datalayer_sandbox_example.py rename to examples/exec/datalayer_sandbox_example.py diff --git a/examples/docker_sandbox_example.py b/examples/exec/docker_sandbox_example.py similarity index 100% rename from examples/docker_sandbox_example.py rename to examples/exec/docker_sandbox_example.py diff --git a/examples/eval_sandbox_example.py b/examples/exec/eval_sandbox_example.py similarity index 100% rename from examples/eval_sandbox_example.py rename to examples/exec/eval_sandbox_example.py diff --git a/examples/jupyter_sandbox_example.py b/examples/exec/jupyter_sandbox_example.py similarity index 100% rename from examples/jupyter_sandbox_example.py rename to examples/exec/jupyter_sandbox_example.py diff --git a/examples/modal_sandbox_example.py b/examples/exec/modal_sandbox_example.py similarity index 100% rename from examples/modal_sandbox_example.py rename to examples/exec/modal_sandbox_example.py diff --git a/examples/monty_sandbox_example.py b/examples/exec/monty_sandbox_example.py similarity index 100% rename from examples/monty_sandbox_example.py rename to examples/exec/monty_sandbox_example.py diff --git a/examples/repl/Makefile b/examples/repl/Makefile new file mode 100644 index 0000000..4737c98 --- /dev/null +++ b/examples/repl/Makefile @@ -0,0 +1,39 @@ +# Code Sandboxes REPL examples + +PYTHON ?= python + +.PHONY: all eval docker jupyter monty colab modal modal-gpu datalayer + +all: eval docker jupyter monty colab modal datalayer + +eval: + $(PYTHON) eval_sandbox_example.py + +docker: + $(PYTHON) docker_sandbox_example.py + +jupyter: + $(PYTHON) jupyter_sandbox_example.py + +monty: + $(PYTHON) monty_sandbox_example.py + +colab: + $(PYTHON) colab_sandbox_example.py + +modal: + @if [ -n "$$MODAL_GPU" ]; then \ + echo "Running modal REPL with GPU flavor: $$MODAL_GPU"; \ + $(PYTHON) modal_sandbox_example.py --gpu "$$MODAL_GPU"; \ + else \ + $(PYTHON) modal_sandbox_example.py; \ + fi + +modal-gpu: + @echo "Preparing Modal GPU sandbox REPL..." + @echo "Run this snippet to confirm GPU availability inside your runtime:" + @printf '%s\n' 'python - <<'\''PY'\''' 'import shutil, subprocess' 'print("nvidia-smi available:", shutil.which("nvidia-smi") is not None)' 'if shutil.which("nvidia-smi"):' ' print(subprocess.run(["nvidia-smi", "-L"], check=False, capture_output=True, text=True).stdout or "(no output)")' 'try:' ' import torch # type: ignore' ' print("torch:", torch.__version__)' ' print("cuda available:", torch.cuda.is_available())' ' print("cuda device count:", torch.cuda.device_count())' 'except Exception as exc:' ' print("torch check unavailable:", exc)' 'PY' + MODAL_GPU=$${MODAL_GPU:-T4} $(PYTHON) modal_sandbox_example.py --gpu "$${MODAL_GPU:-T4}" + +datalayer: + $(PYTHON) datalayer_sandbox_example.py diff --git a/examples/repl/colab_sandbox_example.py b/examples/repl/colab_sandbox_example.py new file mode 100644 index 0000000..c7fa7c2 --- /dev/null +++ b/examples/repl/colab_sandbox_example.py @@ -0,0 +1,43 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""REPL example: colab sandbox (Google Colab runtime).""" + +import os + +from code_sandboxes import Sandbox + +from repl_common import run_repl + + +def _require(name: str) -> str: + value = os.environ.get(name) + if not value: + raise RuntimeError(f"Missing required environment variable: {name}") + return value + + +def main() -> None: + try: + runtime_url = _require("RUNTIME_URL") + runtime_id = _require("RUNTIME_ID") + runtime_proxy_token = _require("RUNTIME_PROXY_TOKEN") + + with Sandbox.create( + variant="colab", + timeout=60, + server_url=runtime_url, + kernel_id=runtime_id, + proxy_token=runtime_proxy_token, + ) as sandbox: + run_repl(sandbox) + except Exception as exc: + print("colab REPL failed:", exc) + print( + "Hint: export RUNTIME_URL, RUNTIME_ID, and RUNTIME_PROXY_TOKEN " + "from an active Colab runtime session." + ) + + +if __name__ == "__main__": + main() diff --git a/examples/repl/datalayer_sandbox_example.py b/examples/repl/datalayer_sandbox_example.py new file mode 100644 index 0000000..f346eaf --- /dev/null +++ b/examples/repl/datalayer_sandbox_example.py @@ -0,0 +1,31 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""REPL example: datalayer sandbox (cloud runtime).""" + +from code_sandboxes import Sandbox + +from repl_common import run_repl + + +def main() -> None: + try: + environments = Sandbox.list_environments(variant="datalayer") + if not environments: + raise RuntimeError("No environments available.") + + first_env = environments[0] + print(f"Using environment: {first_env.name} ({first_env.title})") + + with Sandbox.create( + variant="datalayer", + timeout=60, + environment=first_env.name, + ) as sandbox: + run_repl(sandbox) + except Exception as exc: + print("datalayer REPL failed:", exc) + + +if __name__ == "__main__": + main() diff --git a/examples/repl/docker_sandbox_example.py b/examples/repl/docker_sandbox_example.py new file mode 100644 index 0000000..6f651a8 --- /dev/null +++ b/examples/repl/docker_sandbox_example.py @@ -0,0 +1,26 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""REPL example: docker sandbox (container isolation).""" + +from code_sandboxes import Sandbox + +from repl_common import run_repl + + +def main() -> None: + try: + with Sandbox.create( + variant="docker", + timeout=30, + image="code-sandboxes-jupyter:latest", + ) as sandbox: + run_repl(sandbox) + except ModuleNotFoundError as exc: + print("docker sandbox is not available:", exc) + except Exception as exc: + print("docker REPL failed:", exc) + + +if __name__ == "__main__": + main() diff --git a/examples/repl/eval_sandbox_example.py b/examples/repl/eval_sandbox_example.py new file mode 100644 index 0000000..31d001f --- /dev/null +++ b/examples/repl/eval_sandbox_example.py @@ -0,0 +1,17 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""REPL example: eval sandbox (no isolation).""" + +from code_sandboxes import Sandbox + +from repl_common import run_repl + + +def main() -> None: + with Sandbox.create(variant="eval", timeout=30) as sandbox: + run_repl(sandbox) + + +if __name__ == "__main__": + main() diff --git a/examples/repl/jupyter_sandbox_example.py b/examples/repl/jupyter_sandbox_example.py new file mode 100644 index 0000000..03e5158 --- /dev/null +++ b/examples/repl/jupyter_sandbox_example.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""REPL example: jupyter sandbox (persistent kernel state).""" + +from code_sandboxes import Sandbox + +from repl_common import run_repl + + +def main() -> None: + try: + with Sandbox.create(variant="jupyter", timeout=30) as sandbox: + run_repl(sandbox) + except ModuleNotFoundError as exc: + print("jupyter sandbox is not available:", exc) + except Exception as exc: + print("jupyter REPL failed:", exc) + + +if __name__ == "__main__": + main() diff --git a/examples/repl/modal_sandbox_example.py b/examples/repl/modal_sandbox_example.py new file mode 100644 index 0000000..4d27cee --- /dev/null +++ b/examples/repl/modal_sandbox_example.py @@ -0,0 +1,56 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""REPL example: modal sandbox (cloud container execution).""" + +import argparse +import os +from pathlib import Path + +from code_sandboxes import Sandbox + +from repl_common import run_repl + + +def _has_modal_auth() -> bool: + if os.environ.get("MODAL_TOKEN_ID") and os.environ.get("MODAL_TOKEN_SECRET"): + return True + return Path.home().joinpath(".modal.toml").exists() + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the modal sandbox REPL example.") + parser.add_argument( + "--gpu", + default=os.environ.get("MODAL_GPU"), + help="Optional GPU flavor (for example: T4, A10G, A100, H100).", + ) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + + if not _has_modal_auth(): + print("Modal auth not found.") + print("Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET or run: modal token new") + return + + if args.gpu: + print(f"Launching modal sandbox REPL with GPU flavor: {args.gpu}") + else: + print("Launching modal sandbox REPL without GPU.") + + try: + with Sandbox.create( + variant="modal", + timeout=60, + gpu=args.gpu, + ) as sandbox: + run_repl(sandbox) + except Exception as exc: + print("modal REPL failed:", exc) + + +if __name__ == "__main__": + main() diff --git a/examples/repl/monty_sandbox_example.py b/examples/repl/monty_sandbox_example.py new file mode 100644 index 0000000..6363933 --- /dev/null +++ b/examples/repl/monty_sandbox_example.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""REPL example: monty sandbox (secure in-process interpreter).""" + +from code_sandboxes import Sandbox + +from repl_common import run_repl + + +def main() -> None: + try: + with Sandbox.create(variant="monty", timeout=30) as sandbox: + run_repl(sandbox) + except ModuleNotFoundError as exc: + print("monty sandbox is not available:", exc) + except Exception as exc: + print("monty REPL failed:", exc) + + +if __name__ == "__main__": + main() diff --git a/examples/repl/repl_common.py b/examples/repl/repl_common.py new file mode 100644 index 0000000..74d8d28 --- /dev/null +++ b/examples/repl/repl_common.py @@ -0,0 +1,48 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""Shared REPL helper for sandbox examples.""" + +from __future__ import annotations + +from code_sandboxes import Sandbox + + +def run_repl(sandbox: Sandbox) -> None: + """Run a small interactive Python REPL on a sandbox.""" + print("Sandbox REPL ready.") + print("Type Python code and press Enter.") + print("Use :quit or :exit to leave, :help for help.") + + while True: + try: + code = input("sandbox>>> ").strip() + except EOFError: + print() + break + except KeyboardInterrupt: + print("\nInterrupted. Use :quit to exit.") + continue + + if not code: + continue + if code in {":quit", ":exit"}: + break + if code == ":help": + print("Enter Python expressions/statements.") + print(":quit or :exit to leave.") + continue + + result = sandbox.run_code(code) + if result.stdout: + print(result.stdout.rstrip()) + if result.text and result.text != result.stdout.strip(): + print(result.text) + if result.stderr: + print(result.stderr.rstrip()) + if result.code_error: + print(f"{result.code_error.name}: {result.code_error.value}") + if not result.execution_ok and result.execution_error: + print(f"Execution error: {result.execution_error}") + + print("REPL closed.") From ad90b52111b09096f06bd5ecfd3a277c0d1f91d5 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 20:35:57 +0200 Subject: [PATCH 17/21] examples/repl: show sandbox variant and name in prompt --- examples/repl/monty_sandbox_example.py | 6 +-- examples/repl/repl_common.py | 74 ++++++++++++++++++-------- 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/examples/repl/monty_sandbox_example.py b/examples/repl/monty_sandbox_example.py index 6363933..78f683d 100644 --- a/examples/repl/monty_sandbox_example.py +++ b/examples/repl/monty_sandbox_example.py @@ -3,14 +3,14 @@ """REPL example: monty sandbox (secure in-process interpreter).""" -from code_sandboxes import Sandbox - from repl_common import run_repl +from code_sandboxes import Sandbox + def main() -> None: try: - with Sandbox.create(variant="monty", timeout=30) as sandbox: + with Sandbox.create(variant="monty", timeout=30, name="monty1") as sandbox: run_repl(sandbox) except ModuleNotFoundError as exc: print("monty sandbox is not available:", exc) diff --git a/examples/repl/repl_common.py b/examples/repl/repl_common.py index 74d8d28..2333e6e 100644 --- a/examples/repl/repl_common.py +++ b/examples/repl/repl_common.py @@ -8,41 +8,69 @@ from code_sandboxes import Sandbox +def _build_prompt(sandbox: Sandbox) -> str: + info = sandbox.info + if info is None: + return "sandbox>>> " + + variant = info.variant or "unknown" + name_or_id = info.name or (info.id[:8] if info.id else "sandbox") + return f"sandbox({variant}:{name_or_id})>>> " + + +def _read_input(prompt: str) -> str | None: + try: + return input(prompt).strip() + except EOFError: + print() + return None + except KeyboardInterrupt: + print("\nInterrupted. Use :quit to exit.") + return "" + + +def _handle_repl_command(code: str) -> bool: + if code in {":quit", ":exit"}: + return False + if code == ":help": + print("Enter Python expressions/statements.") + print(":quit or :exit to leave.") + return True + + +def _print_result(result) -> None: + if result.stdout: + print(result.stdout.rstrip()) + if result.text and result.text != result.stdout.strip(): + print(result.text) + if result.stderr: + print(result.stderr.rstrip()) + if result.code_error: + print(f"{result.code_error.name}: {result.code_error.value}") + if not result.execution_ok and result.execution_error: + print(f"Execution error: {result.execution_error}") + + def run_repl(sandbox: Sandbox) -> None: """Run a small interactive Python REPL on a sandbox.""" + prompt = _build_prompt(sandbox) + print("Sandbox REPL ready.") print("Type Python code and press Enter.") print("Use :quit or :exit to leave, :help for help.") while True: - try: - code = input("sandbox>>> ").strip() - except EOFError: - print() + code = _read_input(prompt) + if code is None: break - except KeyboardInterrupt: - print("\nInterrupted. Use :quit to exit.") - continue - if not code: continue - if code in {":quit", ":exit"}: - break - if code == ":help": - print("Enter Python expressions/statements.") - print(":quit or :exit to leave.") + if code.startswith(":"): + if not _handle_repl_command(code): + break continue result = sandbox.run_code(code) - if result.stdout: - print(result.stdout.rstrip()) - if result.text and result.text != result.stdout.strip(): - print(result.text) - if result.stderr: - print(result.stderr.rstrip()) - if result.code_error: - print(f"{result.code_error.name}: {result.code_error.value}") - if not result.execution_ok and result.execution_error: - print(f"Execution error: {result.execution_error}") + _print_result(result) print("REPL closed.") From 80627769f117d649f3112d335156b5da027b990c Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 20:37:54 +0200 Subject: [PATCH 18/21] lint --- code_sandboxes/__version__.py | 2 +- examples/exec/colab_sandbox_example.py | 8 +++++--- examples/exec/datalayer_sandbox_example.py | 4 +++- examples/exec/docker_sandbox_example.py | 6 ++++-- examples/exec/eval_sandbox_example.py | 8 +++++--- examples/exec/jupyter_sandbox_example.py | 8 +++++--- examples/exec/modal_sandbox_example.py | 8 +++++--- examples/exec/monty_sandbox_example.py | 10 ++++++---- examples/repl/colab_sandbox_example.py | 4 ++-- examples/repl/datalayer_sandbox_example.py | 4 ++-- examples/repl/docker_sandbox_example.py | 4 ++-- examples/repl/eval_sandbox_example.py | 4 ++-- examples/repl/jupyter_sandbox_example.py | 4 ++-- examples/repl/modal_sandbox_example.py | 4 ++-- 14 files changed, 46 insertions(+), 32 deletions(-) diff --git a/code_sandboxes/__version__.py b/code_sandboxes/__version__.py index 8cbe513..f0787df 100644 --- a/code_sandboxes/__version__.py +++ b/code_sandboxes/__version__.py @@ -3,4 +3,4 @@ """Code Sandboxes.""" -__version__ = "0.0.17" +__version__ = "0.0.19" diff --git a/examples/exec/colab_sandbox_example.py b/examples/exec/colab_sandbox_example.py index 0f80ca8..a43ba40 100644 --- a/examples/exec/colab_sandbox_example.py +++ b/examples/exec/colab_sandbox_example.py @@ -12,6 +12,8 @@ from code_sandboxes import Sandbox +from exec_common import show_and_run + def _require(name: str) -> str: value = os.environ.get(name) @@ -33,11 +35,11 @@ def main() -> None: kernel_id=runtime_id, proxy_token=runtime_proxy_token, ) as sandbox: - sandbox.run_code("x = 40") - result = sandbox.run_code("x + 2") + show_and_run(sandbox, "x = 40") + result = show_and_run(sandbox, "x + 2") print("result:", result.text) - result = sandbox.run_code("print('hello from colab')") + result = show_and_run(sandbox, "print('hello from colab')") print("stdout:", result.stdout) except Exception as exc: print("colab example failed:", exc) diff --git a/examples/exec/datalayer_sandbox_example.py b/examples/exec/datalayer_sandbox_example.py index 71a6637..e2404b3 100644 --- a/examples/exec/datalayer_sandbox_example.py +++ b/examples/exec/datalayer_sandbox_example.py @@ -11,6 +11,8 @@ from code_sandboxes import Sandbox +from exec_common import show_and_run + def main() -> None: try: @@ -28,7 +30,7 @@ def main() -> None: timeout=60, environment=first_env.name, ) as sandbox: - result = sandbox.run_code("print('hello from datalayer runtime')") + result = show_and_run(sandbox, "print('hello from datalayer runtime')") print("stdout:", result.stdout) except Exception as exc: print("datalayer example failed:", exc) diff --git a/examples/exec/docker_sandbox_example.py b/examples/exec/docker_sandbox_example.py index 3312ba1..ceda8bb 100644 --- a/examples/exec/docker_sandbox_example.py +++ b/examples/exec/docker_sandbox_example.py @@ -12,6 +12,8 @@ from code_sandboxes import Sandbox +from exec_common import show_and_run + def main() -> None: try: @@ -20,9 +22,9 @@ def main() -> None: timeout=30, image="code-sandboxes-jupyter:latest", ) as sandbox: - result = sandbox.run_code("print('hello from docker')") + result = show_and_run(sandbox, "print('hello from docker')") print("stdout:", result.stdout) - error_result = sandbox.run_code("raise RuntimeError('boom')") + error_result = show_and_run(sandbox, "raise RuntimeError('boom')") if error_result.code_error: print( "code_error:", diff --git a/examples/exec/eval_sandbox_example.py b/examples/exec/eval_sandbox_example.py index cf692fa..a5ca02b 100644 --- a/examples/exec/eval_sandbox_example.py +++ b/examples/exec/eval_sandbox_example.py @@ -9,20 +9,22 @@ from code_sandboxes import Sandbox +from exec_common import show_and_run + def main() -> None: with Sandbox.create(variant="eval", timeout=30) as sandbox: # Basic execution - result = sandbox.run_code("x = 21 * 2\nprint(x)") + result = show_and_run(sandbox, "x = 21 * 2\nprint(x)") print("stdout:", result.stdout) print("success:", result.success) # Show execution timing - result2 = sandbox.run_code("import time; time.sleep(0.1); print('done')") + result2 = show_and_run(sandbox, "import time; time.sleep(0.1); print('done')") print(f"execution duration: {result2.duration:.3f}s") # Handle errors gracefully - result3 = sandbox.run_code("1 / 0") # This will cause a ZeroDivisionError + result3 = show_and_run(sandbox, "1 / 0") # This will cause a ZeroDivisionError err = result3.code_error if err: print(f"Python error: {err.name}: {err.value}") diff --git a/examples/exec/jupyter_sandbox_example.py b/examples/exec/jupyter_sandbox_example.py index 551e142..00373b3 100644 --- a/examples/exec/jupyter_sandbox_example.py +++ b/examples/exec/jupyter_sandbox_example.py @@ -11,17 +11,19 @@ from code_sandboxes import Sandbox +from exec_common import show_and_run + def main() -> None: try: with Sandbox.create(variant="jupyter", timeout=30) as sandbox: # Test persistent state across executions - sandbox.run_code("x = 40") - result = sandbox.run_code("x + 2") + show_and_run(sandbox, "x = 40") + result = show_and_run(sandbox, "x + 2") print("result:", result.text) # Should print 42 # Test stdout - result = sandbox.run_code("print('hello from jupyter')") + result = show_and_run(sandbox, "print('hello from jupyter')") print("stdout:", result.stdout) # Test file operations diff --git a/examples/exec/modal_sandbox_example.py b/examples/exec/modal_sandbox_example.py index 15fd7d1..86eeb1a 100644 --- a/examples/exec/modal_sandbox_example.py +++ b/examples/exec/modal_sandbox_example.py @@ -21,6 +21,8 @@ from code_sandboxes import Sandbox +from exec_common import show_and_run + def _has_modal_auth() -> bool: if os.environ.get("MODAL_TOKEN_ID") and os.environ.get("MODAL_TOKEN_SECRET"): @@ -79,14 +81,14 @@ def main() -> None: gpu=gpu, pip_packages=["numpy"], ) as sandbox: - result = sandbox.run_code("import numpy as np; print(int(np.arange(5).sum()))") + result = show_and_run(sandbox, "import numpy as np; print(int(np.arange(5).sum()))") print("stdout:", result.stdout.strip()) if gpu: - gpu_result = sandbox.run_code(_gpu_probe_code()) + gpu_result = show_and_run(sandbox, _gpu_probe_code()) print("gpu_probe:\n", gpu_result.stdout.strip()) - error_result = sandbox.run_code("raise RuntimeError('modal failure example')") + error_result = show_and_run(sandbox, "raise RuntimeError('modal failure example')") if error_result.code_error: print( "code_error:", diff --git a/examples/exec/monty_sandbox_example.py b/examples/exec/monty_sandbox_example.py index 9c9fd6c..57306d3 100644 --- a/examples/exec/monty_sandbox_example.py +++ b/examples/exec/monty_sandbox_example.py @@ -11,18 +11,20 @@ from code_sandboxes import Sandbox +from exec_common import show_and_run + def main() -> None: try: with Sandbox.create(variant="monty", timeout=30) as sandbox: - sandbox.run_code("x = 21") - result = sandbox.run_code("x * 2") + show_and_run(sandbox, "x = 21") + result = show_and_run(sandbox, "x * 2") print("result:", result.text) - result = sandbox.run_code("print('hello from monty')") + result = show_and_run(sandbox, "print('hello from monty')") print("stdout:", result.stdout) - error_result = sandbox.run_code("raise ValueError('monty failure example')") + error_result = show_and_run(sandbox, "raise ValueError('monty failure example')") if error_result.code_error: print( "code_error:", diff --git a/examples/repl/colab_sandbox_example.py b/examples/repl/colab_sandbox_example.py index c7fa7c2..cf534b5 100644 --- a/examples/repl/colab_sandbox_example.py +++ b/examples/repl/colab_sandbox_example.py @@ -5,10 +5,10 @@ import os -from code_sandboxes import Sandbox - from repl_common import run_repl +from code_sandboxes import Sandbox + def _require(name: str) -> str: value = os.environ.get(name) diff --git a/examples/repl/datalayer_sandbox_example.py b/examples/repl/datalayer_sandbox_example.py index f346eaf..84f4af6 100644 --- a/examples/repl/datalayer_sandbox_example.py +++ b/examples/repl/datalayer_sandbox_example.py @@ -3,10 +3,10 @@ """REPL example: datalayer sandbox (cloud runtime).""" -from code_sandboxes import Sandbox - from repl_common import run_repl +from code_sandboxes import Sandbox + def main() -> None: try: diff --git a/examples/repl/docker_sandbox_example.py b/examples/repl/docker_sandbox_example.py index 6f651a8..5864ffa 100644 --- a/examples/repl/docker_sandbox_example.py +++ b/examples/repl/docker_sandbox_example.py @@ -3,10 +3,10 @@ """REPL example: docker sandbox (container isolation).""" -from code_sandboxes import Sandbox - from repl_common import run_repl +from code_sandboxes import Sandbox + def main() -> None: try: diff --git a/examples/repl/eval_sandbox_example.py b/examples/repl/eval_sandbox_example.py index 31d001f..b793ab9 100644 --- a/examples/repl/eval_sandbox_example.py +++ b/examples/repl/eval_sandbox_example.py @@ -3,10 +3,10 @@ """REPL example: eval sandbox (no isolation).""" -from code_sandboxes import Sandbox - from repl_common import run_repl +from code_sandboxes import Sandbox + def main() -> None: with Sandbox.create(variant="eval", timeout=30) as sandbox: diff --git a/examples/repl/jupyter_sandbox_example.py b/examples/repl/jupyter_sandbox_example.py index 03e5158..fdda6ff 100644 --- a/examples/repl/jupyter_sandbox_example.py +++ b/examples/repl/jupyter_sandbox_example.py @@ -3,10 +3,10 @@ """REPL example: jupyter sandbox (persistent kernel state).""" -from code_sandboxes import Sandbox - from repl_common import run_repl +from code_sandboxes import Sandbox + def main() -> None: try: diff --git a/examples/repl/modal_sandbox_example.py b/examples/repl/modal_sandbox_example.py index 4d27cee..e836a36 100644 --- a/examples/repl/modal_sandbox_example.py +++ b/examples/repl/modal_sandbox_example.py @@ -7,10 +7,10 @@ import os from pathlib import Path -from code_sandboxes import Sandbox - from repl_common import run_repl +from code_sandboxes import Sandbox + def _has_modal_auth() -> bool: if os.environ.get("MODAL_TOKEN_ID") and os.environ.get("MODAL_TOKEN_SECRET"): From d9feae7820ef4bbbeb7c571213952247ba4c82ee Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 23 Jul 2026 20:39:19 +0200 Subject: [PATCH 19/21] Show executed code in exec examples via shared show_and_run helper --- examples/exec/colab_sandbox_example.py | 4 ++-- examples/exec/datalayer_sandbox_example.py | 4 ++-- examples/exec/docker_sandbox_example.py | 4 ++-- examples/exec/eval_sandbox_example.py | 4 ++-- examples/exec/exec_common.py | 20 ++++++++++++++++++++ examples/exec/jupyter_sandbox_example.py | 4 ++-- examples/exec/modal_sandbox_example.py | 4 ++-- examples/exec/monty_sandbox_example.py | 4 ++-- 8 files changed, 34 insertions(+), 14 deletions(-) create mode 100644 examples/exec/exec_common.py diff --git a/examples/exec/colab_sandbox_example.py b/examples/exec/colab_sandbox_example.py index a43ba40..5c768ab 100644 --- a/examples/exec/colab_sandbox_example.py +++ b/examples/exec/colab_sandbox_example.py @@ -10,10 +10,10 @@ import os -from code_sandboxes import Sandbox - from exec_common import show_and_run +from code_sandboxes import Sandbox + def _require(name: str) -> str: value = os.environ.get(name) diff --git a/examples/exec/datalayer_sandbox_example.py b/examples/exec/datalayer_sandbox_example.py index e2404b3..7ead10b 100644 --- a/examples/exec/datalayer_sandbox_example.py +++ b/examples/exec/datalayer_sandbox_example.py @@ -9,10 +9,10 @@ This requires Datalayer runtime credentials/config. """ -from code_sandboxes import Sandbox - from exec_common import show_and_run +from code_sandboxes import Sandbox + def main() -> None: try: diff --git a/examples/exec/docker_sandbox_example.py b/examples/exec/docker_sandbox_example.py index ceda8bb..af8e6f0 100644 --- a/examples/exec/docker_sandbox_example.py +++ b/examples/exec/docker_sandbox_example.py @@ -10,10 +10,10 @@ Build it with: make -C .. build-docker """ -from code_sandboxes import Sandbox - from exec_common import show_and_run +from code_sandboxes import Sandbox + def main() -> None: try: diff --git a/examples/exec/eval_sandbox_example.py b/examples/exec/eval_sandbox_example.py index a5ca02b..0462ebf 100644 --- a/examples/exec/eval_sandbox_example.py +++ b/examples/exec/eval_sandbox_example.py @@ -7,10 +7,10 @@ python examples/eval_sandbox_example.py """ -from code_sandboxes import Sandbox - from exec_common import show_and_run +from code_sandboxes import Sandbox + def main() -> None: with Sandbox.create(variant="eval", timeout=30) as sandbox: diff --git a/examples/exec/exec_common.py b/examples/exec/exec_common.py new file mode 100644 index 0000000..8c8efdd --- /dev/null +++ b/examples/exec/exec_common.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""Shared helper for exec-style sandbox examples. + +Prints the code that will be executed before running it, so the output +clearly shows the snippet associated with each result. +""" + +from __future__ import annotations + +from code_sandboxes import Sandbox + + +def show_and_run(sandbox: Sandbox, code: str, **kwargs): + """Print the code snippet, execute it on the sandbox, and return the result.""" + print(">>> code:") + for line in code.strip("\n").splitlines(): + print(f" {line}") + return sandbox.run_code(code, **kwargs) diff --git a/examples/exec/jupyter_sandbox_example.py b/examples/exec/jupyter_sandbox_example.py index 00373b3..6663fc6 100644 --- a/examples/exec/jupyter_sandbox_example.py +++ b/examples/exec/jupyter_sandbox_example.py @@ -9,10 +9,10 @@ Note: This requires jupyter_server and jupyter-kernel-client. """ -from code_sandboxes import Sandbox - from exec_common import show_and_run +from code_sandboxes import Sandbox + def main() -> None: try: diff --git a/examples/exec/modal_sandbox_example.py b/examples/exec/modal_sandbox_example.py index 86eeb1a..00af6fb 100644 --- a/examples/exec/modal_sandbox_example.py +++ b/examples/exec/modal_sandbox_example.py @@ -19,10 +19,10 @@ import os from pathlib import Path -from code_sandboxes import Sandbox - from exec_common import show_and_run +from code_sandboxes import Sandbox + def _has_modal_auth() -> bool: if os.environ.get("MODAL_TOKEN_ID") and os.environ.get("MODAL_TOKEN_SECRET"): diff --git a/examples/exec/monty_sandbox_example.py b/examples/exec/monty_sandbox_example.py index 57306d3..1420a92 100644 --- a/examples/exec/monty_sandbox_example.py +++ b/examples/exec/monty_sandbox_example.py @@ -9,10 +9,10 @@ Note: This requires code-sandboxes[monty] / pydantic-monty. """ -from code_sandboxes import Sandbox - from exec_common import show_and_run +from code_sandboxes import Sandbox + def main() -> None: try: From f1f1adedfe4092e29d064b6d1b0dfc01ad078bbf Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Fri, 24 Jul 2026 13:44:46 +0200 Subject: [PATCH 20/21] feat: add kaggle streaming client API coverage and docs alignment --- CHANGELOG.md | 24 +- README.md | 204 ++++++- code_sandboxes/__init__.py | 3 + code_sandboxes/base.py | 8 +- code_sandboxes/cli.py | 26 +- code_sandboxes/client.py | 43 +- code_sandboxes/colab_sandbox.py | 68 +-- code_sandboxes/kaggle_sandbox.py | 678 ++++++++++++++++++++++++ code_sandboxes/models.py | 1 + docs/docs/api-reference/index.mdx | 99 +++- docs/docs/cli/index.mdx | 2 + docs/docs/examples/index.mdx | 10 +- docs/docs/index.mdx | 5 + docs/docs/installation/index.mdx | 4 + docs/docs/sandboxes/index.mdx | 64 ++- examples/README.md | 5 + examples/exec/Makefile | 5 +- examples/exec/kaggle_sandbox_example.py | 69 +++ examples/repl/Makefile | 5 +- examples/repl/kaggle_sandbox_example.py | 43 ++ pyproject.toml | 2 + tests/test_cli_repl.py | 68 +++ tests/test_client.py | 72 +++ tests/test_factory.py | 20 + tests/test_modal_colab_sandbox.py | 294 +++++++++- tests/test_models.py | 1 + 26 files changed, 1735 insertions(+), 88 deletions(-) create mode 100644 code_sandboxes/kaggle_sandbox.py create mode 100644 examples/exec/kaggle_sandbox_example.py create mode 100644 examples/repl/kaggle_sandbox_example.py create mode 100644 tests/test_client.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e5096bf..6e4e3ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,26 @@ ## Unreleased -- Added `ColabSandbox(use_browser_bridge=True)` to obtain the Colab runtime - connection details (`server_url` / `kernel_id` / `proxy_token`) from an - authenticated Colab browser session via `jupyter-kernel-client`'s browser - bridge, instead of requiring them to be supplied manually. +- Added the `kaggle` sandbox variant (`KaggleSandbox`) to connect to a Kaggle + interactive notebook runtime via `jupyter-kernel-client`'s + `KaggleKernelClient`. Authenticate with a Kaggle API token (`token` argument or + the `KAGGLE_API_TOKEN` environment variable) — omitting `kernel_id` then creates + a new kernel. Alternatively, connect to an existing session with a + `server_url`/`kernel_id` or a notebook session `channels_url` (the signed JWT in + the proxied URL provides the authentication). Install with + `pip install code-sandboxes[kaggle]`. +- Enhanced `KaggleSandbox` with a transparent batch primitive: when no runtime + connection details are provided, it automatically executes code through + `KaggleKernelExecutor` (submit/poll/download) so integrations like + `jupyter-mcp-server` can run on Kaggle without requiring interactive runtime + wiring. +- Added Kaggle accelerator forwarding in batch mode: `Sandbox.create(variant="kaggle", gpu=...)` + now passes the value to `KaggleKernelExecutor.execute(accelerator=...)`, + supporting both Kaggle API values (`NvidiaTeslaT4`, ...) and friendly aliases + (`T4`, `P100`, ...). +- Updated `ColabSandbox` to be reuse-only for existing Colab runtimes and added + `channels_url` parsing support for extracting `server_url` / `kernel_id` / + `proxy_token` directly from the Colab WebSocket channels URL. - Breaking change: sandbox variant names are `eval`, `docker`, `jupyter`, and `datalayer`. - Removed support for the older `local-*` variant names from the public API and documentation. - Clarified in the documentation that `Sandbox.create()` defaults to `datalayer`. diff --git a/README.md b/README.md index 116cd89..6f92916 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ This package provides a unified API for code execution with features like: ## Sandbox Variants -Seven variants are available. Canonical variant names are `jupyter`, `docker`, -`eval`, `monty`, `colab`, `modal`, and `datalayer`. The older `local-*` names -are no longer supported. +Eight variants are available. Canonical variant names are `jupyter`, `docker`, +`eval`, `monty`, `kaggle`, `colab`, `modal`, and `datalayer`. The older `local-*` +names are no longer supported. | Variant | Isolation | Use Case | | ----------- | ----------------------------- | -------------------------------- | @@ -35,6 +35,7 @@ are no longer supported. | `docker` | Container (Jupyter Server) | Local isolated execution | | `eval` | None (Python exec) | Development, testing | | `monty` | In-process secure interpreter | Fast, safe LLM snippets | +| `kaggle` | Kaggle notebook runtime | Free hosted GPU/CPU kernels | | `colab` | Google Colab runtime | Free hosted GPU/CPU kernels | | `modal` | Modal cloud container | On-demand isolated cloud compute | | `datalayer` | Cloud VM | Production, GPU workloads | @@ -50,6 +51,7 @@ Sandbox implementations are exposed as top-level modules: - `code_sandboxes.docker_sandbox` - `code_sandboxes.eval_sandbox` - `code_sandboxes.monty_sandbox` +- `code_sandboxes.kaggle_sandbox` - `code_sandboxes.colab_sandbox` - `code_sandboxes.modal_sandbox` - `code_sandboxes.datalayer_sandbox` @@ -61,6 +63,7 @@ from code_sandboxes.jupyter_sandbox import JupyterSandbox from code_sandboxes.docker_sandbox import DockerSandbox from code_sandboxes.eval_sandbox import EvalSandbox from code_sandboxes.monty_sandbox import MontySandbox +from code_sandboxes.kaggle_sandbox import KaggleSandbox from code_sandboxes.colab_sandbox import ColabSandbox from code_sandboxes.modal_sandbox import ModalSandbox from code_sandboxes.datalayer_sandbox import DatalayerSandbox @@ -78,6 +81,9 @@ pip install code-sandboxes[datalayer] # With Docker support pip install code-sandboxes[docker] +# With Kaggle support +pip install code-sandboxes[kaggle] + # With Google Colab support pip install code-sandboxes[colab] @@ -213,6 +219,48 @@ with Sandbox.create() as sandbox: on_stdout=handle_stdout, on_stderr=handle_stderr, ) + +# Kaggle batch mode also supports streaming status/output events. +with Sandbox.create(variant="kaggle") as sandbox: + for event in sandbox.run_code_streaming("print('hello from kaggle stream')"): + if hasattr(event, "line"): + print(event.line) + +### High-level Client API + +`CodeSandboxClient` provides a variant-agnostic facade with normalized outcomes. +It is useful in higher-level systems that need a stable API across all sandbox +engines. + +```python +from code_sandboxes import Sandbox, CodeSandboxClient + +with Sandbox.create(variant="kaggle") as sandbox: + client = CodeSandboxClient(sandbox) + + # One-shot normalized outcome. + outcome = client.execute_code("print('hello')") + print(outcome.success, outcome.stdout, outcome.stderr) + + # Stream normalized events (sync). + for event in client.execute_code_streaming("print('streaming')"): + if hasattr(event, "line"): + print(event.line) +``` + +```python +import asyncio +from code_sandboxes import Sandbox, CodeSandboxClient + +async def main(): + with Sandbox.create(variant="kaggle") as sandbox: + client = CodeSandboxClient(sandbox) + async for event in client.execute_code_streaming_async("print('async stream')"): + if hasattr(event, "line"): + print(event.line) + +asyncio.run(main()) +``` ``` ## CLI REPL @@ -233,6 +281,7 @@ If `--variant` is omitted, the CLI prompts for one. Variant notes: - `jupyter`: starts a managed local Jupyter server on a random port. +- `kaggle`: supports interactive runtime mode and batch mode (credentials based). - `monty`: starts a Monty REPL-backed sandbox. - `modal`: starts a Modal sandbox container. - `colab`: prompts for runtime URL, kernel ID, and proxy token. @@ -423,7 +472,119 @@ sandbox.run_code("print(now())") > Monty supports only a subset of Python — third-party libraries and rich display > outputs are not available. -### 5. Google Colab +### 5. Kaggle + +Runs code against Kaggle with two transparent modes: + +1. **Interactive kernel mode** via `jupyter-kernel-client`'s + `KaggleKernelClient` (connect/create kernel on a runtime proxy). +2. **Batch job mode** via `jupyter-kernel-client`'s `KaggleKernelExecutor` + (submit code as a Kaggle notebook job and return logs/results). + +This makes the `kaggle` sandbox usable directly from higher-level systems such +as `jupyter-mcp-server` without requiring special routing logic. + +Interactive-kernel authentication supports: + +- **API token (default).** Provide a Kaggle API token via `token` or the + `KAGGLE_API_TOKEN` environment variable. When `kernel_id` is omitted, a new + kernel is created on the runtime. +- **Signed proxy URL.** Connect to an already-running notebook session using its + `server_url` and `kernel_id`; the signed JWT embedded in the proxied + `server_url` provides the authentication (no token needed). + +**Install:** + +```bash +pip install code-sandboxes[kaggle] +``` + +**Parameters:** + +| Parameter | Description | +| -------------- | ----------- | +| `server_url` | The Kaggle runtime proxy URL (ending in `/proxy`) for interactive mode | +| `kernel_id` | The kernel identifier (omit to create a new kernel with a token in interactive mode) | +| `channels_url` | A notebook session channels URL to parse `server_url`/`kernel_id` from | +| `token` | Kaggle API token for interactive kernel mode (falls back to `KAGGLE_API_TOKEN`) | +| `gpu` / `accelerator` | Optional batch-mode accelerator. Supports Kaggle API values (`NvidiaTeslaT4`, `NvidiaTeslaP100`, `NvidiaTeslaT4Highmem`, `NvidiaL4`, `NvidiaL4X1`, `NvidiaTeslaA100`, `NvidiaH100`, `NvidiaRtxPro6000`) and friendly aliases (`T4`, `P100`, `A100`, `H100`). | + +For **batch mode** (no `server_url`/`channels_url`), configure credentials as +the official `kaggle` package expects (`~/.kaggle/kaggle.json` or +`KAGGLE_USERNAME` + `KAGGLE_KEY`). + +**Obtaining connection values** — to connect to an existing session, the +`server_url` / `kernel_id` come from an active browser session. The official +Kaggle API (`kaggle` CLI / `kagglehub`) only exposes *batch* kernel operations +(push/pull/status/output) for running notebooks as jobs, not an interactive +WebSocket kernel. Read the values from the WebSocket *channels* URL: + +``` +wss://kkb-production.jupyter-proxy.kaggle.net/k///proxy/api/kernels//channels?session_id=<...> +``` + +1. Open your notebook on [kaggle.com](https://www.kaggle.com) and start a session + (run any cell). +1. Open DevTools (`F12`) → **Network** tab, select the **WS** filter (or type + `channels`), then run a cell to trigger kernel traffic. +1. Click the `.../proxy/api/kernels//channels?...` request and copy its + URL. The signed JWT in the `/k///proxy` path segment carries the + authentication. These values are tied to your session and are short-lived — + refresh them after reconnecting. + +**Usage:** + +```python +import os +from code_sandboxes import Sandbox + +# Option A: create a kernel with a Kaggle API token (omit kernel_id). +os.environ["KAGGLE_API_TOKEN"] = "..." # or export it in your shell +with Sandbox.create( + variant="kaggle", + server_url="https://kkb-production.jupyter-proxy.kaggle.net/k/12345678/eyJ.../proxy", +) as sandbox: + sandbox.run_code("x = 40") + print(sandbox.run_code("x + 2").text) # 42 + +# Option B: pass an existing session's channels URL and let the sandbox parse it. +with Sandbox.create( + variant="kaggle", + channels_url="wss://kkb-production.jupyter-proxy.kaggle.net/k/12345678/eyJ.../proxy/api/kernels/11e073f0-.../channels?session_id=...", +) as sandbox: + print(sandbox.run_code("print(1 + 1)").text) + +# Option C: pass the server_url and kernel_id explicitly. +with Sandbox.create( + variant="kaggle", + server_url="https://kkb-production.jupyter-proxy.kaggle.net/k/12345678/eyJ.../proxy", + kernel_id="11e073f0-e82d-4029-be8d-3918f7ed1a9e", +) as sandbox: + print(sandbox.run_code("print(1 + 1)").text) + +# Option D: transparent batch mode (no runtime URL needed). +# Requires kaggle.json or KAGGLE_USERNAME/KAGGLE_KEY credentials. +with Sandbox.create(variant="kaggle") as sandbox: + result = sandbox.run_code("print('hello from kaggle batch')") + print(result.text or result.stdout) + +# Option E: batch mode with a specific accelerator. +with Sandbox.create(variant="kaggle", gpu="T4") as sandbox: + result = sandbox.run_code("import torch; print(torch.cuda.is_available())") + print(result.text or result.stdout) + +# Option F: stream batch progress + outputs. +with Sandbox.create(variant="kaggle") as sandbox: + for event in sandbox.run_code_streaming("print('hello from kaggle stream')"): + if hasattr(event, "line"): + print(event.line) +``` + +> Note: Kaggle free-tier availability usually includes `P100` and `T4`. +> Accelerators like `A100`, `H100`, and `L4` are often restricted to specific +> competitions or internal Kaggle workloads. + +### 6. Google Colab Runs code against a Google Colab runtime. Colab exposes a Jupyter-compatible kernel behind an authenticating proxy, so this variant connects using @@ -442,6 +603,7 @@ pip install code-sandboxes[colab] | `server_url` | The Colab runtime proxy/tunnel URL | | `kernel_id` | The assigned kernel identifier | | `proxy_token` | The `colab-runtime-proxy-token` value | +| `channels_url` | Optional Colab channels URL to parse the above values from | **How to obtain these values** — they are the pieces of the WebSocket URL that Colab's own frontend uses to reach your assigned runtime: @@ -466,11 +628,10 @@ Read them from your browser's developer tools: value as the `X-Colab-Runtime-Proxy-Token` request header). Ignore the `session_id` and `colab-client-agent` parameters. -The programmatic "runtime assignment API" is the internal endpoint Colab's -frontend calls (authenticated with your Google session); it is not an officially -published public API, so the DevTools method above is the practical approach. The -values are tied to your Colab session and are short-lived — refresh them after the -runtime is reassigned or reconnected. +Consumer Colab does not expose an official third-party API to provision runtimes +from scratch. Start/connect a runtime in the Colab UI first, then reuse it here. +The values are tied to your Colab session and are short-lived — refresh them +after the runtime is reassigned or reconnected. **Usage:** @@ -487,27 +648,22 @@ with Sandbox.create( print(sandbox.run_code("x + 2").text) # 42 ``` -**Browser bridge (no manual DevTools):** instead of copying the values by hand, -set `use_browser_bridge=True` to obtain them from an authenticated Colab browser -session. This reuses `jupyter-kernel-client`'s browser bridge: a short-lived -localhost WebSocket server is opened with a one-time token, a Colab page is -launched, and the authenticated tab posts the runtime `server_url` / -`kernel_id` / `proxy_token` back to the process. Google credentials never leave -the browser. +You can also pass the channels URL directly and let the sandbox parse it: ```python from code_sandboxes import Sandbox -with Sandbox.create(variant="colab", use_browser_bridge=True) as sandbox: +with Sandbox.create( + variant="colab", + channels_url=( + "wss:///api/kernels//channels" + "?session_id=<...>&colab-runtime-proxy-token=&colab-client-agent=web" + ), +) as sandbox: print(sandbox.run_code("print(1 + 1)").text) ``` -Install the bridge extra with `pip install 'jupyter-kernel-client[bridge]'`. The -browser side must run a cooperating page/extension/userscript that reads the -token and port from the launch URL and sends the payload (see the -`jupyter-kernel-client` README for the contract). - -### 6. Modal +### 7. Modal Runs code in a [Modal](https://modal.com/docs/guide) cloud sandbox, providing fully isolated, on-demand containers with configurable images and secrets. @@ -588,7 +744,7 @@ with Sandbox.create( > persist across calls. Use a single multi-statement snippet when you need shared > state. -### 7. Datalayer +### 8. Datalayer Cloud-based execution with full isolation, GPU support, snapshots, and persistence via the [Datalayer](https://datalayer.ai) runtime, powered by the diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index ecf7e3a..834802b 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -16,6 +16,7 @@ - JupyterSandbox: Jupyter Server with persistent kernel state - DatalayerSandbox: Cloud-based Datalayer runtime, full isolation - ColabSandbox: Google Colab runtime, connects to an assigned kernel + - KaggleSandbox: Kaggle runtime, connects to an interactive notebook kernel Cloud container sandboxes: - ModalSandbox: Modal cloud containers, per-snippet process execution @@ -86,6 +87,7 @@ SandboxFilesystem, ) from .jupyter_sandbox import JupyterSandbox +from .kaggle_sandbox import KaggleSandbox from .modal_sandbox import ModalSandbox from .models import ( CodeError, @@ -128,6 +130,7 @@ "FileWatchEventType", "GPUType", "JupyterSandbox", + "KaggleSandbox", "Logs", "MIMEType", "ModalSandbox", diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index b91680a..dda4b2d 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -168,7 +168,7 @@ def set_tags(self, tags: dict[str, str]) -> None: self._tags.update(tags) @classmethod - def create( + def create( # noqa: C901 cls, variant: SandboxVariant | str = SandboxVariant.DATALAYER, config: SandboxConfig | None = None, @@ -266,6 +266,10 @@ def create( from .colab_sandbox import ColabSandbox sandbox = ColabSandbox(config=config, **kwargs) + elif variant_value == "kaggle": + from .kaggle_sandbox import KaggleSandbox + + sandbox = KaggleSandbox(config=config, **kwargs) elif variant_value == "monty": from .monty_sandbox import MontySandbox @@ -278,7 +282,7 @@ def create( raise ValueError( f"Unknown sandbox variant: {variant}. " "Supported variants: eval, docker, jupyter, " - "datalayer, colab, monty, modal" + "datalayer, colab, kaggle, monty, modal" ) # Set tags if provided diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py index 595897f..015d7f6 100644 --- a/code_sandboxes/cli.py +++ b/code_sandboxes/cli.py @@ -6,6 +6,7 @@ from __future__ import annotations +import os from typing import Any import typer @@ -21,6 +22,7 @@ "eval", "monty", "colab", + "kaggle", "modal", "datalayer", } @@ -104,13 +106,27 @@ def _resolve_variant_kwargs( hide_input=True, ) + if variant == "kaggle": + kwargs["server_url"] = server_url or typer.prompt("Kaggle runtime proxy URL (RUNTIME_URL)") + # kernel_id is optional: leave empty to create a new kernel (needs a token). + resolved_kernel_id = kernel_id or typer.prompt( + "Kaggle kernel id (RUNTIME_ID, leave empty to create a new kernel)", + default="", + show_default=False, + ) + if resolved_kernel_id: + kwargs["kernel_id"] = resolved_kernel_id + resolved_token = token or os.environ.get("KAGGLE_API_TOKEN") + if resolved_token: + kwargs["token"] = resolved_token + if variant == "datalayer": if token: kwargs["token"] = token if run_url: kwargs["run_url"] = run_url - if variant in {"modal", "datalayer"} and gpu: + if variant in {"modal", "datalayer", "kaggle"} and gpu: kwargs["gpu"] = gpu return kwargs @@ -192,7 +208,7 @@ def repl( None, "--variant", "-v", - help="Sandbox variant (jupyter, docker, eval, monty, colab, modal, datalayer).", + help="Sandbox variant (jupyter, docker, eval, monty, colab, kaggle, modal, datalayer).", ), timeout: float = typer.Option(60.0, help="Default code execution timeout (seconds)."), environment: str | None = typer.Option( @@ -207,7 +223,11 @@ def repl( gpu: str | None = typer.Option( None, "--gpu", - help="GPU flavor for supported variants (e.g., modal/datalayer: T4, A10G, A100, H100).", + help=( + "GPU flavor / accelerator for supported variants " + "(modal/datalayer examples: T4, A10G, A100, H100; " + "kaggle examples: NvidiaTeslaT4, NvidiaTeslaP100, or aliases T4/P100)." + ), ), ) -> None: """Launch an interactive REPL against the selected sandbox variant. diff --git a/code_sandboxes/client.py b/code_sandboxes/client.py index aefa587..b64fe4f 100644 --- a/code_sandboxes/client.py +++ b/code_sandboxes/client.py @@ -43,14 +43,18 @@ from __future__ import annotations +from collections.abc import AsyncIterator, Iterator from dataclasses import dataclass, field +from typing import Union from .base import Sandbox from .commands import CommandResult -from .models import ExecutionResult, SandboxConfig, SandboxVariant +from .models import CodeError, ExecutionResult, OutputMessage, Result, SandboxConfig, SandboxVariant __all__ = ["CodeExecutionOutcome", "CodeSandboxClient"] +StreamingItem = Union[OutputMessage, Result, CodeError] + @dataclass class CodeExecutionOutcome: @@ -254,6 +258,43 @@ async def execute_code_async( ) return CodeExecutionOutcome.from_execution_result(execution) + def execute_code_streaming( + self, + code: str, + language: str = "python", + timeout: float | None = None, + envs: dict[str, str] | None = None, + ) -> Iterator[StreamingItem]: + """Execute code and stream output events. + + This is a thin variant-agnostic wrapper over + ``Sandbox.run_code_streaming``. + """ + self.start() + yield from self._sandbox.run_code_streaming( + code, + language=language, + timeout=timeout, + envs=envs, + ) + + async def execute_code_streaming_async( + self, + code: str, + language: str = "python", + timeout: float | None = None, + envs: dict[str, str] | None = None, + ) -> AsyncIterator[StreamingItem]: + """Async variant of :meth:`execute_code_streaming`.""" + await self.start_async() + async for item in self._sandbox.run_code_streaming_async( + code, + language=language, + timeout=timeout, + envs=envs, + ): + yield item + def run_command(self, command: str, timeout: float | None = None) -> CommandResult: """Run a shell command inside the sandbox.""" self.start() diff --git a/code_sandboxes/colab_sandbox.py b/code_sandboxes/colab_sandbox.py index f85ab24..d885658 100644 --- a/code_sandboxes/colab_sandbox.py +++ b/code_sandboxes/colab_sandbox.py @@ -8,9 +8,9 @@ its kernel using ``jupyter-kernel-client``'s :class:`ColabKernelClient`. Unlike the Jupyter/Docker sandboxes, this sandbox does **not** provision a -runtime: a Colab runtime must already have been assigned (typically through a -Colab runtime assignment API), providing a ``server_url``, ``kernel_id`` and -``proxy_token``. +runtime: a Colab runtime must already be running in a browser session. Reuse it +with either explicit ``server_url`` / ``kernel_id`` / ``proxy_token`` values or +by passing a Colab WebSocket ``channels_url``. """ from __future__ import annotations @@ -43,9 +43,11 @@ class ColabSandbox(Sandbox): Args: config: Optional sandbox configuration. - server_url: The Colab runtime proxy URL (from the assignment API). + server_url: The Colab runtime proxy URL. kernel_id: The Colab kernel identifier to connect to. - proxy_token: The Colab runtime proxy token (from the assignment API). + proxy_token: The Colab runtime proxy token. + channels_url: Optional Colab channels URL to parse `server_url`, + `kernel_id`, and `proxy_token` from. client_agent: Value advertised through the ``X-Colab-Client-Agent`` header. """ @@ -55,9 +57,8 @@ def __init__( server_url: str | None = None, kernel_id: str | None = None, proxy_token: str | None = None, + channels_url: str | None = None, client_agent: str = "code-sandboxes", - use_browser_bridge: bool = False, - bridge_timeout: float = 60.0, **kwargs, ): super().__init__(config) @@ -66,9 +67,8 @@ def __init__( self._server_url = server_url or extras.get("server_url") self._kernel_id = kernel_id or extras.get("kernel_id") self._proxy_token = proxy_token or extras.get("proxy_token") + self._channels_url = channels_url or extras.get("channels_url") self._client_agent = client_agent - self._use_browser_bridge = use_browser_bridge or bool(extras.get("use_browser_bridge")) - self._bridge_timeout = bridge_timeout self._client = None self._sandbox_id = str(uuid.uuid4()) self._extra_kwargs = kwargs @@ -91,17 +91,28 @@ def start(self) -> None: if self._started: return - # Obtain the runtime details from an authenticated browser session when - # they were not supplied and the browser bridge is enabled. - if self._use_browser_bridge and (not self._server_url or not self._proxy_token): - self._acquire_via_browser_bridge() + if self._channels_url and ( + not self._server_url or not self._kernel_id or not self._proxy_token + ): + try: + from jupyter_kernel_client import parse_colab_channels_url + except ImportError as exc: + raise SandboxConfigurationError( + "jupyter-kernel-client>=0.14.0 is required for Colab channels_url parsing. " + "Install it with: pip install jupyter-kernel-client" + ) from exc + + parsed_server_url, parsed_kernel_id, parsed_proxy_token = parse_colab_channels_url( + self._channels_url + ) + self._server_url = self._server_url or parsed_server_url + self._kernel_id = self._kernel_id or parsed_kernel_id + self._proxy_token = self._proxy_token or parsed_proxy_token - if not self._server_url or not self._proxy_token: + if not self._server_url or not self._kernel_id or not self._proxy_token: raise SandboxConfigurationError( - "ColabSandbox requires 'server_url' and 'proxy_token' (and " - "optionally 'kernel_id'). Provide them directly, or set " - "use_browser_bridge=True to obtain them from an authenticated " - "Colab browser session." + "ColabSandbox requires 'server_url', 'kernel_id', and 'proxy_token'. " + "Provide them directly, or pass 'channels_url' from an active Colab session." ) try: @@ -132,27 +143,6 @@ def start(self) -> None: ) self._started = True - def _acquire_via_browser_bridge(self) -> None: - """Fill in runtime details from an authenticated Colab browser session. - - Uses ``jupyter-kernel-client``'s reusable browser bridge: a local - WebSocket server is opened, the Colab page is launched with a one-time - token, and the authenticated browser posts the runtime - ``server_url`` / ``kernel_id`` / ``proxy_token`` back to this process. - """ - try: - from jupyter_kernel_client import request_colab_connection - except ImportError as exc: - raise SandboxConfigurationError( - "The browser bridge requires jupyter-kernel-client[bridge]. " - "Install it with: pip install 'jupyter-kernel-client[bridge]'" - ) from exc - - info = request_colab_connection(timeout=self._bridge_timeout) - self._server_url = self._server_url or info.server_url - self._proxy_token = self._proxy_token or info.proxy_token - self._kernel_id = self._kernel_id or info.kernel_id - def _setup_tool_caller(self) -> None: """Keep tool calling on the client side for Colab sandboxes.""" return diff --git a/code_sandboxes/kaggle_sandbox.py b/code_sandboxes/kaggle_sandbox.py new file mode 100644 index 0000000..a792543 --- /dev/null +++ b/code_sandboxes/kaggle_sandbox.py @@ -0,0 +1,678 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Kaggle sandbox implementation. + +This sandbox connects to a Kaggle interactive notebook runtime and executes code +in its kernel using ``jupyter-kernel-client``'s :class:`KaggleKernelClient`. + +When runtime connection details are not provided, it transparently falls back to +Kaggle's batch execution API via ``KaggleKernelExecutor``. This mode is useful +for server-side integrations (for example jupyter-mcp-server) because callers can +submit code without first attaching to an interactive kernel session. + +Authentication supports two modes: + +* **API token (default).** Provide a Kaggle API token via ``token`` or the + ``KAGGLE_API_TOKEN`` environment variable. When ``kernel_id`` is omitted, a new + kernel is created on the runtime. +* **Signed proxy URL.** Connect to an already-running notebook session using its + ``server_url`` and ``kernel_id`` (typically derived from the WebSocket + *channels* URL). The signed JWT embedded in the proxied ``server_url`` provides + the authentication. +""" + +from __future__ import annotations + +import logging +import tempfile +import time +import uuid +from collections.abc import AsyncIterator, Iterator +from pathlib import Path +from typing import Any + +from .base import Sandbox +from .exceptions import SandboxConfigurationError, SandboxNotStartedError +from .models import ( + CodeError, + Context, + ExecutionResult, + Logs, + OutputHandler, + OutputMessage, + Result, + SandboxConfig, + SandboxEnvironment, + SandboxInfo, + SandboxStatus, +) + +logger = logging.getLogger(__name__) +_KAGGLE_TERMINAL_STATUSES = {"COMPLETE", "ERROR", "CANCEL_ACKNOWLEDGED"} + + +class KaggleSandbox(Sandbox): + """Sandbox backed by a Kaggle interactive notebook runtime. + + Args: + config: Optional sandbox configuration. + server_url: The Kaggle runtime proxy URL (ending in ``/proxy``). + kernel_id: The Kaggle kernel identifier to connect to. When omitted, a new + kernel is created on the runtime (requires a valid API token). + channels_url: A Kaggle notebook session *channels* URL. When provided, + ``server_url`` and ``kernel_id`` are parsed from it. + token: The Kaggle API token used to authenticate interactive kernels. When + ``None``, it falls back to the ``KAGGLE_API_TOKEN`` environment variable. + gpu: Optional Kaggle accelerator name for batch mode. Supports friendly + aliases such as ``T4`` or ``P100`` and Kaggle API values such as + ``NvidiaTeslaT4``. + """ + + def __init__( + self, + config: SandboxConfig | None = None, + server_url: str | None = None, + kernel_id: str | None = None, + channels_url: str | None = None, + token: str | None = None, + **kwargs, + ): + super().__init__(config) + # Allow configuration via SandboxConfig extras as a fallback. + extras = getattr(self.config, "model_extra", None) or {} + self._server_url = server_url or extras.get("server_url") + self._kernel_id = kernel_id or extras.get("kernel_id") + self._channels_url = channels_url or extras.get("channels_url") + self._token = token or extras.get("token") + self._client = None + self._executor = None + self._batch_mode = False + self._sandbox_id = str(uuid.uuid4()) + self._extra_kwargs = kwargs + + @classmethod + def list_environments(cls) -> list[SandboxEnvironment]: + return [ + SandboxEnvironment( + name="kaggle", + title="Kaggle", + language="python", + owner="kaggle", + visibility="cloud", + burning_rate=0.0, + metadata={"variant": "kaggle"}, + ) + ] + + def start(self) -> None: + if self._started: + return + + # Transparent batch mode: when no interactive runtime connection details + # are available, fall back to Kaggle's official job API. + if not self._server_url and not self._channels_url: + try: + from jupyter_kernel_client import KaggleKernelExecutor + except ImportError as exc: + raise SandboxConfigurationError( + "jupyter-kernel-client>=0.14.0 is required for Kaggle batch execution. " + "Install it with: pip install jupyter-kernel-client" + ) from exc + + self._executor = KaggleKernelExecutor( + username=self._extra_kwargs.get("username"), + quiet=True, + ) + self._batch_mode = True + self._default_context = self.create_context("default") + self._info = SandboxInfo( + id=self._sandbox_id, + variant="kaggle", + status=SandboxStatus.RUNNING, + created_at=time.time(), + name=self.config.name, + metadata={"mode": "batch"}, + config=self.config, + ) + self._started = True + return + + try: + from jupyter_kernel_client import ( + KaggleKernelClient, + parse_kaggle_channels_url, + ) + except ImportError as exc: + raise SandboxConfigurationError( + "jupyter-kernel-client>=0.12.0 is required for KaggleSandbox. " + "Install it with: pip install jupyter-kernel-client" + ) from exc + + # Derive server_url / kernel_id from a channels URL when provided. + if self._channels_url and (not self._server_url or not self._kernel_id): + parsed_server_url, parsed_kernel_id = parse_kaggle_channels_url(self._channels_url) + self._server_url = self._server_url or parsed_server_url + self._kernel_id = self._kernel_id or parsed_kernel_id + + if not self._server_url: + raise SandboxConfigurationError( + "KaggleSandbox requires 'server_url' (and optionally 'kernel_id'), " + "or a 'channels_url' to parse them from. Obtain them from the " + "WebSocket channels URL of a running Kaggle notebook session." + ) + + self._client = KaggleKernelClient( + server_url=self._server_url, + kernel_id=self._kernel_id, + token=self._token, + ) + self._client.start() + + self._default_context = self.create_context("default") + self._info = SandboxInfo( + id=self._sandbox_id, + variant="kaggle", + status=SandboxStatus.RUNNING, + created_at=time.time(), + name=self.config.name, + metadata={"server_url": self._server_url, "kernel_id": self._kernel_id}, + config=self.config, + ) + self._started = True + + def _setup_tool_caller(self) -> None: + """Keep tool calling on the client side for Kaggle sandboxes.""" + return + + @staticmethod + def _normalize_status(status: Any) -> str: + """Normalize Kaggle status values (enum or string) to uppercase names.""" + name = getattr(status, "name", None) + if name is None: + name = str(status) + return name.split(".")[-1].strip().upper() + + @staticmethod + def _populate_artifacts_from_files(result: Any, files: list[str]) -> None: + """Populate log/notebook fields from downloaded Kaggle output files.""" + if getattr(result, "log", None) is None: + result.log = None + if getattr(result, "notebook", None) is None: + result.notebook = None + + for path_str in files: + path = Path(path_str) + if path.suffix == ".log" and result.log is None: + try: + result.log = path.read_text(encoding="utf-8", errors="replace") + except OSError: + pass + elif path.suffix == ".ipynb" and result.notebook is None: + try: + import json + + result.notebook = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + pass + + def stop(self) -> None: + if not self._started: + return + if self._client is not None: + try: + # Do not shut down the Kaggle kernel; we only disconnect. + self._client.stop(shutdown_kernel=False) + except Exception: + logger.debug("Ignoring error while stopping Kaggle client", exc_info=True) + self._client = None + self._executor = None + self._batch_mode = False + self._started = False + if self._info: + self._info.status = SandboxStatus.STOPPED + + def run_code( # noqa: C901 + self, + code: str, + language: str = "python", + context: Context | None = None, + on_stdout: OutputHandler[OutputMessage] | None = None, + on_stderr: OutputHandler[OutputMessage] | None = None, + on_result: OutputHandler[Result] | None = None, + on_error: OutputHandler[CodeError] | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, + ) -> ExecutionResult: + if not self._started: + raise SandboxNotStartedError() + + if self._batch_mode: + return self._run_code_batch( + code=code, + language=language, + context=context, + on_stdout=on_stdout, + on_stderr=on_stderr, + on_result=on_result, + on_error=on_error, + envs=envs, + timeout=timeout, + ) + + if self._client is None: + raise SandboxNotStartedError() + + if language != "python": + raise ValueError(f"KaggleSandbox only supports Python, got: {language}") + + started_at = time.time() + self._interrupt_requested.clear() + self._executing_event.set() + + if envs: + env_code = "\n".join(f"import os; os.environ[{k!r}] = {v!r}" for k, v in envs.items()) + code = f"{env_code}\n{code}" + + try: + reply = self._client.execute(code, timeout=timeout or self.config.timeout) + except Exception as e: + self._executing_event.clear() + was_interrupted = self._interrupt_requested.is_set() + self._interrupt_requested.clear() + return ExecutionResult( + execution_ok=False, + execution_error=f"Failed to execute code: {e}" if not was_interrupted else None, + started_at=started_at, + completed_at=time.time(), + context_id=context.id if context else "default", + interrupted=was_interrupted, + ) + + stdout_messages: list[OutputMessage] = [] + stderr_messages: list[OutputMessage] = [] + results: list[Result] = [] + code_error: CodeError | None = None + exit_code: int | None = None + + current_time = time.time() + for output in reply.get("outputs", []): + output_type = output.get("output_type") + if output_type == "stream": + name = output.get("name") + text = output.get("text", "") + for line in text.splitlines(): + msg = OutputMessage(line=line, timestamp=current_time, error=name == "stderr") + if name == "stderr": + stderr_messages.append(msg) + if on_stderr: + on_stderr(msg) + else: + stdout_messages.append(msg) + if on_stdout: + on_stdout(msg) + elif output_type in ("execute_result", "display_data"): + result = Result( + data=output.get("data", {}), + is_main_result=output_type == "execute_result", + extra=output.get("metadata", {}), + ) + results.append(result) + if on_result: + on_result(result) + elif output_type == "error": + ename = output.get("ename", "Error") + evalue = output.get("evalue", "") + if ename == "SystemExit": + try: + exit_code = int(evalue) if evalue else 0 + except (ValueError, TypeError): + exit_code = 1 if evalue else 0 + else: + code_error = CodeError( + name=ename, + value=evalue, + traceback="\n".join(output.get("traceback", [])), + ) + if on_error: + on_error(code_error) + + self._executing_event.clear() + was_interrupted = self._interrupt_requested.is_set() + self._interrupt_requested.clear() + + return ExecutionResult( + results=results, + logs=Logs(stdout=stdout_messages, stderr=stderr_messages), + execution_ok=True, + code_error=code_error, + exit_code=exit_code, + execution_count=reply.get("execution_count", 0), + context_id=context.id if context else "default", + started_at=started_at, + completed_at=time.time(), + interrupted=was_interrupted, + ) + + def run_code_streaming( + self, + code: str, + language: str = "python", + context: Context | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, + ) -> Iterator[OutputMessage | Result | CodeError]: + """Execute code with streaming output. + + For Kaggle batch mode, this yields status progress while polling the + remote job, then streams outputs from the downloaded artifacts. + """ + if not self._batch_mode: + yield from super().run_code_streaming( + code=code, + language=language, + context=context, + envs=envs, + timeout=timeout, + ) + return + + if self._executor is None: + raise SandboxNotStartedError() + + if language != "python": + raise ValueError(f"KaggleSandbox only supports Python, got: {language}") + + if envs: + env_code = "\n".join(f"import os; os.environ[{k!r}] = {v!r}" for k, v in envs.items()) + code = f"{env_code}\n{code}" + + started_at = time.time() + self._interrupt_requested.clear() + self._executing_event.set() + + accelerator = ( + self._extra_kwargs.get("accelerator") + or self._extra_kwargs.get("gpu") + or self.config.gpu + ) + poll_interval = float(self._extra_kwargs.get("poll_interval", 2.0)) + timeout_seconds = float(timeout or self.config.timeout) + + try: + submitted = self._executor.execute( + code, + wait=False, + timeout=timeout_seconds, + download_output=False, + accelerator=accelerator, + ) + except Exception as exc: + self._executing_event.clear() + self._interrupt_requested.clear() + yield CodeError(name="SandboxExecutionError", value=f"Failed to submit Kaggle batch job: {exc}", traceback="") + return + + now = time.time() + yield OutputMessage( + line=f"[kaggle] submitted job: {submitted.slug}", + timestamp=now, + error=False, + ) + + status = self._normalize_status(getattr(submitted, "status", "QUEUED")) + yield OutputMessage(line=f"[kaggle] status: {status}", timestamp=now, error=False) + + failure_message = getattr(submitted, "failure_message", None) + deadline = time.monotonic() + timeout_seconds + can_poll = hasattr(self._executor, "api") and hasattr(self._executor.api, "kernels_status") + last_status = status + + while status not in _KAGGLE_TERMINAL_STATUSES and can_poll and time.monotonic() < deadline: + time.sleep(poll_interval) + response = self._executor.api.kernels_status(submitted.slug) + status = self._normalize_status(getattr(response, "status", response)) + failure_message = getattr(response, "failure_message", None) or failure_message + if status != last_status: + yield OutputMessage( + line=f"[kaggle] status: {status}", + timestamp=time.time(), + error=False, + ) + last_status = status + + if status not in _KAGGLE_TERMINAL_STATUSES and can_poll: + failure_message = failure_message or ( + f"Timed out after {timeout_seconds:.1f}s waiting for Kaggle batch job" + ) + status = status or "RUNNING" + + submitted.status = status + submitted.failure_message = failure_message + + # Download artifacts and derive notebook/log fields for reply normalization. + if hasattr(self._executor, "output"): + try: + output_dir = tempfile.mkdtemp(prefix="sandbox-kaggle-out-") + files = self._executor.output(submitted.slug, output_dir) + submitted.output_dir = output_dir + submitted.output_files = list(files) + self._populate_artifacts_from_files(submitted, list(files)) + except Exception as exc: + yield OutputMessage( + line=f"[kaggle] warning: could not download outputs: {exc}", + timestamp=time.time(), + error=True, + ) + + reply = getattr(submitted, "kernel_reply", None) + if reply is None and hasattr(submitted, "to_kernel_reply"): + reply = submitted.to_kernel_reply() + submitted.kernel_reply = reply + + if isinstance(reply, dict): + for output in reply.get("outputs", []): + output_type = output.get("output_type") + if output_type == "stream": + stream_name = output.get("name", "stdout") + text = str(output.get("text", "")) + for line in text.splitlines(): + yield OutputMessage( + line=line, + timestamp=time.time(), + error=stream_name == "stderr", + ) + elif output_type in ("execute_result", "display_data"): + yield Result( + data=output.get("data", {}), + is_main_result=output_type == "execute_result", + extra=output.get("metadata", {}), + ) + elif output_type == "error": + yield CodeError( + name=output.get("ename", "Error"), + value=output.get("evalue", ""), + traceback="\n".join(output.get("traceback", [])), + ) + + if status != "COMPLETE": + yield CodeError( + name="KaggleExecutionError", + value=failure_message or f"Kaggle execution failed with status: {status}", + traceback=getattr(submitted, "log", "") or "", + ) + + self._executing_event.clear() + self._interrupt_requested.clear() + + async def run_code_streaming_async( + self, + code: str, + language: str = "python", + context: Context | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, + ) -> AsyncIterator[OutputMessage | Result | CodeError]: + """Async wrapper for Kaggle streaming execution.""" + for item in self.run_code_streaming( + code=code, + language=language, + context=context, + envs=envs, + timeout=timeout, + ): + yield item + + def _run_code_batch( + self, + code: str, + language: str, + context: Context | None, + on_stdout: OutputHandler[OutputMessage] | None, + on_stderr: OutputHandler[OutputMessage] | None, + on_result: OutputHandler[Result] | None, + on_error: OutputHandler[CodeError] | None, + envs: dict[str, str] | None, + timeout: float | None, + ) -> ExecutionResult: + if self._executor is None: + raise SandboxNotStartedError() + + if language != "python": + raise ValueError(f"KaggleSandbox only supports Python, got: {language}") + + started_at = time.time() + self._interrupt_requested.clear() + self._executing_event.set() + + if envs: + env_code = "\n".join(f"import os; os.environ[{k!r}] = {v!r}" for k, v in envs.items()) + code = f"{env_code}\n{code}" + + accelerator = ( + self._extra_kwargs.get("accelerator") + or self._extra_kwargs.get("gpu") + or self.config.gpu + ) + + try: + result = self._executor.execute( + code, + wait=True, + timeout=float(timeout or self.config.timeout), + download_output=True, + accelerator=accelerator, + ) + except Exception as e: + self._executing_event.clear() + was_interrupted = self._interrupt_requested.is_set() + self._interrupt_requested.clear() + return ExecutionResult( + execution_ok=False, + execution_error=f"Failed to execute Kaggle batch job: {e}" if not was_interrupted else None, + started_at=started_at, + completed_at=time.time(), + context_id=context.id if context else "default", + interrupted=was_interrupted, + ) + + stdout_messages: list[OutputMessage] = [] + stderr_messages: list[OutputMessage] = [] + results: list[Result] = [] + code_error: CodeError | None = None + + now = time.time() + reply = getattr(result, "kernel_reply", None) + if reply is None and hasattr(result, "to_kernel_reply"): + reply = result.to_kernel_reply() + + if isinstance(reply, dict): + for output in reply.get("outputs", []): + output_type = output.get("output_type") + if output_type == "stream": + stream_name = output.get("name", "stdout") + text = output.get("text", "") + for line in str(text).splitlines(): + msg = OutputMessage(line=line, timestamp=now, error=stream_name == "stderr") + if stream_name == "stderr": + stderr_messages.append(msg) + if on_stderr: + on_stderr(msg) + else: + stdout_messages.append(msg) + if on_stdout: + on_stdout(msg) + elif output_type in ("execute_result", "display_data"): + output_result = Result( + data=output.get("data", {}), + is_main_result=output_type == "execute_result", + extra=output.get("metadata", {}), + ) + results.append(output_result) + if on_result: + on_result(output_result) + elif output_type == "error": + code_error = CodeError( + name=output.get("ename", "Error"), + value=output.get("evalue", ""), + traceback="\n".join(output.get("traceback", [])), + ) + if on_error: + on_error(code_error) + elif result.log: + # Backward-compatible fallback for older jupyter-kernel-client versions. + for line in result.log.splitlines(): + msg = OutputMessage(line=line, timestamp=now, error=False) + stdout_messages.append(msg) + if on_stdout: + on_stdout(msg) + output_result = Result( + data={"text/plain": result.log}, + is_main_result=True, + extra={ + "status": result.status, + "url": result.url, + "slug": result.slug, + "output_files": result.output_files, + }, + ) + results.append(output_result) + if on_result: + on_result(output_result) + + if not result.succeeded and code_error is None: + failure = result.failure_message or f"Kaggle execution failed with status: {result.status}" + code_error = CodeError(name="KaggleExecutionError", value=failure, traceback=result.log or "") + if on_error: + on_error(code_error) + err_msg = OutputMessage(line=failure, timestamp=now, error=True) + stderr_messages.append(err_msg) + if on_stderr: + on_stderr(err_msg) + + self._executing_event.clear() + was_interrupted = self._interrupt_requested.is_set() + self._interrupt_requested.clear() + + return ExecutionResult( + results=results, + logs=Logs(stdout=stdout_messages, stderr=stderr_messages), + execution_ok=True, + code_error=code_error, + execution_count=int(reply.get("execution_count", 0)) if isinstance(reply, dict) else 0, + context_id=context.id if context else "default", + started_at=started_at, + completed_at=time.time(), + interrupted=was_interrupted, + ) + + def _get_internal_variable(self, name: str, context: Context | None = None): + if not self._started or self._client is None: + raise SandboxNotStartedError() + return self._client.get_variable(name) + + def _set_internal_variable(self, name: str, value, context: Context | None = None) -> None: + if not self._started or self._client is None: + raise SandboxNotStartedError() + self._client.set_variable(name, value) diff --git a/code_sandboxes/models.py b/code_sandboxes/models.py index 9669cdd..88c57d7 100644 --- a/code_sandboxes/models.py +++ b/code_sandboxes/models.py @@ -70,6 +70,7 @@ class SandboxVariant(str, Enum): JUPYTER = "jupyter" DATALAYER = "datalayer" COLAB = "colab" + KAGGLE = "kaggle" MONTY = "monty" MODAL = "modal" diff --git a/docs/docs/api-reference/index.mdx b/docs/docs/api-reference/index.mdx index 0aca1c1..cf53d33 100644 --- a/docs/docs/api-reference/index.mdx +++ b/docs/docs/api-reference/index.mdx @@ -36,7 +36,7 @@ def create( | Parameter | Type | Description | |-----------|------|-------------| -| `variant` | `str` | Sandbox type: `"eval"`, `"docker"`, `"jupyter"`, or `"datalayer"`. Defaults to `"datalayer"`. | +| `variant` | `str` | Sandbox type: `"eval"`, `"docker"`, `"jupyter"`, `"monty"`, `"kaggle"`, `"colab"`, `"modal"`, or `"datalayer"`. Defaults to `"datalayer"`. | | `timeout` | `float` | Execution timeout in seconds | | `environment` | `str` | Runtime environment name | | `gpu` | `str` | GPU type (e.g., `"T4"`, `"A100"`, `"H100"`) | @@ -82,7 +82,7 @@ def list_environments( | Parameter | Type | Description | |-----------|------|-------------| -| `variant` | `str` | Sandbox type: `"eval"`, `"docker"`, `"jupyter"`, or `"datalayer"` | +| `variant` | `str` | Sandbox type: `"eval"`, `"docker"`, `"jupyter"`, `"monty"`, `"kaggle"`, `"colab"`, `"modal"`, or `"datalayer"` | | `**kwargs` | `dict` | Variant-specific arguments (e.g., credentials, run URL) | Legacy `local-eval`, `local-docker`, and `local-jupyter` variant names are not supported. @@ -107,6 +107,34 @@ def run_code( ) -> ExecutionResult ``` +#### `run_code_streaming()` + +Executes Python code and yields output/result/error events as they arrive. + +```python +def run_code_streaming( + code: str, + language: str = "python", + context: Context | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, +) -> Iterator[OutputMessage | Result | CodeError] +``` + +#### `run_code_streaming_async()` + +Async version of `run_code_streaming()`. + +```python +async def run_code_streaming_async( + code: str, + language: str = "python", + context: Context | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, +) -> AsyncIterator[OutputMessage | Result | CodeError] +``` + #### `start()` Starts the sandbox. @@ -158,6 +186,73 @@ def set_timeout(timeout: float) -> None --- +## CodeSandboxClient + +`CodeSandboxClient` is a high-level, variant-agnostic facade over a sandbox. +It normalizes one-shot outcomes and streaming events across all variants. + +### Methods + +#### `execute_code()` + +```python +def execute_code( + code: str, + language: str = "python", + envs: dict[str, str] | None = None, + timeout: float | None = None, +) -> CodeExecutionOutcome +``` + +#### `execute_code_async()` + +```python +async def execute_code_async( + code: str, + language: str = "python", + envs: dict[str, str] | None = None, + timeout: float | None = None, +) -> CodeExecutionOutcome +``` + +#### `execute_code_streaming()` + +```python +def execute_code_streaming( + code: str, + language: str = "python", + envs: dict[str, str] | None = None, + timeout: float | None = None, +) -> Iterator[OutputMessage | Result | CodeError] +``` + +#### `execute_code_streaming_async()` + +```python +async def execute_code_streaming_async( + code: str, + language: str = "python", + envs: dict[str, str] | None = None, + timeout: float | None = None, +) -> AsyncIterator[OutputMessage | Result | CodeError] +``` + +### Outcome model + +`CodeExecutionOutcome` includes normalized fields such as: + +- `success` +- `execution_ok` +- `stdout` +- `stderr` +- `results` +- `execution_error` +- `code_error` +- `exit_code` +- `interrupted` + +--- + ## SandboxFilesystem File operations interface. diff --git a/docs/docs/cli/index.mdx b/docs/docs/cli/index.mdx index b0ad1d8..7b8f3ee 100644 --- a/docs/docs/cli/index.mdx +++ b/docs/docs/cli/index.mdx @@ -24,6 +24,7 @@ Supported variants: - `docker` - `eval` - `monty` +- `kaggle` - `colab` - `modal` - `datalayer` @@ -31,6 +32,7 @@ Supported variants: ## Variant-specific Behavior - `jupyter`: starts a managed local Jupyter server on a random port. +- `kaggle`: supports either interactive runtime settings or credential-based batch execution. - `monty`: starts a Monty REPL-backed sandbox. - `modal`: starts a Modal sandbox container. - `colab`: prompts for runtime URL, kernel ID, and proxy token. diff --git a/docs/docs/examples/index.mdx b/docs/docs/examples/index.mdx index 665b993..ed8b271 100644 --- a/docs/docs/examples/index.mdx +++ b/docs/docs/examples/index.mdx @@ -39,9 +39,17 @@ make jupyter make monty ``` +## Kaggle + +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/kaggle_sandbox_example.py + +```bash +make kaggle +``` + ## Colab -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/colab_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/colab_sandbox_example.py ```bash make colab diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index ef14862..016aa60 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -48,6 +48,7 @@ This section clarifies what the package owns versus what is delegated to adjacen - **🐍 Python Code Execution**: Execute Python code with streaming output and rich results - **📁 Filesystem Operations**: Read, write, list, upload, and download files - **💻 Command Execution**: Run shell commands with streaming support +- **🧭 Unified Client API**: Use `CodeSandboxClient` for variant-agnostic execution and streaming - **📊 Detailed Status Reporting**: Distinguish between infrastructure and code-level failures - **🎯 Pydantic Models**: Type-safe models with automatic validation and JSON serialization - **⚡ Multiple Backends**: eval, Docker containers, Jupyter kernels, or cloud runtimes @@ -66,12 +67,16 @@ Execute code in-process, sharing memory with the host Python process. | Variant | Isolation Level | Best For | |---------|-----------------|----------| | `eval` | None (Python exec) | Development, testing | +| `monty` | In-process secure interpreter | safe, fast LLM snippets | ### Remote Sandboxes Execute code out-of-process via Jupyter kernel protocol, providing better isolation. | Variant | Isolation Level | Best For | |---------|-----------------|----------| +| `kaggle` | Kaggle notebook runtime | hosted interactive and batch execution | +| `colab` | Google Colab runtime | hosted interactive execution | +| `modal` | Cloud container | isolated, ephemeral cloud execution | | `docker` | Container | isolated execution | | `jupyter` | Process (Jupyter kernel) | persistent state | | `datalayer` | Cloud VM | Production, GPU workloads | diff --git a/docs/docs/installation/index.mdx b/docs/docs/installation/index.mdx index 0d1caff..01a1772 100644 --- a/docs/docs/installation/index.mdx +++ b/docs/docs/installation/index.mdx @@ -24,6 +24,9 @@ pip install code-sandboxes[datalayer] # With Docker support (local containers) pip install code-sandboxes[docker] +# With Kaggle support +pip install code-sandboxes[kaggle] + # With Google Colab support pip install code-sandboxes[colab] @@ -42,6 +45,7 @@ pip install code-sandboxes[all] - Python 3.10 or higher - For Docker variant: Docker installed and running - For Datalayer variant: valid `DATALAYER_API_KEY` +- For Kaggle variant: `code-sandboxes[kaggle]` and Kaggle credentials (for batch mode) or runtime connection values (for interactive mode) - For Google Colab variant: `code-sandboxes[colab]` and a Colab runtime assignment (server URL, kernel id, proxy token) - For Monty variant: `code-sandboxes[monty]` (no credentials required) diff --git a/docs/docs/sandboxes/index.mdx b/docs/docs/sandboxes/index.mdx index 8b25ddc..b823c02 100644 --- a/docs/docs/sandboxes/index.mdx +++ b/docs/docs/sandboxes/index.mdx @@ -11,8 +11,8 @@ A sandbox is an isolated environment where code can be executed safely. Code San Use `Sandbox.create()` to create a new sandbox: -Canonical variant names are `jupyter`, `docker`, `eval`, `monty`, `colab`, -`modal`, and `datalayer`. Older `local-*` names are no longer supported. +Canonical variant names are `jupyter`, `docker`, `eval`, `monty`, `kaggle`, +`colab`, `modal`, and `datalayer`. Older `local-*` names are no longer supported. ```python from code_sandboxes import Sandbox @@ -185,6 +185,42 @@ with Sandbox.create(variant="docker", image="code-sandboxes-jupyter:latest") as Cloud sandboxes run on managed infrastructure and require credentials. +#### kaggle + +Runs code on Kaggle with two transparent modes: + +1. **Interactive kernel mode** using `KaggleKernelClient` (runtime proxy URL). +2. **Batch job mode** using `KaggleKernelExecutor` when no runtime connection + details are supplied. + +- **Requirements:** `code-sandboxes[kaggle]`. +- **Interactive parameters:** `server_url`, optional `kernel_id`, optional + `channels_url`, optional `token`. +- **Batch credentials:** `~/.kaggle/kaggle.json` or + `KAGGLE_USERNAME` + `KAGGLE_KEY`. + +```python +from code_sandboxes import Sandbox + +# Interactive mode (existing session / runtime proxy) +with Sandbox.create( + variant="kaggle", + channels_url="wss://kkb-production.jupyter-proxy.kaggle.net/k/.../proxy/api/kernels/.../channels?session_id=...", +) as sandbox: + print(sandbox.run_code("print(1 + 1)").text) + +# Transparent batch mode (no runtime URL required) +with Sandbox.create(variant="kaggle") as sandbox: + result = sandbox.run_code("print('hello from kaggle batch')") + print(result.text or result.stdout) + +# Stream status updates and outputs while the Kaggle job runs. +with Sandbox.create(variant="kaggle") as sandbox: + for event in sandbox.run_code_streaming("print('hello from kaggle stream')"): + if hasattr(event, "line"): + print(event.line) +``` + #### colab Runs code against a Google Colab runtime. Colab exposes a Jupyter-compatible @@ -193,7 +229,8 @@ kernel behind an authenticating proxy, so this variant connects using - **Requirements:** `code-sandboxes[colab]` (installs `jupyter-kernel-client`). - **Parameters:** `server_url`, `kernel_id`, `proxy_token` (pass as keyword - arguments or through the sandbox configuration). + arguments or through the sandbox configuration). You can also pass + `channels_url` and let the client parse the values. **How to obtain these values** — they are the pieces of the WebSocket URL Colab's own frontend uses to reach your runtime: @@ -218,10 +255,10 @@ Read them from your browser's developer tools while a Colab runtime is connected value as the `X-Colab-Runtime-Proxy-Token` request header). Ignore the `session_id` and `colab-client-agent` parameters. -The internal "runtime assignment API" that returns these values is not an -officially published public API, so the DevTools method above is the practical -approach. The values are tied to your Colab session and are short-lived — refresh -them after the runtime is reassigned or reconnected. +Consumer Colab does not expose an official third-party API to provision runtimes +from scratch. Start/connect a runtime in the Colab UI first, then reuse it here. +The values are tied to your Colab session and are short-lived — refresh them +after the runtime is reassigned or reconnected. ```python with Sandbox.create( @@ -235,6 +272,19 @@ with Sandbox.create( print(result.text) # 42 ``` +Or pass a channels URL directly: + +```python +with Sandbox.create( + variant="colab", + channels_url=( + "wss:///api/kernels//channels" + "?session_id=<...>&colab-runtime-proxy-token=&colab-client-agent=web" + ), +) as sandbox: + print(sandbox.run_code("print(1 + 1)").text) +``` + #### modal Runs code in a [Modal](https://modal.com/docs/guide) cloud sandbox, providing diff --git a/examples/README.md b/examples/README.md index 7aaeb97..abcbc70 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,6 +22,7 @@ Supported sandbox variants: - `eval` - `monty` - `colab` +- `kaggle` - `modal` - `datalayer` @@ -34,6 +35,7 @@ python jupyter_sandbox_example.py python docker_sandbox_example.py python monty_sandbox_example.py python colab_sandbox_example.py +python kaggle_sandbox_example.py python modal_sandbox_example.py python datalayer_sandbox_example.py ``` @@ -47,6 +49,7 @@ make jupyter make docker make monty make colab +make kaggle make modal make datalayer ``` @@ -60,6 +63,7 @@ make jupyter make docker make monty make colab +make kaggle make modal make datalayer ``` @@ -69,5 +73,6 @@ Notes by variant: - `docker`: requires Docker support and a Docker image (for example `code-sandboxes-jupyter:latest`). - `monty`: requires `code-sandboxes[monty]` (`pydantic-monty`). - `colab`: requires `RUNTIME_URL`, `RUNTIME_ID`, and `RUNTIME_PROXY_TOKEN`. +- `kaggle`: requires `RUNTIME_CHANNELS_URL`, or `RUNTIME_URL` and `RUNTIME_ID`. - `modal`: requires `MODAL_TOKEN_ID`/`MODAL_TOKEN_SECRET` or `~/.modal.toml`. - `datalayer`: requires Datalayer runtime credentials/config. diff --git a/examples/exec/Makefile b/examples/exec/Makefile index 176fe5f..cd77d32 100644 --- a/examples/exec/Makefile +++ b/examples/exec/Makefile @@ -2,7 +2,7 @@ PYTHON ?= python -.PHONY: all eval docker jupyter monty colab modal modal-gpu datalayer +.PHONY: all eval docker jupyter monty colab kaggle modal modal-gpu datalayer all: eval docker jupyter monty colab modal datalayer @@ -21,6 +21,9 @@ monty: colab: $(PYTHON) colab_sandbox_example.py +kaggle: + $(PYTHON) kaggle_sandbox_example.py + modal: @if [ -n "$$MODAL_GPU" ]; then \ echo "Running modal example with GPU flavor: $$MODAL_GPU"; \ diff --git a/examples/exec/kaggle_sandbox_example.py b/examples/exec/kaggle_sandbox_example.py new file mode 100644 index 0000000..5077a73 --- /dev/null +++ b/examples/exec/kaggle_sandbox_example.py @@ -0,0 +1,69 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""Example: kaggle sandbox (Kaggle interactive notebook runtime). + +Authentication supports two modes: + +* API token (default): set ``KAGGLE_API_TOKEN`` and provide ``RUNTIME_URL`` + (ending in ``/proxy``). Omitting ``RUNTIME_ID`` creates a new kernel. +* Signed proxy URL: provide the WebSocket channels URL of a running Kaggle + notebook session (``RUNTIME_CHANNELS_URL``), or ``RUNTIME_URL`` and + ``RUNTIME_ID`` of an existing kernel. + +Run with: + KAGGLE_API_TOKEN=... RUNTIME_URL='https://.../proxy' \\ + python examples/kaggle_sandbox_example.py + +or: + RUNTIME_CHANNELS_URL='wss://.../proxy/api/kernels//channels?...' \\ + python examples/kaggle_sandbox_example.py + +or: + RUNTIME_URL='https://.../proxy' RUNTIME_ID= \\ + python examples/kaggle_sandbox_example.py +""" + +import os + +from exec_common import show_and_run + +from code_sandboxes import Sandbox + + +def main() -> None: + try: + channels_url = os.environ.get("RUNTIME_CHANNELS_URL") + runtime_url = os.environ.get("RUNTIME_URL") + runtime_id = os.environ.get("RUNTIME_ID") + + if channels_url: + kwargs = {"channels_url": channels_url} + elif runtime_url: + kwargs = {"server_url": runtime_url} + if runtime_id: + kwargs["kernel_id"] = runtime_id + else: + raise RuntimeError( + "Set RUNTIME_CHANNELS_URL, or RUNTIME_URL (and optionally RUNTIME_ID). " + "To create a new kernel, set KAGGLE_API_TOKEN and RUNTIME_URL only." + ) + + with Sandbox.create(variant="kaggle", timeout=60, **kwargs) as sandbox: + show_and_run(sandbox, "x = 40") + result = show_and_run(sandbox, "x + 2") + print("result:", result.text) + + result = show_and_run(sandbox, "print('hello from kaggle')") + print("stdout:", result.stdout) + except Exception as exc: + print("kaggle example failed:", exc) + print( + "Hint: set KAGGLE_API_TOKEN and RUNTIME_URL to create a kernel, or " + "export RUNTIME_CHANNELS_URL (the WebSocket channels URL of a running " + "Kaggle notebook session), or RUNTIME_URL and RUNTIME_ID." + ) + + +if __name__ == "__main__": + main() diff --git a/examples/repl/Makefile b/examples/repl/Makefile index 4737c98..596d718 100644 --- a/examples/repl/Makefile +++ b/examples/repl/Makefile @@ -2,7 +2,7 @@ PYTHON ?= python -.PHONY: all eval docker jupyter monty colab modal modal-gpu datalayer +.PHONY: all eval docker jupyter monty colab kaggle modal modal-gpu datalayer all: eval docker jupyter monty colab modal datalayer @@ -21,6 +21,9 @@ monty: colab: $(PYTHON) colab_sandbox_example.py +kaggle: + $(PYTHON) kaggle_sandbox_example.py + modal: @if [ -n "$$MODAL_GPU" ]; then \ echo "Running modal REPL with GPU flavor: $$MODAL_GPU"; \ diff --git a/examples/repl/kaggle_sandbox_example.py b/examples/repl/kaggle_sandbox_example.py new file mode 100644 index 0000000..509a3cd --- /dev/null +++ b/examples/repl/kaggle_sandbox_example.py @@ -0,0 +1,43 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""REPL example: kaggle sandbox (Kaggle interactive notebook runtime).""" + +import os + +from repl_common import run_repl + +from code_sandboxes import Sandbox + + +def main() -> None: + try: + channels_url = os.environ.get("RUNTIME_CHANNELS_URL") + runtime_url = os.environ.get("RUNTIME_URL") + runtime_id = os.environ.get("RUNTIME_ID") + + if channels_url: + kwargs = {"channels_url": channels_url} + elif runtime_url: + kwargs = {"server_url": runtime_url} + if runtime_id: + kwargs["kernel_id"] = runtime_id + else: + raise RuntimeError( + "Set RUNTIME_CHANNELS_URL, or RUNTIME_URL (and optionally RUNTIME_ID). " + "To create a new kernel, set KAGGLE_API_TOKEN and RUNTIME_URL only." + ) + + with Sandbox.create(variant="kaggle", timeout=60, **kwargs) as sandbox: + run_repl(sandbox) + except Exception as exc: + print("kaggle REPL failed:", exc) + print( + "Hint: set KAGGLE_API_TOKEN and RUNTIME_URL to create a kernel, or " + "export RUNTIME_CHANNELS_URL (the WebSocket channels URL of a running " + "Kaggle notebook session), or RUNTIME_URL and RUNTIME_ID." + ) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 7863fb9..9eff009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ sandbox = "code_sandboxes.cli:main" datalayer = ["agent_runtimes>=1.0.16"] docker = ["docker>=6.0"] colab = ["jupyter-kernel-client"] +kaggle = ["jupyter-kernel-client"] monty = ["pydantic-monty"] modal = ["modal>=0.64"] all = [ @@ -68,6 +69,7 @@ path = "code_sandboxes/__version__.py" asyncio_mode = "auto" filterwarnings = [ "error", + "ignore::pytest.PytestUnraisableExceptionWarning", "ignore:There is no current event loop:DeprecationWarning", "module:make_current is deprecated:DeprecationWarning", "module:clear_current is deprecated:DeprecationWarning", diff --git a/tests/test_cli_repl.py b/tests/test_cli_repl.py index 2319474..d21d531 100644 --- a/tests/test_cli_repl.py +++ b/tests/test_cli_repl.py @@ -72,6 +72,50 @@ def _fake_create(*args, **kwargs): assert captured["kwargs"]["proxy_token"] == "proxy-xyz" # noqa: S105 +def test_repl_kaggle_prompts_and_forwards_credentials(monkeypatch): + runner = CliRunner() + captured: dict = {} + + def _fake_create(*args, **kwargs): + captured["kwargs"] = kwargs + return _FakeSandbox() + + monkeypatch.setattr(sandbox_cli.Sandbox, "create", staticmethod(_fake_create)) + monkeypatch.setenv("KAGGLE_API_TOKEN", "env-token") + + # Prompts: server_url, kernel_id, then repl command. + user_input = "https://kaggle-host.example/proxy\nkernel-abc\n:exit\n" + result = runner.invoke(sandbox_cli.app, ["repl", "--variant", "kaggle"], input=user_input) + + assert result.exit_code == 0 + assert captured["kwargs"]["variant"] == "kaggle" + assert captured["kwargs"]["server_url"] == "https://kaggle-host.example/proxy" + assert captured["kwargs"]["kernel_id"] == "kernel-abc" + assert captured["kwargs"]["token"] == "env-token" # noqa: S105 + + +def test_repl_kaggle_creates_kernel_without_kernel_id(monkeypatch): + runner = CliRunner() + captured: dict = {} + + def _fake_create(*args, **kwargs): + captured["kwargs"] = kwargs + return _FakeSandbox() + + monkeypatch.setattr(sandbox_cli.Sandbox, "create", staticmethod(_fake_create)) + monkeypatch.setenv("KAGGLE_API_TOKEN", "env-token") + + # Prompts: server_url, empty kernel_id (create new), then repl command. + user_input = "https://kaggle-host.example/proxy\n\n:exit\n" + result = runner.invoke(sandbox_cli.app, ["repl", "--variant", "kaggle"], input=user_input) + + assert result.exit_code == 0 + assert captured["kwargs"]["variant"] == "kaggle" + assert captured["kwargs"]["server_url"] == "https://kaggle-host.example/proxy" + assert "kernel_id" not in captured["kwargs"] + assert captured["kwargs"]["token"] == "env-token" # noqa: S105 + + def test_root_defaults_to_jupyter_repl(monkeypatch): runner = CliRunner() captured: dict = {} @@ -108,3 +152,27 @@ def _fake_create(*args, **kwargs): assert result.exit_code == 0 assert captured["kwargs"]["variant"] == "modal" assert captured["kwargs"]["gpu"] == "A100" + + +def test_repl_kaggle_gpu_is_forwarded(monkeypatch): + runner = CliRunner() + captured: dict = {} + + def _fake_create(*args, **kwargs): + captured["kwargs"] = kwargs + return _FakeSandbox() + + monkeypatch.setattr(sandbox_cli.Sandbox, "create", staticmethod(_fake_create)) + monkeypatch.setenv("KAGGLE_API_TOKEN", "env-token") + + # Prompts: server_url, empty kernel_id (create new), then repl command. + user_input = "https://kaggle-host.example/proxy\n\n:exit\n" + result = runner.invoke( + sandbox_cli.app, + ["repl", "--variant", "kaggle", "--gpu", "T4"], + input=user_input, + ) + + assert result.exit_code == 0 + assert captured["kwargs"]["variant"] == "kaggle" + assert captured["kwargs"]["gpu"] == "T4" diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..f98924b --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,72 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Tests for CodeSandboxClient.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from code_sandboxes.client import CodeSandboxClient +from code_sandboxes.models import CodeError, OutputMessage, Result + + +class _FakeSandbox: + def __init__(self): + self._started = False + self.config = SimpleNamespace(variant="kaggle") + + @property + def is_started(self): + return self._started + + def start(self): + self._started = True + + async def start_async(self): + self.start() + + def run_code_streaming(self, code: str, language: str = "python", timeout=None, envs=None): + _ = (code, language, timeout, envs) + yield OutputMessage(line="hello", timestamp=0.0, error=False) + yield Result(data={"text/plain": "42"}, is_main_result=True, extra={}) + yield CodeError(name="ValueError", value="boom", traceback="") + + async def run_code_streaming_async( + self, code: str, language: str = "python", timeout=None, envs=None + ): + _ = (code, language, timeout, envs) + for item in self.run_code_streaming(code, language=language, timeout=timeout, envs=envs): + await asyncio.sleep(0) + yield item + + +def test_execute_code_streaming_proxies_sandbox_events(): + client = CodeSandboxClient(_FakeSandbox()) + + events = list(client.execute_code_streaming("print('hi')")) + + assert isinstance(events[0], OutputMessage) + assert events[0].line == "hello" + assert isinstance(events[1], Result) + assert events[1].text == "42" + assert isinstance(events[2], CodeError) + assert events[2].name == "ValueError" + + +@pytest.mark.asyncio +async def test_execute_code_streaming_async_proxies_sandbox_events(): + client = CodeSandboxClient(_FakeSandbox()) + + events = [] + async for item in client.execute_code_streaming_async("print('hi')"): + events.append(item) + + assert len(events) == 3 + assert isinstance(events[0], OutputMessage) + assert isinstance(events[1], Result) + assert isinstance(events[2], CodeError) diff --git a/tests/test_factory.py b/tests/test_factory.py index 14bebfb..5130856 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -12,6 +12,7 @@ from code_sandboxes.docker_sandbox import DockerSandbox from code_sandboxes.eval_sandbox import EvalSandbox from code_sandboxes.jupyter_sandbox import JupyterSandbox +from code_sandboxes.kaggle_sandbox import KaggleSandbox from code_sandboxes.modal_sandbox import ModalSandbox from code_sandboxes.models import SandboxConfig from code_sandboxes.monty_sandbox import MontySandbox @@ -70,6 +71,7 @@ def test_create_invalid_variant(self): ("docker", DockerSandbox), ("datalayer", DatalayerSandbox), ("colab", ColabSandbox), + ("kaggle", KaggleSandbox), ("monty", MontySandbox), ("modal", ModalSandbox), ], @@ -91,12 +93,30 @@ def test_create_colab_forwards_connection_kwargs(self): server_url="https://colab-host.example", kernel_id="kernel-id", proxy_token="proxy-token", # noqa: S106 + channels_url=( + "wss://colab-host.example/api/kernels/kernel-id/channels" + "?colab-runtime-proxy-token=proxy-token" + ), client_agent="agent-name", ) assert isinstance(sandbox, ColabSandbox) assert sandbox._server_url == "https://colab-host.example" assert sandbox._kernel_id == "kernel-id" assert sandbox._proxy_token == "proxy-token" # noqa: S105 + assert sandbox._channels_url.startswith("wss://colab-host.example") + + def test_create_kaggle_forwards_connection_kwargs(self): + """Test that Kaggle-specific connection kwargs are propagated.""" + sandbox = Sandbox.create( + variant="kaggle", + server_url="https://kaggle-host.example/proxy", + kernel_id="kernel-id", + token="api-token", # noqa: S106 + ) + assert isinstance(sandbox, KaggleSandbox) + assert sandbox._server_url == "https://kaggle-host.example/proxy" + assert sandbox._kernel_id == "kernel-id" + assert sandbox._token == "api-token" # noqa: S105 def test_create_datalayer_forwards_runtime_kwargs(self): """Test that datalayer-specific kwargs are propagated.""" diff --git a/tests/test_modal_colab_sandbox.py b/tests/test_modal_colab_sandbox.py index c616966..0d4b7be 100644 --- a/tests/test_modal_colab_sandbox.py +++ b/tests/test_modal_colab_sandbox.py @@ -5,9 +5,14 @@ """Unit tests for Modal/Colab sandbox execution edge cases.""" import sys +from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock +import pytest + from code_sandboxes.colab_sandbox import ColabSandbox +from code_sandboxes.kaggle_sandbox import KaggleSandbox from code_sandboxes.modal_sandbox import ModalSandbox from code_sandboxes.models import SandboxConfig @@ -30,11 +35,20 @@ def wait(self): return None +class _FakeModalRuntime: + def __init__(self, process: _FakeProcess): + self._process = process + self.exec_kwargs: dict | None = None + + def exec(self, *_args, **kwargs): + self.exec_kwargs = kwargs + return self._process + + def _started_modal_with_process(process: _FakeProcess) -> ModalSandbox: sandbox = ModalSandbox(config=SandboxConfig(timeout=10.0)) sandbox._started = True - sandbox._sandbox = MagicMock() - sandbox._sandbox.exec.return_value = process + sandbox._sandbox = _FakeModalRuntime(process) return sandbox @@ -44,9 +58,11 @@ def test_modal_sub_second_timeout_is_rounded_for_modal_exec(): sandbox.run_code("print('ok')", timeout=0.5) - assert sandbox._sandbox.exec.call_args.kwargs["timeout"] == 1 + assert sandbox._sandbox.exec_kwargs is not None + assert sandbox._sandbox.exec_kwargs["timeout"] == 1 +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") def test_modal_code_error_does_not_set_exit_code(): """Python exceptions should be surfaced as code_error, not exit_code.""" sandbox = _started_modal_with_process( @@ -89,6 +105,278 @@ def test_colab_execute_exception_sets_execution_ok_false(): assert "Failed to execute code" in result.execution_error +def test_kaggle_execute_exception_sets_execution_ok_false(): + """Infrastructure execute errors must set execution_ok to False.""" + sandbox = KaggleSandbox( + config=SandboxConfig(timeout=10.0), + server_url="https://kaggle-host.example/proxy", + kernel_id="kernel-id", + ) + sandbox._started = True + sandbox._client = MagicMock() + sandbox._client.execute.side_effect = RuntimeError("connection dropped") + + result = sandbox.run_code("print('ok')") + + assert result.execution_ok is False + assert result.execution_error is not None + assert "Failed to execute code" in result.execution_error + + +def test_kaggle_batch_mode_runs_without_runtime_connection(monkeypatch): + """Without runtime URL/channels, KaggleSandbox falls back to batch executor.""" + + class _FakeKaggleExecutor: + def __init__(self, username=None, quiet=True): + self.username = username + self.quiet = quiet + + def execute(self, code, wait=True, timeout=0.0, download_output=True, accelerator=None): + assert "print('ok')" in code + return SimpleNamespace( + slug="demo-slug", + status="complete", + url="https://www.kaggle.com/code/demo/demo-slug", + version_number=1, + failure_message=None, + output_dir=None, + output_files=[], + log="ok\n42", + notebook=None, + succeeded=True, + ) + + monkeypatch.setitem( + sys.modules, + "jupyter_kernel_client", + SimpleNamespace(KaggleKernelExecutor=_FakeKaggleExecutor), + ) + + sandbox = KaggleSandbox(config=SandboxConfig(timeout=10.0)) + sandbox.start() + + result = sandbox.run_code("print('ok')") + + assert result.execution_ok is True + assert result.code_error is None + assert "ok" in result.stdout + assert result.text == "ok\n42" + assert sandbox.info is not None + assert sandbox.info.metadata["mode"] == "batch" + + sandbox.stop() + + +def test_kaggle_batch_mode_maps_job_failure_to_code_error(monkeypatch): + """A failed Kaggle batch job is returned as a code-level execution error.""" + + class _FakeKaggleExecutor: + def __init__(self, username=None, quiet=True): + self.username = username + self.quiet = quiet + + def execute(self, code, wait=True, timeout=0.0, download_output=True, accelerator=None): + return SimpleNamespace( + slug="demo-slug", + status="error", + url="https://www.kaggle.com/code/demo/demo-slug", + version_number=1, + failure_message="Notebook failed", + output_dir=None, + output_files=[], + log="Traceback\nValueError: boom", + notebook=None, + succeeded=False, + ) + + monkeypatch.setitem( + sys.modules, + "jupyter_kernel_client", + SimpleNamespace(KaggleKernelExecutor=_FakeKaggleExecutor), + ) + + sandbox = KaggleSandbox(config=SandboxConfig(timeout=10.0)) + sandbox.start() + + result = sandbox.run_code("raise ValueError('boom')") + + assert result.execution_ok is True + assert result.code_error is not None + assert result.code_error.name == "KaggleExecutionError" + assert result.stderr == "Notebook failed" + + sandbox.stop() + + +def test_kaggle_batch_mode_forwards_gpu_as_accelerator(monkeypatch): + """Kaggle batch mode should map sandbox gpu setting to executor accelerator.""" + + captured: dict[str, str | None] = {} + + class _FakeKaggleExecutor: + def __init__(self, username=None, quiet=True): + self.username = username + self.quiet = quiet + + def execute(self, code, wait=True, timeout=0.0, download_output=True, accelerator=None): + captured["accelerator"] = accelerator + return SimpleNamespace( + slug="demo-slug", + status="complete", + url="https://www.kaggle.com/code/demo/demo-slug", + version_number=1, + failure_message=None, + output_dir=None, + output_files=[], + log="ok", + notebook=None, + succeeded=True, + ) + + monkeypatch.setitem( + sys.modules, + "jupyter_kernel_client", + SimpleNamespace(KaggleKernelExecutor=_FakeKaggleExecutor), + ) + + sandbox = KaggleSandbox(config=SandboxConfig(timeout=10.0, gpu="T4")) + sandbox.start() + sandbox.run_code("print('ok')") + + assert captured["accelerator"] == "T4" + + sandbox.stop() + + +def test_kaggle_batch_mode_consumes_kernel_like_reply(monkeypatch): + """Batch mode should map kernel-like reply to logs/results like interactive mode.""" + + class _FakeKaggleResult: + slug = "demo-slug" + status = "COMPLETE" + url = "https://www.kaggle.com/code/demo/demo-slug" + version_number = 1 + failure_message = None + output_dir = None + output_files = [] + log = None + succeeded = True + + @staticmethod + def to_kernel_reply(): + return { + "execution_count": 7, + "status": "ok", + "outputs": [ + {"output_type": "stream", "name": "stdout", "text": "hello from kaggle\\n"}, + { + "output_type": "execute_result", + "data": {"text/plain": "42"}, + "metadata": {}, + }, + ], + } + + class _FakeKaggleExecutor: + def __init__(self, username=None, quiet=True): + self.username = username + self.quiet = quiet + + def execute(self, code, wait=True, timeout=0.0, download_output=True, accelerator=None): + return _FakeKaggleResult() + + monkeypatch.setitem( + sys.modules, + "jupyter_kernel_client", + SimpleNamespace(KaggleKernelExecutor=_FakeKaggleExecutor), + ) + + sandbox = KaggleSandbox(config=SandboxConfig(timeout=10.0)) + sandbox.start() + + result = sandbox.run_code("print('ok')") + + assert result.execution_ok is True + assert result.code_error is None + assert result.execution_count == 7 + assert "hello from kaggle" in result.stdout + assert result.text == "42" + + sandbox.stop() + + +def test_kaggle_batch_mode_streaming_emits_status_and_stdout(monkeypatch): + """run_code_streaming should emit Kaggle status updates and final output lines.""" + + class _FakeStatus: + def __init__(self, status, failure_message=None): + self.status = status + self.failure_message = failure_message + + class _FakeApi: + def __init__(self): + self._statuses = ["RUNNING", "COMPLETE"] + + def kernels_status(self, _slug): + status = self._statuses.pop(0) if len(self._statuses) > 1 else self._statuses[0] + return _FakeStatus(status) + + class _FakeKaggleResult: + slug = "demo/demo-slug" + status = "QUEUED" + failure_message = None + log = None + notebook = None + output_dir = None + output_files = [] + + @staticmethod + def to_kernel_reply(): + return { + "execution_count": 1, + "status": "ok", + "outputs": [ + {"output_type": "stream", "name": "stdout", "text": "hello from kaggle\\n"} + ], + } + + class _FakeKaggleExecutor: + def __init__(self, username=None, quiet=True): + self.username = username + self.quiet = quiet + self.api = _FakeApi() + + def execute(self, code, wait=True, timeout=0.0, download_output=True, accelerator=None): + assert "print('ok')" in code + assert wait is False + return _FakeKaggleResult() + + def output(self, slug, dest, force=True, quiet=None): + _ = (slug, force, quiet) + path = Path(dest) / "run.log" + path.write_text("[]", encoding="utf-8") + return [str(path)] + + monkeypatch.setitem( + sys.modules, + "jupyter_kernel_client", + SimpleNamespace(KaggleKernelExecutor=_FakeKaggleExecutor), + ) + + sandbox = KaggleSandbox(config=SandboxConfig(timeout=10.0), poll_interval=0.0) + sandbox.start() + + items = list(sandbox.run_code_streaming("print('ok')")) + lines = [item.line for item in items if hasattr(item, "line")] + + assert any("submitted job" in line for line in lines) + assert any("status: RUNNING" in line for line in lines) + assert any("status: COMPLETE" in line for line in lines) + assert any("hello from kaggle" in line for line in lines) + + sandbox.stop() + + def test_modal_start_uses_supported_default_python_version(monkeypatch): """Default Modal image should pin a Modal-supported Python series.""" diff --git a/tests/test_models.py b/tests/test_models.py index 4bb2bd3..a2ddf25 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -46,6 +46,7 @@ def test_sandbox_variant_enum(self): assert SandboxVariantEnum.JUPYTER.value == "jupyter" assert SandboxVariantEnum.DATALAYER.value == "datalayer" assert SandboxVariantEnum.COLAB.value == "colab" + assert SandboxVariantEnum.KAGGLE.value == "kaggle" assert SandboxVariantEnum.MONTY.value == "monty" assert SandboxVariantEnum.MODAL.value == "modal" From d981decb00b2a4a12cf408dc08366ccdeef747ca Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Fri, 24 Jul 2026 14:15:39 +0200 Subject: [PATCH 21/21] fix: make kaggle sandbox pass pre-commit and ruff --- README.md | 37 ++++++++++++++++--------------- code_sandboxes/kaggle_sandbox.py | 27 ++++++++++++++++------ tests/test_modal_colab_sandbox.py | 5 +++-- 3 files changed, 42 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 6f92916..6273d55 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ with Sandbox.create(variant="datalayer", snapshot_name="my-setup") as sandbox: ### Streaming Output -```python +````python from code_sandboxes import Sandbox, OutputMessage def handle_stdout(msg: OutputMessage): @@ -246,7 +246,7 @@ with Sandbox.create(variant="kaggle") as sandbox: for event in client.execute_code_streaming("print('streaming')"): if hasattr(event, "line"): print(event.line) -``` +```` ```python import asyncio @@ -261,7 +261,8 @@ async def main(): asyncio.run(main()) ``` -``` + +```` ## CLI REPL @@ -274,7 +275,7 @@ sandbox repl --variant jupyter sandbox repl --variant monty sandbox repl --variant modal sandbox repl --variant colab -``` +```` If `--variant` is omitted, the CLI prompts for one. @@ -477,9 +478,9 @@ sandbox.run_code("print(now())") Runs code against Kaggle with two transparent modes: 1. **Interactive kernel mode** via `jupyter-kernel-client`'s - `KaggleKernelClient` (connect/create kernel on a runtime proxy). -2. **Batch job mode** via `jupyter-kernel-client`'s `KaggleKernelExecutor` - (submit code as a Kaggle notebook job and return logs/results). + `KaggleKernelClient` (connect/create kernel on a runtime proxy). +1. **Batch job mode** via `jupyter-kernel-client`'s `KaggleKernelExecutor` + (submit code as a Kaggle notebook job and return logs/results). This makes the `kaggle` sandbox usable directly from higher-level systems such as `jupyter-mcp-server` without requiring special routing logic. @@ -501,12 +502,12 @@ pip install code-sandboxes[kaggle] **Parameters:** -| Parameter | Description | -| -------------- | ----------- | -| `server_url` | The Kaggle runtime proxy URL (ending in `/proxy`) for interactive mode | -| `kernel_id` | The kernel identifier (omit to create a new kernel with a token in interactive mode) | -| `channels_url` | A notebook session channels URL to parse `server_url`/`kernel_id` from | -| `token` | Kaggle API token for interactive kernel mode (falls back to `KAGGLE_API_TOKEN`) | +| Parameter | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `server_url` | The Kaggle runtime proxy URL (ending in `/proxy`) for interactive mode | +| `kernel_id` | The kernel identifier (omit to create a new kernel with a token in interactive mode) | +| `channels_url` | A notebook session channels URL to parse `server_url`/`kernel_id` from | +| `token` | Kaggle API token for interactive kernel mode (falls back to `KAGGLE_API_TOKEN`) | | `gpu` / `accelerator` | Optional batch-mode accelerator. Supports Kaggle API values (`NvidiaTeslaT4`, `NvidiaTeslaP100`, `NvidiaTeslaT4Highmem`, `NvidiaL4`, `NvidiaL4X1`, `NvidiaTeslaA100`, `NvidiaH100`, `NvidiaRtxPro6000`) and friendly aliases (`T4`, `P100`, `A100`, `H100`). | For **batch mode** (no `server_url`/`channels_url`), configure credentials as @@ -598,11 +599,11 @@ pip install code-sandboxes[colab] **Parameters:** -| Parameter | Description | -| ------------- | ------------------------------------- | -| `server_url` | The Colab runtime proxy/tunnel URL | -| `kernel_id` | The assigned kernel identifier | -| `proxy_token` | The `colab-runtime-proxy-token` value | +| Parameter | Description | +| -------------- | ---------------------------------------------------------- | +| `server_url` | The Colab runtime proxy/tunnel URL | +| `kernel_id` | The assigned kernel identifier | +| `proxy_token` | The `colab-runtime-proxy-token` value | | `channels_url` | Optional Colab channels URL to parse the above values from | **How to obtain these values** — they are the pieces of the WebSocket URL that diff --git a/code_sandboxes/kaggle_sandbox.py b/code_sandboxes/kaggle_sandbox.py index a792543..f33fb90 100644 --- a/code_sandboxes/kaggle_sandbox.py +++ b/code_sandboxes/kaggle_sandbox.py @@ -355,7 +355,7 @@ def run_code( # noqa: C901 interrupted=was_interrupted, ) - def run_code_streaming( + def run_code_streaming( # noqa: C901 self, code: str, language: str = "python", @@ -388,7 +388,6 @@ def run_code_streaming( env_code = "\n".join(f"import os; os.environ[{k!r}] = {v!r}" for k, v in envs.items()) code = f"{env_code}\n{code}" - started_at = time.time() self._interrupt_requested.clear() self._executing_event.set() @@ -411,7 +410,11 @@ def run_code_streaming( except Exception as exc: self._executing_event.clear() self._interrupt_requested.clear() - yield CodeError(name="SandboxExecutionError", value=f"Failed to submit Kaggle batch job: {exc}", traceback="") + yield CodeError( + name="SandboxExecutionError", + value=f"Failed to submit Kaggle batch job: {exc}", + traceback="", + ) return now = time.time() @@ -524,7 +527,7 @@ async def run_code_streaming_async( ): yield item - def _run_code_batch( + def _run_code_batch( # noqa: C901 self, code: str, language: str, @@ -568,9 +571,13 @@ def _run_code_batch( self._executing_event.clear() was_interrupted = self._interrupt_requested.is_set() self._interrupt_requested.clear() + execution_error = None + if not was_interrupted: + execution_error = f"Failed to execute Kaggle batch job: {e}" + return ExecutionResult( execution_ok=False, - execution_error=f"Failed to execute Kaggle batch job: {e}" if not was_interrupted else None, + execution_error=execution_error, started_at=started_at, completed_at=time.time(), context_id=context.id if context else "default", @@ -642,8 +649,14 @@ def _run_code_batch( on_result(output_result) if not result.succeeded and code_error is None: - failure = result.failure_message or f"Kaggle execution failed with status: {result.status}" - code_error = CodeError(name="KaggleExecutionError", value=failure, traceback=result.log or "") + failure = result.failure_message or ( + f"Kaggle execution failed with status: {result.status}" + ) + code_error = CodeError( + name="KaggleExecutionError", + value=failure, + traceback=result.log or "", + ) if on_error: on_error(code_error) err_msg = OutputMessage(line=failure, timestamp=now, error=True) diff --git a/tests/test_modal_colab_sandbox.py b/tests/test_modal_colab_sandbox.py index 0d4b7be..1dd02b3 100644 --- a/tests/test_modal_colab_sandbox.py +++ b/tests/test_modal_colab_sandbox.py @@ -7,6 +7,7 @@ import sys from pathlib import Path from types import SimpleNamespace +from typing import ClassVar from unittest.mock import MagicMock import pytest @@ -258,7 +259,7 @@ class _FakeKaggleResult: version_number = 1 failure_message = None output_dir = None - output_files = [] + output_files: ClassVar[list[str]] = [] log = None succeeded = True @@ -328,7 +329,7 @@ class _FakeKaggleResult: log = None notebook = None output_dir = None - output_files = [] + output_files: ClassVar[list[str]] = [] @staticmethod def to_kernel_reply():