From 706f9b60b15a3ff86f3abee4399e167d7bc9d00f Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Fri, 21 Aug 2026 19:11:55 +0200 Subject: [PATCH 1/2] feat: more providers --- CHANGELOG.md | 42 ++ README.md | 3 + code_sandboxes/__init__.py | 9 + code_sandboxes/base.py | 26 +- code_sandboxes/cli.py | 28 +- code_sandboxes/cloudflare_sandbox.py | 611 +++++++++++++++++ code_sandboxes/coreweave_sandbox.py | 696 ++++++++++++++++++++ code_sandboxes/e2b_sandbox.py | 535 +++++++++++++++ code_sandboxes/manage.py | 273 ++++++++ code_sandboxes/modal_sandbox.py | 10 +- code_sandboxes/models.py | 3 + code_sandboxes/providers.py | 51 ++ docs/docs/api-reference/index.mdx | 10 +- docs/docs/cli/index.mdx | 14 + docs/docs/cli/management.mdx | 14 +- docs/docs/comparison/index.mdx | 287 ++++---- docs/docs/examples/index.mdx | 49 +- docs/docs/index.mdx | 5 +- docs/docs/installation/index.mdx | 25 + docs/docs/sandboxes/cloudflare.mdx | 139 ++++ docs/docs/sandboxes/coreweave.mdx | 180 +++++ docs/docs/sandboxes/datalayer.mdx | 2 +- docs/docs/sandboxes/daytona.mdx | 2 +- docs/docs/sandboxes/docker.mdx | 2 +- docs/docs/sandboxes/e2b.mdx | 188 ++++++ docs/docs/sandboxes/eval.mdx | 2 +- docs/docs/sandboxes/google-colab.mdx | 2 +- docs/docs/sandboxes/index.mdx | 17 +- docs/docs/sandboxes/jupyter-server.mdx | 2 +- docs/docs/sandboxes/kaggle.mdx | 2 +- docs/docs/sandboxes/modal.mdx | 12 +- docs/docs/sandboxes/monty.mdx | 2 +- examples/README.md | 22 + examples/exec/Makefile | 29 +- examples/exec/cloudflare_sandbox_example.py | 100 +++ examples/exec/coreweave_sandbox_example.py | 140 ++++ examples/exec/e2b_sandbox_example.py | 107 +++ examples/repl/Makefile | 30 +- examples/repl/cloudflare_sandbox_example.py | 62 ++ examples/repl/coreweave_sandbox_example.py | 74 +++ examples/repl/e2b_sandbox_example.py | 72 ++ pyproject.toml | 7 + tests/test_cloudflare.py | 400 +++++++++++ tests/test_coreweave.py | 439 ++++++++++++ tests/test_e2b.py | 395 +++++++++++ tests/test_manage.py | 3 + 46 files changed, 4939 insertions(+), 184 deletions(-) create mode 100644 code_sandboxes/cloudflare_sandbox.py create mode 100644 code_sandboxes/coreweave_sandbox.py create mode 100644 code_sandboxes/e2b_sandbox.py create mode 100644 docs/docs/sandboxes/cloudflare.mdx create mode 100644 docs/docs/sandboxes/coreweave.mdx create mode 100644 docs/docs/sandboxes/e2b.mdx create mode 100644 examples/exec/cloudflare_sandbox_example.py create mode 100644 examples/exec/coreweave_sandbox_example.py create mode 100644 examples/exec/e2b_sandbox_example.py create mode 100644 examples/repl/cloudflare_sandbox_example.py create mode 100644 examples/repl/coreweave_sandbox_example.py create mode 100644 examples/repl/e2b_sandbox_example.py create mode 100644 tests/test_cloudflare.py create mode 100644 tests/test_coreweave.py create mode 100644 tests/test_e2b.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f5ecb4f..80f037f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,48 @@ ## Unreleased +- Added three cloud variants: `e2b`, `coreweave` and `cloudflare`. + + `e2b` runs in a Firecracker microVM through E2B's code interpreter SDK, so it + holds a Jupyter kernel per context — `x = 1` in one call is still there in + the next — and answers with rich display data: a figure comes back as an + image, an HTML repr as HTML. It needs `E2B_API_KEY` and + `pip install code-sandboxes[e2b]`. `set_timeout()` extends the life of a + running sandbox and `get_host(port)` gives the public host of a port inside. + + `coreweave` runs a container on CoreWeave's GPU cloud. What the SDK offers is + `exec` — a process at a time — so a namespace is held here instead: one + `python -u -c` session is started with the sandbox and fed JSON lines on + stdin, the same arrangement the `modal` variant uses, and snippets share a + namespace as they do everywhere else. A session that cannot start, or that + goes away, drops back to a process per snippet rather than failing. It needs + `CWSANDBOX_API_KEY` and `pip install code-sandboxes[coreweave]`. + + `cloudflare` runs a container on Cloudflare's edge. Cloudflare's own SDK is a + Workers binding written in TypeScript, which a Python process cannot hold, so + this variant drives the SANDBOX BRIDGE — the Worker Cloudflare publishes to + expose the SDK over HTTP. Deploy it once with + `npm create cloudflare -- sandbox-bridge --template=cloudflare/sandbox-sdk/bridge/worker`, + then set `CLOUDFLARE_SANDBOX_API_URL` and `CLOUDFLARE_SANDBOX_API_KEY`. The + bridge gives a started process nothing to write to, so each snippet runs in + one of its own and state does not carry between calls — put what shares state + in one snippet, or keep it in a file, which does persist. Its manager creates, + gets and deletes; it cannot list, because the bridge has no endpoint that + enumerates sandboxes, and says so rather than answering with an empty list. + +- A GPU asked of a variant that has none is now REFUSED rather than dropped. + `--gpu` reaches `coreweave`, `datalayer`, `daytona`, `kaggle` and `modal`, + and `code-sandboxes exec -v e2b --gpu H100` says which variants can give one + instead of running on a CPU as though nothing had been asked — a sandbox that + looks as though it asked for an H100 and did not is one whose timings mean + nothing. `--gpu` was previously accepted and silently ignored for every other + variant. + +- Corrected the module docstring of the `modal` variant, which still described + the process-per-snippet behaviour that the session process replaced: `modal` + keeps a namespace between snippets, and falls back to a process per snippet + only when the session cannot be held. + - Added GPU support to the `daytona` variant. `gpu=` takes Daytona's own flavors, `gpu_count=` how many, and several names comma-separated are an ordered list of preferences Daytona falls back along — `gpu="H100,H200"` diff --git a/README.md b/README.md index 9708380..e142d48 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,12 @@ Code Sandboxes (`code_sandboxes`) is a Python package for running code in isolat Canonical variant names: +- `cloudflare` +- `coreweave` - `datalayer` - `daytona` - `docker` +- `e2b` - `eval` - `google-colab` - `jupyter-server` diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index 5635223..775923d 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -21,6 +21,9 @@ Cloud container sandboxes: - ModalSandbox: Modal cloud containers, per-snippet process execution - DaytonaSandbox: Daytona cloud sandboxes, stateful Python interpreter + - E2BSandbox: E2B microVMs, stateful Python kernel and rich outputs + - CoreWeaveSandbox: CoreWeave containers, stateful Python session + - CloudflareSandbox: Cloudflare containers, through a sandbox bridge Worker Features: - Code execution with streaming support @@ -60,6 +63,7 @@ from .base import Sandbox from .client import CodeExecutionOutcome, CodeSandboxClient, execution_result_to_reply +from .cloudflare_sandbox import CloudflareSandbox from .commands import CommandResult, ProcessHandle, SandboxCommands from .console import ( EXIT_COMMANDS, @@ -69,9 +73,11 @@ show_code, show_result, ) +from .coreweave_sandbox import CoreWeaveSandbox from .datalayer_sandbox import DatalayerSandbox from .daytona_sandbox import DaytonaSandbox from .docker_sandbox import DockerSandbox +from .e2b_sandbox import E2BSandbox from .eval_sandbox import EvalSandbox from .exceptions import ( ContextNotFoundError, @@ -147,15 +153,18 @@ "EXIT_COMMANDS", "KAGGLE_API_TOKEN_ENV", "PROVIDERS", + "CloudflareSandbox", "CodeError", "CodeExecutionOutcome", "CodeSandboxClient", "CommandResult", "Context", "ContextNotFoundError", + "CoreWeaveSandbox", "DatalayerSandbox", "DaytonaSandbox", "DockerSandbox", + "E2BSandbox", "EvalSandbox", "ExecutionResult", "FileInfo", diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index b8ab02a..db0a876 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -324,6 +324,18 @@ def create( # noqa: C901 from .daytona_sandbox import DaytonaSandbox sandbox = DaytonaSandbox(config=config, **kwargs) + elif variant_value == "e2b": + from .e2b_sandbox import E2BSandbox + + sandbox = E2BSandbox(config=config, **kwargs) + elif variant_value == "coreweave": + from .coreweave_sandbox import CoreWeaveSandbox + + sandbox = CoreWeaveSandbox(config=config, **kwargs) + elif variant_value == "cloudflare": + from .cloudflare_sandbox import CloudflareSandbox + + sandbox = CloudflareSandbox(config=config, **kwargs) else: raise ValueError( f"Unknown sandbox variant: {variant}. " @@ -358,7 +370,7 @@ def from_id(cls, sandbox_id: str, **kwargs) -> Sandbox: return DatalayerSandbox.from_id(sandbox_id, **kwargs) @classmethod - def list_environments( + def list_environments( # noqa: C901 cls, variant: SandboxVariant | str = SandboxVariant.DATALAYER, **kwargs, @@ -398,6 +410,18 @@ def list_environments( from .daytona_sandbox import DaytonaSandbox return DaytonaSandbox.list_environments() + if variant_value == "e2b": + from .e2b_sandbox import E2BSandbox + + return E2BSandbox.list_environments() + if variant_value == "coreweave": + from .coreweave_sandbox import CoreWeaveSandbox + + return CoreWeaveSandbox.list_environments() + if variant_value == "cloudflare": + from .cloudflare_sandbox import CloudflareSandbox + + return CloudflareSandbox.list_environments() if variant_value == "kaggle": from .kaggle_sandbox import KaggleSandbox diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py index 2651716..7d92434 100644 --- a/code_sandboxes/cli.py +++ b/code_sandboxes/cli.py @@ -31,9 +31,12 @@ #: brings it back to one of these. _SUPPORTED_RUN_VARIANTS = frozenset( { + "cloudflare", + "coreweave", "datalayer", "daytona", "docker", + "e2b", "eval", "google-colab", "jupyter-server", @@ -44,6 +47,11 @@ ) +#: The variants a GPU can be asked of. The others have none at all, and say so +#: rather than running on a CPU as though nothing had been asked. +_GPU_VARIANTS = frozenset({"coreweave", "datalayer", "daytona", "kaggle", "modal"}) + + @app.callback(invoke_without_command=True) def _root(ctx: typer.Context) -> None: """Code sandboxes CLI.""" @@ -103,7 +111,7 @@ def _kaggle_kwargs( return kwargs -def _resolve_variant_kwargs( +def _resolve_variant_kwargs( # noqa: C901 variant: str, server_url: str | None, kernel_id: str | None, @@ -131,7 +139,15 @@ def _resolve_variant_kwargs( if run_url: kwargs["run_url"] = run_url - if variant in {"modal", "daytona", "datalayer", "kaggle"} and gpu: + if gpu: + # A GPU reaches the variants that can give one, and is REFUSED by the + # rest rather than dropped: a sandbox that looks as though it asked + # for an H100 and did not is one whose timings mean nothing. + if variant not in _GPU_VARIANTS: + raise typer.BadParameter( + f"--gpu is not something {variant} can give. The variants with " + "a GPU are: " + ", ".join(sorted(_GPU_VARIANTS)) + "." + ) kwargs["gpu"] = gpu if spot: @@ -149,10 +165,10 @@ def _resolve_variant_kwargs( None, "--variant", "-v", - help=( - "Sandbox variant (datalayer, daytona, docker, eval, " - "google-colab, jupyter-server, kaggle, modal, monty)." - ), + # Built from the set itself, the way the management commands build theirs: + # a hand-written list is one more place to forget a variant, and it had + # already fallen behind twice. + help="Sandbox variant (" + ", ".join(sorted(_SUPPORTED_RUN_VARIANTS)) + ").", ) _RUN_TIMEOUT_OPTION = typer.Option(60.0, help="Code execution timeout (seconds).") _RUN_ENVIRONMENT_OPTION = typer.Option( diff --git a/code_sandboxes/cloudflare_sandbox.py b/code_sandboxes/cloudflare_sandbox.py new file mode 100644 index 0000000..06d84f7 --- /dev/null +++ b/code_sandboxes/cloudflare_sandbox.py @@ -0,0 +1,611 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Cloudflare sandbox implementation. + +`Cloudflare Sandboxes `_ run +untrusted code in containers on Cloudflare's edge. The SDK for them is a +Workers binding, written in TypeScript, and a Python process cannot hold one: +``getSandbox(env.Sandbox, id)`` only means something inside a Worker. + +What a Python process CAN talk to is the SANDBOX BRIDGE — a small Worker +Cloudflare publishes as a reference implementation, which exposes the SDK as +an HTTP API — so that is what this variant drives. It is deployed once per +account with + +.. code-block:: bash + + npm create cloudflare -- sandbox-bridge \\ + --template=cloudflare/sandbox-sdk/bridge/worker + +which returns a URL and generates a key. Both are what this variant needs: +``CLOUDFLARE_SANDBOX_API_URL`` and ``CLOUDFLARE_SANDBOX_API_KEY``. + +The bridge offers a container and ``exec`` — one process per call, with its +output streamed back as server-sent events — and no way to feed a process +stdin after it has started. A namespace therefore CANNOT be held between +calls the way the CoreWeave and Modal variants hold one: each snippet runs in +a process of its own, and ``x = 1`` in one call is gone by the next. Combine +statements into a single snippet when they need to share state, or keep the +state in a file — the sandbox's filesystem does persist. Rich display data +has no channel either. The value of a trailing expression is reported. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +import time +from collections.abc import Iterator +from typing import Any +from urllib.parse import quote + +from .base import Sandbox +from .exceptions import ( + SandboxConfigurationError, + SandboxConnectionError, + SandboxExecutionError, + SandboxNotStartedError, + VariableNotFoundError, +) +from .models import ( + CodeError, + Context, + ExecutionResult, + Logs, + OutputHandler, + OutputMessage, + ResourceConfig, + Result, + SandboxConfig, + SandboxEnvironment, + SandboxInfo, + SandboxStatus, +) + +logger = logging.getLogger(__name__) + +#: Where the bridge Worker answers, and the key it was deployed with. +API_URL_ENV_VAR = "CLOUDFLARE_SANDBOX_API_URL" +API_KEY_ENV_VAR = "CLOUDFLARE_SANDBOX_API_KEY" + +#: The Python inside the container the bridge starts. +DEFAULT_PYTHON = "python3" + +#: The program each snippet is run by. It takes its request as its one +#: argument rather than on stdin — the bridge starts a process and streams its +#: output, and gives nothing to write to — and answers with one JSON line, so +#: that what the code itself printed stays separable from the reply. +_RUNNER_SOURCE = """ +import ast, contextlib, io, json, sys, traceback + +request = json.loads(sys.argv[1]) +out, err = io.StringIO(), io.StringIO() +reply = {"status": "ok"} +try: + tree = ast.parse(request.get("code", ""), mode="exec") + trailing = None + if tree.body and isinstance(tree.body[-1], ast.Expr): + trailing = ast.Expression(tree.body.pop(-1).value) + namespace = {"__name__": "__main__"} + namespace.update(request.get("globals") or {}) + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + if tree.body: + exec(compile(tree, "", "exec"), namespace) + if trailing is not None: + value = eval(compile(trailing, "", "eval"), namespace) + if value is not None: + reply["result"] = repr(value) +except BaseException as error: + reply["status"] = "error" + reply["error"] = { + "name": type(error).__name__, + "value": str(error), + "traceback": traceback.format_exc(), + } +reply["stdout"] = out.getvalue() +reply["stderr"] = err.getvalue() +sys.stdout.write("\\n" + json.dumps(reply)) +""" + + +def _import_httpx() -> Any: + try: + import httpx + except ImportError as exc: + raise SandboxConfigurationError( + "httpx is required for CloudflareSandbox. Install it with: " + "pip install code-sandboxes[cloudflare]" + ) from exc + return httpx + + +def _sse_events(lines: Iterator[str]) -> Iterator[tuple[str, str]]: + """The (event, data) pairs of a server-sent-event stream. + + Written here rather than taken from a library because the bridge's stream + is the whole of its execution protocol and this package should not grow a + dependency for twenty lines. A record is a run of ``field: value`` lines + ended by a blank one; ``data`` may appear more than once in a record, and + the pieces are joined with newlines, as the specification says. + """ + event = "message" + data: list[str] = [] + for raw in lines: + line = raw.rstrip("\r") + if not line: + if data: + yield event, "\n".join(data) + event, data = "message", [] + continue + if line.startswith(":"): + # A comment, which the bridge sends as a keep-alive. + continue + field, _, value = line.partition(":") + value = value[1:] if value.startswith(" ") else value + if field == "event": + event = value + elif field == "data": + data.append(value) + if data: + yield event, "\n".join(data) + + +class CloudflareSandbox(Sandbox): + """Sandbox backed by a Cloudflare container, through the sandbox bridge. + + Args: + config: Optional sandbox configuration. + api_url: Where the bridge Worker answers, e.g. + ``https://cloudflare-sandbox-bridge.example.workers.dev``. Read + from ``CLOUDFLARE_SANDBOX_API_URL`` when omitted. + api_key: The key the bridge was deployed with, sent as a bearer token. + Read from ``CLOUDFLARE_SANDBOX_API_KEY`` when omitted. A bridge + running locally for development may have none. + python_executable: The Python inside the container. + working_dir: Where snippets run. The container's ``/workspace`` unless + the configuration names another. + """ + + def __init__( + self, + config: SandboxConfig | None = None, + api_url: str | None = None, + api_key: str | None = None, + python_executable: str = DEFAULT_PYTHON, + working_dir: str | None = None, + **kwargs, + ): + super().__init__(config) + self._api_url = (api_url or os.environ.get(API_URL_ENV_VAR) or "").rstrip("/") + self._api_key = api_key or os.environ.get(API_KEY_ENV_VAR) or "" + self._python_executable = python_executable + self._working_dir = working_dir or self.config.working_dir or "/workspace" + self._client: Any | None = None + self._sandbox_id: str | None = None + self._execution_count = 0 + self._extra_kwargs = kwargs + + @classmethod + def list_environments(cls) -> list[SandboxEnvironment]: + """The environments this provider ships. + + A Cloudflare sandbox is one shape — the container the bridge Worker + was deployed with — so there is one environment, and it is named after + the bridge rather than after a machine. + """ + return [ + SandboxEnvironment( + name="cloudflare-default", + title="Cloudflare", + language="python", + owner="cloudflare", + visibility="cloud", + burning_rate=0.0, + metadata={"variant": "cloudflare"}, + ), + ] + + def start(self) -> None: + if self._started: + return + if not self._api_url: + raise SandboxConfigurationError( + "CloudflareSandbox needs the URL of a deployed sandbox bridge: " + f"set {API_URL_ENV_VAR}, or pass api_url=. Deploy one with " + "`npm create cloudflare -- sandbox-bridge " + "--template=cloudflare/sandbox-sdk/bridge/worker`." + ) + + if self.config.gpu: + raise SandboxConfigurationError( + "Cloudflare sandboxes have no GPU, so gpu=" + repr(self.config.gpu) + " " + "cannot be honoured. Use the daytona, coreweave or modal " + "variant for a GPU." + ) + + self._client = self.build_client() + response = self._client.post("/v1/sandbox") + self._raise_for_status(response, "create a sandbox") + self._sandbox_id = str(response.json()["id"]) + + self._default_context = self.create_context("default") + self._info = SandboxInfo( + id=self._sandbox_id, + variant="cloudflare", + status=SandboxStatus.RUNNING, + created_at=time.time(), + name=self.config.name, + metadata={ + "cloudflare_sandbox_id": self._sandbox_id, + "api_url": self._api_url, + "working_dir": self._working_dir, + }, + resources=ResourceConfig(), + config=self.config, + ) + self._started = True + + def build_client(self) -> Any: + """An HTTP client for the bridge, with no sandbox behind it yet. + + Separate from :meth:`start` because talking to the bridge and HAVING a + sandbox are different things: the manager deletes a sandbox by id and + asks after one by id, and creating a throwaway container merely to get + a client to do it with would leave a container running and billed. + """ + if not self._api_url: + raise SandboxConfigurationError( + "CloudflareSandbox needs the URL of a deployed sandbox bridge: " + f"set {API_URL_ENV_VAR}, or pass api_url=. Deploy one with " + "`npm create cloudflare -- sandbox-bridge " + "--template=cloudflare/sandbox-sdk/bridge/worker`." + ) + httpx = _import_httpx() + headers = {"Authorization": f"Bearer {self._api_key}"} if self._api_key else {} + return httpx.Client( + base_url=self._api_url, + headers=headers, + # Creating a container is slower than the default five seconds, + # and an execution runs for as long as the caller asked it to. + timeout=httpx.Timeout(30.0, read=None), + ) + + def _raise_for_status(self, response: Any, what: str) -> None: + """Say which call failed, and what the bridge said about it. + + ``raise_for_status`` names the URL and the code and nothing else; a + 401 from the bridge means the key is wrong, and a caller should be + able to read that from the message rather than infer it. + """ + if response.status_code < 400: + return + detail = (response.text or "").strip() + if response.status_code in (401, 403): + detail = ( + f"{detail} — the bridge refused the key. Check " + f"{API_KEY_ENV_VAR} against the secret it was deployed with." + ).strip(" —") + raise SandboxConnectionError( + self._api_url, + f"could not {what}: HTTP {response.status_code}. {detail}".strip(), + ) + + def stop(self) -> None: + if not self._started: + return + if self._client is not None and self._sandbox_id: + try: + self._client.delete(f"/v1/sandbox/{quote(self._sandbox_id)}") + except Exception: + logger.debug( + "Ignoring error while destroying the Cloudflare sandbox", exc_info=True + ) + if self._client is not None: + with_close = getattr(self._client, "close", None) + if with_close is not None: + with_close() + self._client = None + self._sandbox_id = None + self._started = False + if self._info: + self._info.status = SandboxStatus.STOPPED + + def is_running(self) -> bool: + """Whether the container is still up. + + Cloudflare puts a sandbox to sleep on its own schedule, so a sandbox + this process created may be gone without this process having asked for + it. The bridge answers the question directly. + """ + if not self._started or self._client is None or not self._sandbox_id: + return False + try: + response = self._client.get(f"/v1/sandbox/{quote(self._sandbox_id)}/running") + return bool(response.json().get("running")) + except Exception: + return False + + def run_code( + 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 or self._client is None or not self._sandbox_id: + raise SandboxNotStartedError() + if language != "python": + raise ValueError(f"CloudflareSandbox only supports Python, got: {language}") + + started_at = time.time() + self._execution_count += 1 + seconds = timeout if timeout is not None else self.config.timeout + request = json.dumps({"code": _with_envs(code, envs)}) + + try: + stdout, stderr, exit_code = self._exec( + [self._python_executable, "-u", "-c", _RUNNER_SOURCE, request], + seconds, + ) + except Exception as error: + # The bridge or the container, not the code. + return ExecutionResult( + execution_ok=False, + execution_error=f"Failed to execute code on Cloudflare: {error}", + started_at=started_at, + completed_at=time.time(), + context_id=context.id if context else "default", + ) + + reply = _reply_of(stdout) + if reply is None: + # The runner never got to answer: the process was killed, or the + # image has no Python where it was looked for. What the container + # said is the only evidence, so it is what gets reported. + return ExecutionResult( + execution_ok=False, + execution_error=( + f"The Cloudflare sandbox answered with nothing this package " + f"could read (exit code {exit_code}). {stderr or stdout}".strip() + ), + started_at=started_at, + completed_at=time.time(), + context_id=context.id if context else "default", + ) + + return self._execution_result( + reply, context, started_at, on_stdout, on_stderr, on_result, on_error + ) + + def _exec(self, argv: list[str], timeout: float) -> tuple[str, str, int | None]: + """One process in the container, and what it wrote. + + The bridge answers with a server-sent-event stream: the output as it + is written, base64-encoded, and one terminal event — the exit code, or + an error that stopped it from running at all. + """ + stdout: list[bytes] = [] + stderr: list[bytes] = [] + exit_code: int | None = None + failure: str | None = None + + with self._client.stream( + "POST", + f"/v1/sandbox/{quote(self._sandbox_id or '')}/exec", + json={ + "argv": argv, + "cwd": self._working_dir, + "timeout_ms": max(1, round(timeout * 1000)), + }, + timeout=timeout + 30.0, + ) as response: + if response.status_code >= 400: + response.read() + self._raise_for_status(response, "run a command") + for event, data in _sse_events(response.iter_lines()): + if event == "stdout": + stdout.append(_decode(data)) + elif event == "stderr": + stderr.append(_decode(data)) + elif event == "exit": + with_code = _json_or_none(data) or {} + exit_code = with_code.get("exit_code") + elif event == "error": + with_error = _json_or_none(data) or {} + failure = str(with_error.get("error") or data) + + if failure is not None: + raise SandboxExecutionError("SandboxError", failure) + return ( + b"".join(stdout).decode("utf-8", errors="replace"), + b"".join(stderr).decode("utf-8", errors="replace"), + exit_code, + ) + + def _execution_result( + self, + reply: dict, + context: Context | None, + started_at: float, + on_stdout: OutputHandler[OutputMessage] | None, + on_stderr: OutputHandler[OutputMessage] | None, + on_result: OutputHandler[Result] | None, + on_error: OutputHandler[CodeError] | None, + ) -> ExecutionResult: + """One reply of the runner, as an `ExecutionResult`. + + The runner collects the output and answers once, so the callbacks are + called here, in order, on the lines it carried. A caller that streams + sees the lines it would have seen, later. + """ + now = time.time() + stdout_messages: list[OutputMessage] = [] + for line in _lines(reply.get("stdout")): + message = OutputMessage(line=line, timestamp=now, error=False) + stdout_messages.append(message) + if on_stdout: + on_stdout(message) + + stderr_messages: list[OutputMessage] = [] + for line in _lines(reply.get("stderr")): + message = OutputMessage(line=line, timestamp=now, error=True) + stderr_messages.append(message) + if on_stderr: + on_stderr(message) + + results: list[Result] = [] + if reply.get("result") is not None: + value = Result(data={"text/plain": reply["result"]}, is_main_result=True) + results.append(value) + if on_result: + on_result(value) + + code_error: CodeError | None = None + error = reply.get("error") + if reply.get("status") == "error" and isinstance(error, dict): + code_error = CodeError( + name=error.get("name") or "Error", + value=error.get("value") or "", + traceback=error.get("traceback") or "", + ) + if on_error: + on_error(code_error) + + 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=now, + ) + + def _do_interrupt(self) -> bool: + """The bridge takes no interrupt; a timeout is the only stop.""" + return False + + def _get_internal_variable(self, name: str, context: Context | None = None) -> Any: + """The value of a variable, carried back as JSON. + + Each snippet runs in a process of its own here, so this can only read + a variable of the snippet it is part of — which is what the base class + asks it for. There is no session to read from between calls. + """ + if not self._started: + raise SandboxNotStartedError() + execution = self.run_code( + "import json as _code_sandboxes_json\n" + f"print(_code_sandboxes_json.dumps({name}, default=repr))\n" + "del _code_sandboxes_json\n", + context=context, + ) + if not execution.execution_ok: + raise SandboxExecutionError( + "SandboxError", execution.execution_error or "Sandbox execution failed" + ) + if execution.code_error is not None: + raise VariableNotFoundError(name) + printed = "\n".join(message.line for message in execution.logs.stdout).strip() + if not printed: + raise VariableNotFoundError(name) + return json.loads(printed) + + def _set_internal_variable(self, name: str, value: Any, context: Context | None = None) -> None: + if not self._started: + raise SandboxNotStartedError() + try: + json.dumps(value) + except TypeError as error: + raise SandboxConfigurationError( + f"A Cloudflare sandbox runs elsewhere, so {name!r} has to cross " + "as JSON and this value cannot be encoded. Build it inside the " + "sandbox with run_code instead." + ) from error + raise SandboxConfigurationError( + "A Cloudflare sandbox runs each snippet in a process of its own, " + f"so {name!r} would be gone before the next one reads it. Set it " + "inside the snippet that uses it, or keep it in a file — the " + "filesystem of the sandbox does persist." + ) + + def _write_file(self, path: str, content: bytes) -> None: + """Straight to the filesystem of the container, not through the code.""" + if not self._started or self._client is None: + raise SandboxNotStartedError() + response = self._client.put(self._file_url(path), content=content) + self._raise_for_status(response, f"write {path}") + + def _read_file(self, path: str) -> bytes: + if not self._started or self._client is None: + raise SandboxNotStartedError() + response = self._client.get(self._file_url(path)) + if response.status_code == 404: + raise FileNotFoundError(f"Could not read file: {path}") + self._raise_for_status(response, f"read {path}") + return bytes(response.content) + + def _file_url(self, path: str) -> str: + """The bridge's URL for one file of this sandbox. + + The path travels as the tail of the URL, so its separators must stay + separators while everything else about it is escaped. + """ + return f"/v1/sandbox/{quote(self._sandbox_id or '')}/file/{quote(path.lstrip('/'))}" + + +def _decode(data: str) -> bytes: + """One base64 payload of the stream, or nothing when it is not one.""" + try: + return base64.b64decode(data) + except Exception: + return data.encode() + + +def _json_or_none(data: str) -> dict | None: + try: + parsed = json.loads(data) + except ValueError: + return None + return parsed if isinstance(parsed, dict) else None + + +def _reply_of(stdout: str) -> dict | None: + """The runner's JSON reply, out of everything the process wrote. + + It is the LAST line: the runner writes it after a newline of its own, so + whatever the container printed on its own account — a warning from the + interpreter, a message from an entrypoint — comes before it. + """ + for line in reversed(stdout.splitlines()): + reply = _json_or_none(line) + if reply is not None: + return reply + return None + + +def _lines(raw: Any) -> list[str]: + """The lines of one stream, without the empty one a trailing newline makes.""" + if not raw: + return [] + return str(raw).splitlines() + + +def _with_envs(code: str, envs: dict[str, str] | None) -> str: + """The snippet, with the environment it asked for set first.""" + if not envs: + return code + assignments = "".join( + f"_code_sandboxes_os.environ[{key!r}] = {value!r}\n" for key, value in envs.items() + ) + return f"import os as _code_sandboxes_os\n{assignments}del _code_sandboxes_os\n{code}" diff --git a/code_sandboxes/coreweave_sandbox.py b/code_sandboxes/coreweave_sandbox.py new file mode 100644 index 0000000..6121b08 --- /dev/null +++ b/code_sandboxes/coreweave_sandbox.py @@ -0,0 +1,696 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""CoreWeave sandbox implementation. + +`CoreWeave Sandboxes `_ run a +container on CoreWeave's own GPU cloud, started against a managed runner and +addressed through the ``cwsandbox`` SDK. What it offers is a container and +``exec`` — a process at a time, with its streams — and nothing that holds a +Python namespace between calls. + +So one is held here. A single ``python -u -c`` process is started with the +sandbox and fed JSON lines on stdin, one request and one reply each, which is +the same arrangement the Modal variant uses and for the same reason: ``x = 1`` +in one call and ``print(x)`` in the next behave the way they do in every other +variant of this package. A driver that cannot be started, or that goes away +mid-session, drops the sandbox back to a process per snippet — working, merely +stateless — rather than failing. + +Rich display data — a figure, an HTML repr — has no channel in this +arrangement, and is not reported. The value of a trailing expression is. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import math +import time +from typing import Any + +from .base import Sandbox +from .exceptions import ( + SandboxConfigurationError, + SandboxExecutionError, + SandboxNotStartedError, + VariableNotFoundError, +) +from .models import ( + CodeError, + Context, + ExecutionResult, + Logs, + OutputHandler, + OutputMessage, + ResourceConfig, + Result, + SandboxConfig, + SandboxEnvironment, + SandboxInfo, + SandboxStatus, +) + +logger = logging.getLogger(__name__) + +#: What every sandbox this package creates is tagged with, so the ones it made +#: can be told from the rest of an organization's. +CREATED_BY_LABEL = "code-sandboxes" + +#: The container a sandbox runs when the caller names no image. It is the +#: SDK's own default, and carries nothing but Python. +DEFAULT_CONTAINER_IMAGE = "python:3.11" + +#: The session process. CoreWeave's `exec` is one process per call — whatever +#: a snippet defined is gone when its process exits, so a namespace has to be +#: kept by something that outlives them. This driver is started once and fed +#: JSON lines on stdin — one request, one reply — executing everything in a +#: single namespace, and answering with what the code printed, what its +#: trailing expression evaluated to, and the error it raised. +_DRIVER_SOURCE = """ +import ast, contextlib, io, json, sys, traceback + +namespace = {"__name__": "__main__"} +for line in sys.stdin: + line = line.strip() + if not line: + continue + request = json.loads(line) + out, err = io.StringIO(), io.StringIO() + reply = {"seq": request.get("seq"), "status": "ok"} + try: + tree = ast.parse(request.get("code", ""), mode="exec") + trailing = None + if tree.body and isinstance(tree.body[-1], ast.Expr): + trailing = ast.Expression(tree.body.pop(-1).value) + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + if tree.body: + exec(compile(tree, "", "exec"), namespace) + if trailing is not None: + value = eval(compile(trailing, "", "eval"), namespace) + if value is not None: + reply["result"] = repr(value) + except BaseException as error: + reply["status"] = "error" + reply["error"] = { + "name": type(error).__name__, + "value": str(error), + "traceback": traceback.format_exc(), + } + reply["stdout"] = out.getvalue() + reply["stderr"] = err.getvalue() + print(json.dumps(reply), flush=True) +""" + +#: The program a snippet is run by when there is no driver — the fallback, and +#: what `commands.run` uses. +_STATELESS_SOURCE = """ +import ast, contextlib, io, json, sys, traceback + +request = json.loads(sys.stdin.read() or "{}") +out, err = io.StringIO(), io.StringIO() +reply = {"status": "ok"} +try: + tree = ast.parse(request.get("code", ""), mode="exec") + trailing = None + if tree.body and isinstance(tree.body[-1], ast.Expr): + trailing = ast.Expression(tree.body.pop(-1).value) + namespace = {"__name__": "__main__"} + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + if tree.body: + exec(compile(tree, "", "exec"), namespace) + if trailing is not None: + value = eval(compile(trailing, "", "eval"), namespace) + if value is not None: + reply["result"] = repr(value) +except BaseException as error: + reply["status"] = "error" + reply["error"] = { + "name": type(error).__name__, + "value": str(error), + "traceback": traceback.format_exc(), + } +reply["stdout"] = out.getvalue() +reply["stderr"] = err.getvalue() +print(json.dumps(reply), flush=True) +""" + + +def _import_cwsandbox() -> Any: + try: + import cwsandbox + except ImportError as exc: + raise SandboxConfigurationError( + "cwsandbox is required for CoreWeaveSandbox. Install it with: " + "pip install code-sandboxes[coreweave]" + ) from exc + return cwsandbox + + +class CoreWeaveSandbox(Sandbox): + """Sandbox backed by a CoreWeave sandbox. + + Args: + config: Optional sandbox configuration. + api_key: CoreWeave API token. The SDK reads ``CWSANDBOX_API_KEY`` from + the environment when this is omitted; passing it here sets that + variable for the process, since the SDK offers no other way in. + base_url: The control plane to talk to. ``CWSANDBOX_BASE_URL`` when + omitted, which itself defaults to ``https://api.cwsandbox.com``. + container_image: The image the sandbox runs. + :data:`DEFAULT_CONTAINER_IMAGE` when omitted. + profile_names: The sandbox profiles to run under — CoreWeave's own + policy objects, which decide what a sandbox may do. + runner_ids: Particular managed runners to place the sandbox on. Left + to CoreWeave when omitted, which is the usual case. + python_executable: The Python inside the container. ``python3`` unless + the image keeps it elsewhere. + stateful: Whether to keep one process for the session, so snippets + share a namespace. On by default; turning it off runs each snippet + in a process of its own. + """ + + def __init__( + self, + config: SandboxConfig | None = None, + api_key: str | None = None, + base_url: str | None = None, + container_image: str | None = None, + profile_names: list[str] | None = None, + runner_ids: list[str] | None = None, + python_executable: str = "python3", + stateful: bool = True, + **kwargs, + ): + super().__init__(config) + self._api_key = api_key + self._base_url = base_url + self._container_image = container_image + self._profile_names = profile_names + self._runner_ids = runner_ids + self._python_executable = python_executable + self._stateful = stateful + self._sandbox: Any | None = None + self._driver: Any | None = None + self._driver_replies: Any | None = None + self._driver_seq = 0 + self._execution_count = 0 + self._extra_kwargs = kwargs + + @classmethod + def list_environments(cls) -> list[SandboxEnvironment]: + """The environments this provider ships. + + CoreWeave takes an image and a machine specification per sandbox + rather than a catalogue of named ones, so what is offered here are the + shapes worth naming — a plain container and one with a GPU, which is + what CoreWeave is for. The shape is asked for by argument: `gpu=`, + `container_image=`. + """ + return [ + SandboxEnvironment( + name="coreweave-default", + title="CoreWeave", + language="python", + owner="coreweave", + visibility="cloud", + burning_rate=0.0, + metadata={ + "variant": "coreweave", + "container_image": DEFAULT_CONTAINER_IMAGE, + "gpu": None, + }, + ), + SandboxEnvironment( + name="coreweave-gpu", + title="CoreWeave GPU", + language="python", + owner="coreweave", + visibility="cloud", + burning_rate=0.0, + metadata={ + "variant": "coreweave", + "container_image": DEFAULT_CONTAINER_IMAGE, + "gpu": "H100", + }, + ), + ] + + def start(self) -> None: + if self._started: + return + + cwsandbox = _import_cwsandbox() + self._apply_credentials() + self._sandbox = cwsandbox.Sandbox.run(**self._run_params(cwsandbox)) + # `run` sends the request and answers at once; the container is not + # there to `exec` in until it is running. Waited for WITHOUT a deadline + # of ours: `config.timeout` bounds how long a snippet may run, and + # spending it on a cold start — pulling an image, finding a GPU + # runner — would fail a sandbox that was merely slow to arrive. The + # SDK's own request timeout is what bounds this. + self._sandbox.wait() + + if self._stateful: + self._start_driver() + + self._default_context = self.create_context("default") + self._info = SandboxInfo( + id=self._sandbox.sandbox_id, + variant="coreweave", + status=SandboxStatus.RUNNING, + created_at=time.time(), + name=self.config.name, + metadata={ + "coreweave_sandbox_id": self._sandbox.sandbox_id, + "container_image": self._container_image or DEFAULT_CONTAINER_IMAGE, + "runner_id": getattr(self._sandbox, "runner_id", None), + "stateful": self._driver is not None, + }, + resources=ResourceConfig( + cpu=self.config.cpu_limit, + memory=self.config.memory_limit, + gpu=self.config.gpu, + ), + config=self.config, + ) + self._started = True + + def _apply_credentials(self) -> None: + """Put an explicitly given token where the SDK looks for one. + + ``cwsandbox`` authenticates from ``CWSANDBOX_API_KEY`` — there is no + argument for a token on `Sandbox.run` — so a caller who passes one + here has it set for this process. A caller who passes none changes + nothing, and the environment answers as it did. + """ + import os + + if self._api_key: + os.environ["CWSANDBOX_API_KEY"] = self._api_key + if self._base_url: + os.environ["CWSANDBOX_BASE_URL"] = self._base_url + + def _run_params(self, cwsandbox: Any) -> dict[str, Any]: + """What to ask CoreWeave for, from the configuration of this sandbox.""" + params: dict[str, Any] = { + "container_image": self._container_image or DEFAULT_CONTAINER_IMAGE, + "tags": self._tag_list(), + } + if self.config.env_vars: + params["environment_variables"] = dict(self.config.env_vars) + if self.config.max_lifetime: + params["max_lifetime_seconds"] = float(self.config.max_lifetime) + if self._profile_names: + params["profile_names"] = list(self._profile_names) + if self._runner_ids: + params["runner_ids"] = list(self._runner_ids) + resources = self._resources(cwsandbox) + if resources is not None: + params["resources"] = resources + network = self._network_params(cwsandbox) + if network is not None: + params["network"] = network + return params + + def _tag_list(self) -> list[str]: + """The metadata the sandbox carries in CoreWeave. + + CoreWeave keeps tags as a flat list of strings rather than as a map, + so a pair is written ``key=value`` — which is how the name of the + sandbox and the tags of the configuration survive the crossing and + can be read back by `list`. + """ + tags = [f"created-by={CREATED_BY_LABEL}"] + if self.config.name: + tags.append(f"name={self.config.name}") + tags.extend(f"{key}={value}" for key, value in self._tags.items()) + return tags + + def _resources(self, cwsandbox: Any) -> Any | None: + """The machine asked for, or nothing when the defaults will do. + + CoreWeave takes Kubernetes quantities — ``"2"`` cores, ``"4Gi"`` of + memory — as requests and limits, and a GPU as a count and a kind. + """ + requests: dict[str, str] = {} + if self.config.cpu_limit: + requests["cpu"] = str(max(1, math.ceil(self.config.cpu_limit))) + if self.config.memory_limit: + requests["memory"] = f"{max(1, math.ceil(self.config.memory_limit / 1024**3))}Gi" + gpu: dict[str, Any] | None = None + if self.config.gpu: + # One name, or the first of several: CoreWeave places a sandbox on + # a runner of one kind, and has no fallback list of its own. + kind = self.config.gpu.split(",")[0].strip() + if kind: + gpu = {"count": 1, "type": kind} + if not requests and gpu is None: + return None + return cwsandbox.ResourceOptions( + requests=requests or None, + limits=requests or None, + gpu=gpu, + ) + + def _network_params(self, cwsandbox: Any) -> Any | None: + """What the network policy of the configuration means to CoreWeave.""" + policy = self.config.network_policy + if policy == "none": + return cwsandbox.NetworkOptions(deny_egress=True, deny_ingress=True) + if policy == "allowlist": + if not self.config.allowed_hosts: + raise SandboxConfigurationError( + "network_policy='allowlist' needs allowed_hosts: a sandbox " + "allowed nothing is a sandbox with no network at all, which " + "is network_policy='none'." + ) + # An egress list IS the allowlist: naming what may be reached + # denies everything else. + return cwsandbox.NetworkOptions( + egress=[cwsandbox.EgressRule(dns_name=host) for host in self.config.allowed_hosts] + ) + return None + + def _start_driver(self) -> None: + """Start the session process, and fall back to nothing on failure. + + A driver that cannot come up leaves `self._driver` unset, and + `run_code` then executes each snippet in its own process — working, + merely stateless. + """ + import queue + import threading + + try: + driver = self._sandbox.exec( + [self._python_executable, "-u", "-c", _DRIVER_SOURCE], + stdin=True, + ) + except Exception: + logger.warning( + "The CoreWeave session driver could not be started; snippets will not share state.", + exc_info=True, + ) + return + replies: queue.Queue = queue.Queue() + + def pump() -> None: + # The reader dies with the driver, and says so with the sentinel + # below rather than with an exception nobody is there to catch. + with contextlib.suppress(Exception): + for line in driver.stdout: + replies.put(line) + replies.put(None) + + # A thread reads the replies: the stream blocks, and a request that + # never gets its answer must time out rather than hang run_code. + thread = threading.Thread(target=pump, name="coreweave-driver-stdout", daemon=True) + thread.start() + self._driver = driver + self._driver_replies = replies + self._driver_seq = 0 + + def _driver_request(self, code: str, timeout: float) -> dict | None: + """One request to the session process, or None when it cannot serve.""" + import queue + + if self._driver is None: + return None + self._driver_seq += 1 + try: + self._driver.stdin.writeline(json.dumps({"seq": self._driver_seq, "code": code})) + except Exception: + logger.warning("The CoreWeave session driver went away; restarting stateless.") + self._driver = None + return None + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"No reply from the CoreWeave session within {timeout:.0f}s.") + try: + line = self._driver_replies.get(timeout=remaining) + except queue.Empty: + continue + if line is None: + # The reader reached EOF: the driver is gone. + self._driver = None + return None + try: + reply = json.loads(line) + except ValueError: + continue + if reply.get("seq") == self._driver_seq: + return reply + + def _stateless_request(self, code: str, timeout: float) -> dict: + """One snippet in a process of its own, for when there is no driver.""" + process = self._sandbox.exec( + [self._python_executable, "-u", "-c", _STATELESS_SOURCE], + timeout_seconds=timeout, + stdin=True, + ) + process.stdin.writeline(json.dumps({"code": code})) + process.stdin.close() + result = process.result(timeout=timeout) + printed = _text(getattr(result, "stdout_bytes", b"")) + for line in reversed(printed.splitlines()): + # The reply is the LAST line: anything the program wrote outside + # the redirect — a warning from the interpreter itself — comes + # before it. + with contextlib.suppress(ValueError): + reply = json.loads(line) + if isinstance(reply, dict): + return reply + return { + "status": "error", + "stdout": printed, + "stderr": _text(getattr(result, "stderr_bytes", b"")), + "error": { + "name": "SandboxError", + "value": "The sandbox answered with nothing this package could read.", + "traceback": "", + }, + } + + def stop(self) -> None: + if not self._started: + return + if self._driver is not None: + with contextlib.suppress(Exception): + self._driver.stdin.close() + self._driver = None + self._driver_replies = None + if self._sandbox is not None: + try: + self._sandbox.stop().result() + except Exception: + logger.debug("Ignoring error while stopping the CoreWeave sandbox", exc_info=True) + self._sandbox = None + self._started = False + if self._info: + self._info.status = SandboxStatus.STOPPED + + def run_code( + 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 or self._sandbox is None: + raise SandboxNotStartedError() + if language != "python": + raise ValueError(f"CoreWeaveSandbox only supports Python, got: {language}") + + started_at = time.time() + self._execution_count += 1 + seconds = timeout if timeout is not None else self.config.timeout + prepared = _with_envs(code, envs) + + try: + reply = self._driver_request(prepared, seconds) + if reply is None: + reply = self._stateless_request(prepared, seconds) + except Exception as error: + # The container, not the code: a sandbox CoreWeave has taken down, + # a runner that went away, a session that never answered. + return ExecutionResult( + execution_ok=False, + execution_error=f"Failed to execute code on CoreWeave: {error}", + started_at=started_at, + completed_at=time.time(), + context_id=context.id if context else "default", + ) + + return self._execution_result( + reply, context, started_at, on_stdout, on_stderr, on_result, on_error + ) + + def _execution_result( + self, + reply: dict, + context: Context | None, + started_at: float, + on_stdout: OutputHandler[OutputMessage] | None, + on_stderr: OutputHandler[OutputMessage] | None, + on_result: OutputHandler[Result] | None, + on_error: OutputHandler[CodeError] | None, + ) -> ExecutionResult: + """One reply of the driver, as an `ExecutionResult`. + + The output arrives whole rather than as it was written — the driver + collects it and answers once — so the callbacks are called here, in + order, on the lines it carried. A caller that streams sees the lines + it would have seen, later. + """ + now = time.time() + stdout_messages: list[OutputMessage] = [] + for line in _lines(reply.get("stdout")): + message = OutputMessage(line=line, timestamp=now, error=False) + stdout_messages.append(message) + if on_stdout: + on_stdout(message) + + stderr_messages: list[OutputMessage] = [] + for line in _lines(reply.get("stderr")): + message = OutputMessage(line=line, timestamp=now, error=True) + stderr_messages.append(message) + if on_stderr: + on_stderr(message) + + results: list[Result] = [] + if reply.get("result") is not None: + value = Result(data={"text/plain": reply["result"]}, is_main_result=True) + results.append(value) + if on_result: + on_result(value) + + code_error: CodeError | None = None + error = reply.get("error") + if reply.get("status") == "error" and isinstance(error, dict): + code_error = CodeError( + name=error.get("name") or "Error", + value=error.get("value") or "", + traceback=error.get("traceback") or "", + ) + if on_error: + on_error(code_error) + + 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=now, + ) + + def _do_interrupt(self) -> bool: + """The session process takes no interrupt; a timeout is the only stop.""" + return False + + def _get_internal_variable(self, name: str, context: Context | None = None) -> Any: + """The value of a variable, carried back as JSON.""" + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + execution = self.run_code( + "import json as _code_sandboxes_json\n" + f"print(_code_sandboxes_json.dumps({name}, default=repr))\n" + "del _code_sandboxes_json\n", + context=context, + ) + if not execution.execution_ok: + raise SandboxExecutionError( + "SandboxError", execution.execution_error or "Sandbox execution failed" + ) + if execution.code_error is not None: + raise VariableNotFoundError(name) + printed = "\n".join(message.line for message in execution.logs.stdout).strip() + if not printed: + raise VariableNotFoundError(name) + return json.loads(printed) + + def _set_internal_variable(self, name: str, value: Any, context: Context | None = None) -> None: + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + try: + payload = json.dumps(value) + except TypeError as error: + raise SandboxConfigurationError( + f"A CoreWeave sandbox runs elsewhere, so {name!r} has to cross " + "as JSON and this value cannot be encoded. Build it inside the " + "sandbox with run_code instead." + ) from error + execution = self.run_code( + "import json as _code_sandboxes_json\n" + f"{name} = _code_sandboxes_json.loads({payload!r})\n" + "del _code_sandboxes_json\n", + context=context, + ) + if not execution.execution_ok: + raise SandboxExecutionError( + "SandboxError", execution.execution_error or "Sandbox execution failed" + ) + if execution.code_error is not None: + raise SandboxExecutionError( + execution.code_error.name, + execution.code_error.value, + execution.code_error.traceback, + ) + + def _write_file(self, path: str, content: bytes) -> None: + """Straight to the filesystem of the container, not through the code.""" + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + self._sandbox.write_file(path, content).result() + + def _read_file(self, path: str) -> bytes: + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + content = self._sandbox.read_file(path).result() + if content is None: + raise FileNotFoundError(f"Could not read file: {path}") + return bytes(content) + + +def _text(raw: Any) -> str: + """Whatever the SDK handed back, as text.""" + if raw is None: + return "" + if isinstance(raw, bytes): + return raw.decode("utf-8", errors="replace") + return str(raw) + + +def _lines(raw: Any) -> list[str]: + """The lines of one stream, without the empty one a trailing newline makes.""" + text = _text(raw) + if not text: + return [] + return text.splitlines() + + +def _with_envs(code: str, envs: dict[str, str] | None) -> str: + """The snippet, with the environment it asked for set first. + + The session process is started once, so variables meant for one execution + cannot be passed to it the way they would be to a fresh process. They are + set inside the namespace instead, which is where the code reads them from. + """ + if not envs: + return code + assignments = "".join( + f"_code_sandboxes_os.environ[{key!r}] = {value!r}\n" for key, value in envs.items() + ) + return f"import os as _code_sandboxes_os\n{assignments}del _code_sandboxes_os\n{code}" diff --git a/code_sandboxes/e2b_sandbox.py b/code_sandboxes/e2b_sandbox.py new file mode 100644 index 0000000..0bfcc53 --- /dev/null +++ b/code_sandboxes/e2b_sandbox.py @@ -0,0 +1,535 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""E2B sandbox implementation. + +`E2B `_ runs code in Firecracker microVMs that start in about +150 ms. This variant drives one through the CODE INTERPRETER SDK — +``e2b-code-interpreter`` — rather than through the plain ``e2b`` SDK: the +interpreter keeps a Jupyter kernel per context, so ``x = 1`` in one call and +``print(x)`` in the next behave the way they do in every other variant of this +package, and rich display data — a figure, an HTML repr — arrives as results +rather than being lost. + +The mapping is unusually direct. E2B's ``run_code`` takes the same arguments +as :meth:`Sandbox.run_code` down to the names of the callbacks, and answers +with an execution carrying logs, results and an error. What is done here is +therefore mostly translation: E2B names each rich format with an attribute +(``png``, ``html``, …) where this package keys them by MIME type, and E2B +stamps its output messages with a millisecond integer where this package +counts in seconds. +""" + +from __future__ import annotations + +import json +import logging +import time +from typing import Any + +from .base import Sandbox +from .exceptions import ( + SandboxConfigurationError, + SandboxExecutionError, + SandboxNotStartedError, + VariableNotFoundError, +) +from .models import ( + CodeError, + Context, + ExecutionResult, + Logs, + MIMEType, + OutputHandler, + OutputMessage, + ResourceConfig, + Result, + SandboxConfig, + SandboxEnvironment, + SandboxInfo, + SandboxStatus, +) + +logger = logging.getLogger(__name__) + +#: What every sandbox this package creates is tagged with, so the ones it made +#: can be told from the rest of an account's. +CREATED_BY_LABEL = "code-sandboxes" + +#: The E2B template a sandbox is created from when the caller names none. +#: +#: The code interpreter's own, NOT E2B's ``base``: the interpreter talks to a +#: Jupyter kernel inside the sandbox, and only a template carrying one can +#: answer it. ``base`` is the default of the plain ``e2b`` SDK, where there is +#: no kernel to talk to, and a sandbox created from it here would start and +#: then fail every execution. +DEFAULT_TEMPLATE = "code-interpreter-v1" + +#: What E2B calls each rich format, and the MIME type this package keys it by. +#: E2B answers with named attributes rather than a MIME dictionary, so the +#: translation has to be written down somewhere; here, once. +_FORMAT_MIME_TYPES: dict[str, str] = { + "text": MIMEType.TEXT_PLAIN.value, + "html": MIMEType.TEXT_HTML.value, + "markdown": MIMEType.TEXT_MARKDOWN.value, + "svg": MIMEType.IMAGE_SVG.value, + "png": MIMEType.IMAGE_PNG.value, + "jpeg": MIMEType.IMAGE_JPEG.value, + "gif": MIMEType.IMAGE_GIF.value, + "pdf": MIMEType.APPLICATION_PDF.value, + "json": MIMEType.APPLICATION_JSON.value, + "latex": "text/latex", + "javascript": "application/javascript", +} + + +def _import_e2b() -> Any: + try: + import e2b_code_interpreter + except ImportError as exc: + raise SandboxConfigurationError( + "e2b-code-interpreter is required for E2BSandbox. Install it with: " + "pip install code-sandboxes[e2b]" + ) from exc + return e2b_code_interpreter + + +def _timestamp(value: Any) -> float: + """One of E2B's timestamps, in the seconds this package counts in. + + E2B stamps an output message with an integer of MILLISECONDS since the + epoch. Passing it through unchanged would put every line of output fifty + thousand years in the future, which is what a reader comparing it against + `started_at` would see. + """ + try: + number = float(value) + except (TypeError, ValueError): + return time.time() + if number <= 0: + return time.time() + # Anything this large is not a count of seconds: 1e11 seconds is the year + # 5138, while 1e11 milliseconds is 1973. + return number / 1000.0 if number > 1e11 else number + + +def _result_data(result: Any) -> dict[str, Any]: + """The formats one E2B result carries, keyed by MIME type. + + ``formats()`` answers with the names of the formats that are actually + present, including the ones E2B does not have an attribute for — a custom + MIME type a library declared — which arrive in ``extra``. Both are taken, + so a result this package hands on is as complete as the one it was given. + """ + data: dict[str, Any] = {} + for name in result.formats(): + value = getattr(result, name, None) + if value is None: + extra = getattr(result, "extra", None) or {} + value = extra.get(name) + if value is None: + continue + data[_FORMAT_MIME_TYPES.get(name, name)] = value + return data + + +class E2BSandbox(Sandbox): + """Sandbox backed by an E2B microVM. + + Args: + config: Optional sandbox configuration. + api_key: E2B API key. Read from ``E2B_API_KEY`` when omitted. + domain: E2B domain to talk to, for a self-hosted cluster. Read from + ``E2B_DOMAIN`` when omitted, which itself defaults to ``e2b.dev``. + template: The E2B template to create from — a base image with its + packages already installed. :data:`DEFAULT_TEMPLATE` when omitted. + allow_internet_access: Whether the sandbox may reach the network at + all. ``network_policy='none'`` of the configuration says the same + thing and wins when it is set. + secure: E2B's own hardening of the sandbox's control plane. On by + default, as it is in the SDK. + """ + + def __init__( + self, + config: SandboxConfig | None = None, + api_key: str | None = None, + domain: str | None = None, + template: str | None = None, + allow_internet_access: bool = True, + secure: bool = True, + **kwargs, + ): + super().__init__(config) + self._api_key = api_key + self._domain = domain + self._template = template + self._allow_internet_access = allow_internet_access + self._secure = secure + self._sandbox: Any | None = None + #: The E2B context standing for each of ours, made on first use — + #: creating one starts a kernel, and most callers use the default + #: namespace and never need a second. + self._contexts: dict[str, Any] = {} + self._execution_count = 0 + self._extra_kwargs = kwargs + + @classmethod + def list_environments(cls) -> list[SandboxEnvironment]: + """The environments this provider ships. + + E2B takes a TEMPLATE per sandbox rather than a catalogue of machines, + and only a template carrying a Jupyter kernel can serve the code + interpreter this variant drives — so there is ONE environment here, + the interpreter's own template, rather than a menu including images + that would start and then fail every execution. A template built on + top of it is asked for by argument — `template=` — the way an image is + everywhere else in this package. + """ + return [ + SandboxEnvironment( + name="e2b-code-interpreter", + title="E2B", + language="python", + owner="e2b", + visibility="cloud", + burning_rate=0.0, + metadata={"variant": "e2b", "template": DEFAULT_TEMPLATE}, + ), + ] + + def start(self) -> None: + if self._started: + return + + e2b = _import_e2b() + self._sandbox = e2b.Sandbox.create(**self._create_params()) + + self._default_context = self.create_context("default") + self._info = SandboxInfo( + id=self._sandbox.sandbox_id, + variant="e2b", + status=SandboxStatus.RUNNING, + created_at=time.time(), + name=self.config.name, + metadata={ + "e2b_sandbox_id": self._sandbox.sandbox_id, + "template": self._template or DEFAULT_TEMPLATE, + "domain": self._domain, + }, + resources=ResourceConfig( + cpu=self.config.cpu_limit, + memory=self.config.memory_limit, + ), + config=self.config, + ) + self._started = True + + def _create_params(self) -> dict[str, Any]: + """What to ask E2B for, from the configuration of this sandbox. + + A setting that was not given is left out entirely rather than passed + as ``None``: the SDK reads ``E2B_API_KEY`` and ``E2B_DOMAIN`` from the + environment for exactly the arguments that are absent, and handing it + an explicit ``None`` is not the same as handing it nothing. + """ + if self.config.gpu: + # Silently running on a CPU is the worse failure: a sandbox that + # looks as though it asked for an H100 and did not is one whose + # timings mean nothing. + raise SandboxConfigurationError( + "E2B sandboxes have no GPU, so gpu=" + repr(self.config.gpu) + " " + "cannot be honoured. Use the daytona, coreweave or modal " + "variant for a GPU." + ) + params: dict[str, Any] = { + "template": self._template or DEFAULT_TEMPLATE, + "metadata": self._metadata(), + "secure": self._secure, + "allow_internet_access": self._network_allowed(), + } + if self._api_key: + params["api_key"] = self._api_key + if self._domain: + params["domain"] = self._domain + if self.config.env_vars: + params["envs"] = dict(self.config.env_vars) + if self.config.max_lifetime: + # E2B counts the life of a sandbox in whole seconds, and takes it + # down when they are up. + params["timeout"] = max(1, round(self.config.max_lifetime)) + return params + + def _metadata(self) -> dict[str, str]: + """The metadata the sandbox carries in E2B. + + E2B keeps this as a flat map of strings and lets a sandbox be looked + up by it, so the name and the tags of the configuration go here — it + is what makes `list` able to say which sandboxes this package made. + """ + metadata = {"created-by": CREATED_BY_LABEL} + if self.config.name: + metadata["name"] = self.config.name + metadata.update({key: str(value) for key, value in self._tags.items()}) + return metadata + + def _network_allowed(self) -> bool: + """Whether this sandbox may reach the network. + + E2B offers one switch rather than an allowlist, so a policy naming + hosts cannot be honoured and is refused instead of being silently + widened to the whole internet — which is the failure that matters. + """ + policy = self.config.network_policy + if policy == "none": + return False + if policy == "allowlist": + raise SandboxConfigurationError( + "E2B has no host allowlist: a sandbox either reaches the " + "network or it does not. Use network_policy='none' to cut it " + "off, or 'all' to allow it." + ) + return self._allow_internet_access + + def stop(self) -> None: + if not self._started: + return + if self._sandbox is not None: + try: + self._sandbox.kill() + except Exception: + logger.debug("Ignoring error while killing the E2B sandbox", exc_info=True) + self._sandbox = None + self._contexts.clear() + self._started = False + if self._info: + self._info.status = SandboxStatus.STOPPED + + def set_timeout(self, seconds: float) -> None: + """Give the sandbox longer to live, counted from now. + + E2B takes a sandbox down when its timeout runs out, whatever it is + doing. A long-running job therefore has to say so before it starts, + and this is how — the count restarts at the moment of the call. + """ + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + self._sandbox.set_timeout(max(1, round(seconds))) + + def get_host(self, port: int) -> str: + """The host a service listening on `port` inside is reachable at. + + E2B gives every sandbox a public hostname per port, which is what + makes a server started inside — a dashboard, an API under test — + reachable from outside without a tunnel of one's own. + """ + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + return self._sandbox.get_host(port) + + def _e2b_context(self, context: Context | None) -> Any | None: + """The E2B context one of ours stands for, made on first use. + + ``None`` — and the default context, which is the same namespace — is + the sandbox's own kernel. Anything else is a context E2B keeps apart, + so :meth:`create_context` really does isolate. + """ + if context is None or context.id == "default": + return None + existing = self._contexts.get(context.id) + if existing is None: + existing = self._sandbox.create_code_context(cwd=context.cwd, language="python") + self._contexts[context.id] = existing + return existing + + def run_code( + 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 or self._sandbox is None: + raise SandboxNotStartedError() + if language != "python": + raise ValueError(f"E2BSandbox only supports Python, got: {language}") + + started_at = time.time() + self._execution_count += 1 + + stdout_messages: list[OutputMessage] = [] + stderr_messages: list[OutputMessage] = [] + results: list[Result] = [] + + def take_stdout(message: Any) -> None: + translated = OutputMessage( + line=message.line, + timestamp=_timestamp(getattr(message, "timestamp", None)), + error=False, + ) + stdout_messages.append(translated) + if on_stdout: + on_stdout(translated) + + def take_stderr(message: Any) -> None: + translated = OutputMessage( + line=message.line, + timestamp=_timestamp(getattr(message, "timestamp", None)), + error=True, + ) + stderr_messages.append(translated) + if on_stderr: + on_stderr(translated) + + def take_result(result: Any) -> None: + translated = Result( + data=_result_data(result), + is_main_result=bool(getattr(result, "is_main_result", False)), + extra=dict(getattr(result, "extra", None) or {}), + ) + results.append(translated) + if on_result: + on_result(translated) + + seconds = timeout if timeout is not None else self.config.timeout + try: + execution = self._sandbox.run_code( + code, + language="python", + context=self._e2b_context(context), + on_stdout=take_stdout, + on_stderr=take_stderr, + on_result=take_result, + envs=envs, + timeout=seconds, + ) + except Exception as error: + # The microVM, not the code: a dropped connection, a sandbox that + # E2B has already taken down. Reported rather than raised, so a + # caller reads it the same way as every other infrastructure + # failure in this package. + return ExecutionResult( + execution_ok=False, + execution_error=f"Failed to execute code on E2B: {error}", + started_at=started_at, + completed_at=time.time(), + context_id=context.id if context else "default", + ) + + # The callbacks above collect everything as it streams, but E2B also + # answers with the whole execution — and does NOT call `on_result` for + # a sandbox that replayed a cached execution. Anything that only + # appears in the answer is taken from there, without duplicating what + # already arrived. + for result in execution.results[len(results) :]: + take_result(result) + + code_error: CodeError | None = None + if execution.error is not None: + code_error = CodeError( + name=execution.error.name or "Error", + value=execution.error.value or "", + traceback=execution.error.traceback or "", + ) + if on_error: + on_error(code_error) + + 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 _do_interrupt(self) -> bool: + """E2B's interpreter takes no interrupt; a timeout is the only stop.""" + return False + + def _get_internal_variable(self, name: str, context: Context | None = None) -> Any: + """The value of a variable, carried back as JSON. + + An E2B sandbox is a machine of its own: what comes back is what can be + encoded, and anything that cannot arrives as its repr rather than + raising — a partial answer being more use than none for the reading + this serves, which is `commands.run` and the filesystem. + """ + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + execution = self.run_code( + "import json as _code_sandboxes_json\n" + f"print(_code_sandboxes_json.dumps({name}, default=repr))\n" + "del _code_sandboxes_json\n", + context=context, + ) + if not execution.execution_ok: + raise SandboxExecutionError( + "SandboxError", execution.execution_error or "Sandbox execution failed" + ) + if execution.code_error is not None: + raise VariableNotFoundError(name) + printed = "\n".join(message.line for message in execution.logs.stdout).strip() + if not printed: + raise VariableNotFoundError(name) + return json.loads(printed) + + def _set_internal_variable(self, name: str, value: Any, context: Context | None = None) -> None: + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + try: + payload = json.dumps(value) + except TypeError as error: + raise SandboxConfigurationError( + f"An E2B sandbox runs elsewhere, so {name!r} has to cross as " + "JSON and this value cannot be encoded. Build it inside the " + "sandbox with run_code instead." + ) from error + execution = self.run_code( + "import json as _code_sandboxes_json\n" + f"{name} = _code_sandboxes_json.loads({payload!r})\n" + "del _code_sandboxes_json\n", + context=context, + ) + if not execution.execution_ok: + raise SandboxExecutionError( + "SandboxError", execution.execution_error or "Sandbox execution failed" + ) + if execution.code_error is not None: + raise SandboxExecutionError( + execution.code_error.name, + execution.code_error.value, + execution.code_error.traceback, + ) + + def _write_file(self, path: str, content: bytes) -> None: + """Straight to the filesystem of the sandbox, not through the code. + + The base class writes a file by running a snippet that base64-decodes + it, which is the only way when all a variant has is an interpreter. + E2B has a filesystem API, so a large file does not have to become a + large program. + """ + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + self._sandbox.files.write(path, content) + + def _read_file(self, path: str) -> bytes: + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + content = self._sandbox.files.read(path, format="bytes") + if content is None: + # A file that is not there raises out of the SDK, so this is the + # other case: an answer carrying neither content nor error. + # Reading it as an empty file would make a failed read look like a + # successful one. + raise FileNotFoundError(f"Could not read file: {path}") + return bytes(content) diff --git a/code_sandboxes/manage.py b/code_sandboxes/manage.py index e6795f1..a487433 100644 --- a/code_sandboxes/manage.py +++ b/code_sandboxes/manage.py @@ -827,6 +827,276 @@ def create(self, **kwargs: Any) -> SandboxInfo: return info +#: What E2B calls the life of a sandbox, in the words used here. +_E2B_STATES = { + "running": SandboxStatus.RUNNING, + "paused": SandboxStatus.STOPPED, + "pausing": SandboxStatus.STOPPING, + "resuming": SandboxStatus.STARTING, + "killed": SandboxStatus.TERMINATED, + "error": SandboxStatus.ERROR, +} + + +class E2BSandboxManager(SandboxManager): + """Sandboxes of an E2B account.""" + + variant = "e2b" + capabilities = frozenset({"create", "list", "get", "delete"}) + + def __init__(self, api_key: str | None = None, domain: str | None = None, **_: Any) -> None: + self._settings = {"api_key": api_key, "domain": domain} + + def _sandbox_class(self) -> Any: + try: + from e2b_code_interpreter import Sandbox + except ImportError as exc: + raise SandboxManagementError( + "e2b-code-interpreter package is required: pip install code-sandboxes[e2b]" + ) from exc + return Sandbox + + def _opts(self) -> dict[str, Any]: + """Only the settings that were given: the SDK reads the rest from the + environment, and an explicit ``None`` is not the same as nothing.""" + return {key: value for key, value in self._settings.items() if value} + + def _info(self, sandbox: Any) -> SandboxInfo: + metadata = dict(getattr(sandbox, "metadata", None) or {}) + state = getattr(sandbox, "state", None) + state_value = getattr(state, "value", state) + return SandboxInfo( + id=getattr(sandbox, "sandbox_id", None) or str(sandbox), + variant=self.variant, + status=_E2B_STATES.get(str(state_value), SandboxStatus.PENDING), + # E2B has a name of its own, and this package puts the name it was + # given in the metadata; the sandbox's own wins when it has one. + name=getattr(sandbox, "name", None) or metadata.get("name"), + metadata={ + "state": state_value, + "template": getattr(sandbox, "template_id", None), + "metadata": metadata, + "started_at": str(getattr(sandbox, "started_at", "") or "") or None, + "cpu_count": getattr(sandbox, "cpu_count", None), + "memory_mb": getattr(sandbox, "memory_mb", None), + }, + ) + + def list(self) -> list[SandboxInfo]: + # `list` answers with a paginator rather than a list: E2B pages, and + # an account with more sandboxes than one page holds would otherwise + # be reported as having only the first of them. + paginator = self._sandbox_class().list(**self._opts()) + sandboxes: list[Any] = [] + if hasattr(paginator, "next_items"): + while paginator.has_next: + sandboxes.extend(paginator.next_items()) + else: # pragma: no cover - an older SDK answering with a plain list + sandboxes.extend(paginator) + return [self._info(sandbox) for sandbox in sandboxes] + + def get(self, sandbox_id: str) -> SandboxInfo | None: + for info in self.list(): + if info.id == sandbox_id: + return info + return None + + def delete(self, sandbox_id: str) -> bool: + try: + return bool(self._sandbox_class().kill(sandbox_id, **self._opts())) + except Exception: + return False + + def create(self, **kwargs: Any) -> SandboxInfo: + from .e2b_sandbox import E2BSandbox + + sandbox = E2BSandbox(**self._opts(), **kwargs) + sandbox.start() + info = sandbox.info + if info is None: + raise SandboxManagementError("The sandbox started without an identity.") + # Detached, so it outlives this call: stopping it is `delete`. + sandbox._sandbox = None + sandbox._started = False + return info + + +#: What CoreWeave calls the life of a sandbox, in the words used here. +_COREWEAVE_STATES = { + "running": SandboxStatus.RUNNING, + "creating": SandboxStatus.STARTING, + "pending": SandboxStatus.PENDING, + "paused": SandboxStatus.STOPPED, + "terminating": SandboxStatus.STOPPING, + "completed": SandboxStatus.STOPPED, + "terminated": SandboxStatus.TERMINATED, + "failed": SandboxStatus.ERROR, + "unspecified": SandboxStatus.PENDING, +} + + +class CoreWeaveSandboxManager(SandboxManager): + """Sandboxes of a CoreWeave organization.""" + + variant = "coreweave" + capabilities = frozenset({"create", "list", "get", "delete"}) + + def __init__(self, api_key: str | None = None, base_url: str | None = None, **_: Any) -> None: + self._settings = {"api_key": api_key, "base_url": base_url} + + def _sandbox_class(self) -> Any: + try: + from cwsandbox import Sandbox + except ImportError as exc: + raise SandboxManagementError( + "cwsandbox package is required: pip install code-sandboxes[coreweave]" + ) from exc + import os + + # The SDK authenticates from the environment and takes no token + # argument, so a token given here is put where it looks for one. + if self._settings["api_key"]: + os.environ["CWSANDBOX_API_KEY"] = str(self._settings["api_key"]) + if self._settings["base_url"]: + os.environ["CWSANDBOX_BASE_URL"] = str(self._settings["base_url"]) + return Sandbox + + def _info(self, sandbox: Any) -> SandboxInfo: + tags = list(getattr(sandbox, "tags", None) or []) + # CoreWeave keeps tags as a flat list of strings, so a pair travels as + # `key=value` and is read back the same way. + pairs = dict(tag.split("=", 1) for tag in tags if "=" in tag) + status = getattr(sandbox, "status", None) + status_value = getattr(status, "value", status) + return SandboxInfo( + id=sandbox.sandbox_id, + variant=self.variant, + status=_COREWEAVE_STATES.get(str(status_value), SandboxStatus.PENDING), + name=pairs.get("name"), + metadata={ + "status": status_value, + "tags": tags, + "runner_id": getattr(sandbox, "runner_id", None), + }, + ) + + def list(self) -> list[SandboxInfo]: + sandboxes = self._sandbox_class().list().result() + return [self._info(sandbox) for sandbox in sandboxes] + + def get(self, sandbox_id: str) -> SandboxInfo | None: + try: + return self._info(self._sandbox_class().from_id(sandbox_id).result()) + except Exception: + return None + + def delete(self, sandbox_id: str) -> bool: + try: + sandbox = self._sandbox_class().from_id(sandbox_id).result() + except Exception: + return False + try: + sandbox.stop(missing_ok=True).result() + except Exception: + return False + return True + + def create(self, **kwargs: Any) -> SandboxInfo: + from .coreweave_sandbox import CoreWeaveSandbox + + given = {key: value for key, value in self._settings.items() if value} + # No session process: a sandbox nothing is holding open should not be + # paying for a driver waiting on a stdin that will never be written. + sandbox = CoreWeaveSandbox(stateful=False, **given, **kwargs) + sandbox.start() + info = sandbox.info + if info is None: + raise SandboxManagementError("The sandbox started without an identity.") + # Detached, so it outlives this call: stopping it is `delete`. + sandbox._sandbox = None + sandbox._started = False + return info + + +class CloudflareSandboxManager(SandboxManager): + """Sandboxes of a deployed Cloudflare sandbox bridge. + + The bridge exposes one sandbox at a time by its id and has no endpoint + that enumerates them, so `list` cannot be answered — which is said plainly + rather than answered with an empty list, since "none" and "cannot know" + are different facts. + """ + + variant = "cloudflare" + capabilities = frozenset({"create", "get", "delete"}) + + def __init__(self, api_url: str | None = None, api_key: str | None = None, **_: Any) -> None: + self._settings = {"api_url": api_url, "api_key": api_key} + + def _client(self) -> Any: + """An HTTP client for the bridge, with no sandbox behind it. + + Deleting a sandbox by id and asking after one by id are both plain + calls to the bridge; creating a container merely to have something to + make the call with would leave that container running and billed. + """ + from .cloudflare_sandbox import CloudflareSandbox + + given = {key: value for key, value in self._settings.items() if value} + try: + return CloudflareSandbox(**given).build_client() + except Exception as exc: + raise SandboxManagementError(str(exc)) from exc + + def list(self) -> list[SandboxInfo]: + raise self._unsupported( + "list", "the sandbox bridge has no endpoint that enumerates sandboxes" + ) + + def get(self, sandbox_id: str) -> SandboxInfo | None: + from urllib.parse import quote + + try: + with self._client() as client: + response = client.get(f"/v1/sandbox/{quote(sandbox_id)}/running") + if response.status_code >= 400: + return None + running = bool(response.json().get("running")) + except Exception: + return None + return SandboxInfo( + id=sandbox_id, + variant=self.variant, + # The bridge knows one thing about a sandbox — whether its + # container is up — so that is all this claims to know. + status=SandboxStatus.RUNNING if running else SandboxStatus.STOPPED, + ) + + def delete(self, sandbox_id: str) -> bool: + from urllib.parse import quote + + try: + with self._client() as client: + response = client.delete(f"/v1/sandbox/{quote(sandbox_id)}") + except Exception: + return False + return response.status_code < 400 + + def create(self, **kwargs: Any) -> SandboxInfo: + from .cloudflare_sandbox import CloudflareSandbox + + given = {key: value for key, value in self._settings.items() if value} + sandbox = CloudflareSandbox(**given, **kwargs) + sandbox.start() + info = sandbox.info + if info is None: + raise SandboxManagementError("The sandbox started without an identity.") + # Detached, so it outlives this call: stopping it is `delete`. + sandbox._client = None + sandbox._started = False + return info + + _MANAGERS: dict[str, type[SandboxManager]] = { "eval": EvalSandboxManager, "monty": MontySandboxManager, @@ -837,6 +1107,9 @@ def create(self, **kwargs: Any) -> SandboxInfo: "modal": ModalSandboxManager, "daytona": DaytonaSandboxManager, "datalayer": DatalayerSandboxManager, + "e2b": E2BSandboxManager, + "coreweave": CoreWeaveSandboxManager, + "cloudflare": CloudflareSandboxManager, } diff --git a/code_sandboxes/modal_sandbox.py b/code_sandboxes/modal_sandbox.py index b1751d6..d87899d 100644 --- a/code_sandboxes/modal_sandbox.py +++ b/code_sandboxes/modal_sandbox.py @@ -8,10 +8,12 @@ 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. +One ``python -u -c`` process is started with the sandbox and fed JSON lines on +stdin — one request, one reply — so snippets share a namespace: ``x = 1`` in one +call is still there in the next. A session that cannot be started, or that goes +away mid-run, drops back to a fresh ``python -c`` process per snippet, which +works and merely forgets. Rich display outputs (images, HTML) are not captured; +stdout, stderr and the value of a trailing expression are. """ from __future__ import annotations diff --git a/code_sandboxes/models.py b/code_sandboxes/models.py index 72abe96..fd2324c 100644 --- a/code_sandboxes/models.py +++ b/code_sandboxes/models.py @@ -65,9 +65,12 @@ class SandboxStatus(str, Enum): class SandboxVariant(str, Enum): """Supported sandbox variants.""" + CLOUDFLARE = "cloudflare" + COREWEAVE = "coreweave" DATALAYER = "datalayer" DAYTONA = "daytona" DOCKER = "docker" + E2B = "e2b" EVAL = "eval" GOOGLE_COLAB = "google-colab" JUPYTER = "jupyter-server" diff --git a/code_sandboxes/providers.py b/code_sandboxes/providers.py index 1244669..1e59283 100644 --- a/code_sandboxes/providers.py +++ b/code_sandboxes/providers.py @@ -255,6 +255,57 @@ def read(**kwargs) -> list[SandboxEnvironment]: ), list_environments=_environments_of(SandboxVariant.DAYTONA), ), + SandboxProvider( + variant=SandboxVariant.E2B, + title="E2B", + description=( + "Sandboxes on E2B, in Firecracker microVMs that start in about 150 ms, " + "with a stateful Python kernel and rich outputs." + ), + extra="e2b", + requirements=( + ProviderRequirement( + env_vars=("E2B_API_KEY",), + hint="Create an API key at e2b.dev and set E2B_API_KEY.", + ), + ), + list_environments=_environments_of(SandboxVariant.E2B), + ), + SandboxProvider( + variant=SandboxVariant.COREWEAVE, + title="CoreWeave", + description=( + "Containers on CoreWeave, with a stateful Python session and an optional GPU." + ), + extra="coreweave", + requirements=( + ProviderRequirement( + env_vars=("CWSANDBOX_API_KEY",), + hint=("Create an access token in the CoreWeave console and set CWSANDBOX_API_KEY."), + ), + ), + list_environments=_environments_of(SandboxVariant.COREWEAVE), + ), + SandboxProvider( + variant=SandboxVariant.CLOUDFLARE, + title="Cloudflare", + description=( + "Containers on Cloudflare's edge, reached through a deployed sandbox " + "bridge Worker. Each snippet runs in a process of its own." + ), + extra="cloudflare", + requirements=( + ProviderRequirement( + env_vars=("CLOUDFLARE_SANDBOX_API_URL", "CLOUDFLARE_SANDBOX_API_KEY"), + hint=( + "Deploy the sandbox bridge Worker, then set " + "CLOUDFLARE_SANDBOX_API_URL to where it answers and " + "CLOUDFLARE_SANDBOX_API_KEY to the secret it generated." + ), + ), + ), + list_environments=_environments_of(SandboxVariant.CLOUDFLARE), + ), SandboxProvider( variant=SandboxVariant.DOCKER, title="Docker", diff --git a/docs/docs/api-reference/index.mdx b/docs/docs/api-reference/index.mdx index 049d3e6..6a93d1d 100644 --- a/docs/docs/api-reference/index.mdx +++ b/docs/docs/api-reference/index.mdx @@ -28,7 +28,10 @@ def create( tags: dict[str, str] | None = None, name: str | None = None, snapshot_name: str | None = None, + network_policy: str | None = None, + allowed_hosts: list[str] | None = None, config: SandboxConfig | None = None, + **kwargs, ) -> Sandbox ``` @@ -36,7 +39,7 @@ def create( | Parameter | Type | Description | |-----------|------|-------------| -| `variant` | `str` | Sandbox type: `"eval"`, `"docker"`, `"jupyter-server"`, `"monty"`, `"kaggle"`, `"google-colab"`, `"modal"`, or `"datalayer"`. Defaults to `"datalayer"`. | +| `variant` | `str` | Sandbox type: `"cloudflare"`, `"coreweave"`, `"datalayer"`, `"daytona"`, `"docker"`, `"e2b"`, `"eval"`, `"google-colab"`, `"jupyter-server"`, `"kaggle"`, `"modal"`, or `"monty"`. Defaults to `"datalayer"`. | | `timeout` | `float` | Execution timeout in seconds | | `environment` | `str` | Runtime environment name | | `gpu` | `str` | GPU type (e.g., `"T4"`, `"A100"`, `"H100"`) | @@ -46,7 +49,10 @@ def create( | `tags` | `dict` | Metadata tags | | `name` | `str` | Sandbox name | | `snapshot_name` | `str` | Snapshot to restore from | +| `network_policy` | `str` | Network access policy: `"inherit"`, `"none"`, `"allowlist"`, `"all"` | +| `allowed_hosts` | `list[str]` | Hosts reachable when the policy is `"allowlist"` | | `config` | `SandboxConfig` | Full configuration object | +| `**kwargs` | `dict` | Variant-specific arguments handed to the sandbox constructor — `template=` (e2b), `container_image=` (coreweave), `api_url=` (cloudflare), and the rest documented on each [variant's page](/sandboxes) | #### `Sandbox.from_id()` @@ -82,7 +88,7 @@ def list_environments( | Parameter | Type | Description | |-----------|------|-------------| -| `variant` | `str` | Sandbox type: `"eval"`, `"docker"`, `"jupyter-server"`, `"monty"`, `"kaggle"`, `"google-colab"`, `"modal"`, or `"datalayer"` | +| `variant` | `str` | Sandbox type: `"cloudflare"`, `"coreweave"`, `"datalayer"`, `"daytona"`, `"docker"`, `"e2b"`, `"eval"`, `"google-colab"`, `"jupyter-server"`, `"kaggle"`, `"modal"`, or `"monty"` | | `**kwargs` | `dict` | Variant-specific arguments (e.g., credentials, run URL) | Legacy `local-eval`, `local-docker`, and `local-jupyter` variant names are not supported. diff --git a/docs/docs/cli/index.mdx b/docs/docs/cli/index.mdx index dc65dd3..e091ba3 100644 --- a/docs/docs/cli/index.mdx +++ b/docs/docs/cli/index.mdx @@ -75,9 +75,12 @@ for a reminder. On exit, the sandbox is terminated. Supported variants: +- `cloudflare` +- `coreweave` - `datalayer` - `daytona` - `docker` +- `e2b` - `eval` - `google-colab` - `jupyter-server` @@ -91,9 +94,20 @@ needs. ## Variant-specific Behavior +- `cloudflare`: talks to a deployed sandbox bridge Worker, named by + `CLOUDFLARE_SANDBOX_API_URL` with the `CLOUDFLARE_SANDBOX_API_KEY` it was + deployed with. Each snippet — and each line of the REPL — runs in a process + of its own, so a definition on one line is gone by the next. +- `coreweave`: starts a CoreWeave sandbox and holds a session process for it, + so lines share a namespace. `--gpu` is not passed on for this variant; a GPU + is asked for from Python, `Sandbox.create(variant="coreweave", gpu="H100")`. - `daytona`: starts a Daytona sandbox; `--gpu` takes Daytona's own flavors (`H100`, `H200`, `RTX-4090`, ...), several of them comma-separated to fall back along, and `--spot` runs on preemptible capacity. +- `e2b`: starts an E2B microVM from the interpreter's own template, + `code-interpreter-v1`. Another is named from Python with `template=`, and has + to be built on top of it — only a template carrying a Jupyter kernel can + serve this variant. State persists between lines. - `google-colab`: prompts for runtime URL, kernel ID, and proxy token. - `jupyter-server`: starts a managed local Jupyter server on a random port. - `kaggle`: supports either interactive runtime settings or credential-based batch execution. diff --git a/docs/docs/cli/management.mdx b/docs/docs/cli/management.mdx index 42bf7e0..353da8a 100644 --- a/docs/docs/cli/management.mdx +++ b/docs/docs/cli/management.mdx @@ -59,7 +59,7 @@ below the table instead of hiding the ones that answered. from code_sandboxes import get_manager, manageable_variants print(manageable_variants()) -# ['datalayer', 'daytona', 'docker', 'eval', 'google-colab', 'jupyter-server', 'kaggle', 'modal', 'monty'] +# ['cloudflare', 'coreweave', 'datalayer', 'daytona', 'docker', 'e2b', 'eval', 'google-colab', 'jupyter-server', 'kaggle', 'modal', 'monty'] manager = get_manager("modal") for info in manager.list(): @@ -76,6 +76,9 @@ get_manager("jupyter-server", server_url="http://localhost:8888", token="...") get_manager("google-colab", server_url="https://...", proxy_token="...") get_manager("modal", app_name="code-sandboxes") get_manager("daytona", api_key="dtn_...", target="eu") +get_manager("e2b", api_key="e2b_...", domain="e2b.example.com") +get_manager("coreweave", api_key="...", base_url="https://api.cwsandbox.com") +get_manager("cloudflare", api_url="https://...workers.dev", api_key="...") get_manager("kaggle", username="...") get_manager("datalayer", token="...", run_url="https://...") ``` @@ -84,9 +87,12 @@ get_manager("datalayer", token="...", run_url="https://...") | Variant | A sandbox is | Update changes | Delete removes | | --- | --- | --- | --- | +| `cloudflare` | a container of a deployed sandbox bridge | — nothing in place | the Cloudflare sandbox | +| `coreweave` | a sandbox of the CoreWeave organization | — nothing in place | the CoreWeave sandbox | | `datalayer` | a runtime of the Datalayer platform | the capabilities | the runtime | | `daytona` | a sandbox of the Daytona organization | the labels | the Daytona sandbox | | `docker` | a container labelled `code-sandboxes` | the name | the container (forced) | +| `e2b` | a sandbox of the E2B account | — nothing in place | the E2B sandbox | | `eval`, `monty` | an object inside the creating process | — not supported | — not supported | | `google-colab` | a kernel of the Colab runtime | — nothing in place | the kernel | | `jupyter-server` | a kernel of the Jupyter Server | — nothing in place | the kernel | @@ -99,6 +105,12 @@ reason for the rest — an `eval` sandbox lives and dies inside the process that made it, so `list` truthfully answers `[]` and `delete` explains itself rather than silently doing nothing. +Cloudflare notes: `create`, `get` and `delete` are answered; `list` is **not**. +The sandbox bridge exposes a sandbox by its id and has no endpoint that +enumerates them, so `code-sandboxes list -v cloudflare` raises with that +reason instead of answering with an empty list — "none" and "cannot know" are +different facts, and a management tool that confuses them loses sandboxes. + Kaggle notes: `list` enumerates your kernels (`mine=True`), `get` adds the live run status, and `create` pushes a batch kernel with the given `--code` — creation on Kaggle *is* a code push. Both `user/slug` and a bare `slug` of diff --git a/docs/docs/comparison/index.mdx b/docs/docs/comparison/index.mdx index 30b3e45..870018c 100644 --- a/docs/docs/comparison/index.mdx +++ b/docs/docs/comparison/index.mdx @@ -5,165 +5,172 @@ sidebar_position: 3 # Comparison with Other Solutions -This page compares Code Sandboxes with other popular code execution platforms: E2B and Modal. - -## Overview - -| Feature | Code Sandboxes | E2B | Modal | -|---------|---------------|-----|-------| -| **Open Source** | ✅ Yes (BSD-3) | ✅ Yes (Apache-2) | ❌ No | -| **Self-hostable** | ✅ Yes | ✅ Yes | ❌ No | -| **Cloud Offering** | ✅ Datalayer | ✅ E2B Cloud | ✅ Modal Cloud | -| **Execution** | ✅ Yes | ❌ No | ❌ No | -| **GPU Support** | ✅ Yes | ❌ No | ✅ Yes | -| **Snapshots** | ✅ Yes | ✅ Yes | ✅ Yes | -| **Jupyter Kernel** | ✅ Native | ✅ Yes | ❌ No | +This page used to weigh Code Sandboxes against E2B and Modal as alternatives. +They are not alternatives any more: both are **backends this package drives**, +alongside [Daytona](/sandboxes/daytona), [CoreWeave](/sandboxes/coreweave) and +[Cloudflare](/sandboxes/cloudflare). `variant="e2b"` and `variant="modal"` run +code on E2B and on Modal, through their own SDKs, with their own accounts and +their own bills. + +So the question this page answers is not which of them to use instead. It is +what the package adds over calling one of them directly, and what each of them +still decides for you once you are on it. + +## What Code Sandboxes Adds + +- **One result, whichever backend answered.** `run_code` comes back as an + `ExecutionResult` everywhere, with the failure split three ways: the sandbox + (`execution_ok`), the process (`exit_code`), and the code itself + (`code_error`). A caller that handles those handles every backend. +- **One vocabulary for the rest.** `sandbox.files`, `sandbox.commands`, + `create_context()`, `set_timeout()`, names and tags mean the same thing on + each backend and are mapped onto whatever it actually offers — a filesystem + API where there is one, a snippet that decodes base64 where there is not. +- **CRUD in one place.** `code-sandboxes list`, `get`, `create` and `delete` + work across the backends that can answer them; see the + [management guide](/cli/management). +- **A refusal instead of a surprise.** Where a backend cannot do what was + asked, the call says so rather than quietly doing something else: + `network_policy="allowlist"` on E2B is refused because E2B has no host + allowlist, `list` on Cloudflare raises because the bridge cannot enumerate + sandboxes, and a value that cannot cross as JSON is refused rather than + mangled. +- **Nothing is required to be a cloud.** `eval`, `monty`, `docker` and + `jupyter-server` run on the machine in front of you, through the same + interface as the cloud backends — which is what makes a test suite that + exercises the interface cheap to run. + +The package is BSD-3 licensed and no backend is privileged in it. Moving +between them is a change of one string. + +## The Same Program, On Any Backend -## Feature Comparison - -### Sandbox Creation - -| Operation | Code Sandboxes | E2B | Modal | -|-----------|---------------|-----|-------| -| Create sandbox | `Sandbox.create()` | `Sandbox()` | `Sandbox.create()` | -| With timeout | `Sandbox.create(timeout=60)` | `Sandbox(timeout=60000)` | N/A | -| With GPU | `Sandbox.create(gpu="T4")` | N/A | `Sandbox.create(gpu="T4")` | -| From snapshot | `Sandbox.create(snapshot_name="...")` | `Sandbox(template="...")` | `Sandbox.from_id("...")` | -| Reconnect | `Sandbox.from_id(id)` | `Sandbox.reconnect(id)` | `Sandbox.from_id(id)` | -| List sandboxes | `Sandbox.list()` | N/A | `Sandbox.list()` | - -### Code Execution - -| Operation | Code Sandboxes | E2B | Modal | -|-----------|---------------|-----|-------| -| Run code | `sandbox.run_code(code)` | `sandbox.runCode(code)` | `sandbox.exec("python", "-c", code)` | -| Streaming | `on_stdout=callback` | `onStdout=callback` | `for line in proc.stdout` | -| Variables | `sandbox.get_variable(name)` | `sandbox.getVar(name)` | N/A | -| Install packages | `sandbox.install_packages([...])` | `sandbox.installPackages([...])` | `sandbox.exec("pip", "install", ...)` | - -### Filesystem Operations - -| Operation | Code Sandboxes | E2B | Modal | -|-----------|---------------|-----|-------| -| Read file | `sandbox.files.read(path)` | `sandbox.files.read(path)` | `sandbox.read_file(path)` | -| Write file | `sandbox.files.write(path, content)` | `sandbox.files.write(path, content)` | `sandbox.open(path, "w")` | -| List directory | `sandbox.files.list(path)` | `sandbox.files.list(path)` | `sandbox.list_files(path)` | -| Make directory | `sandbox.files.mkdir(path)` | `sandbox.files.makeDir(path)` | N/A | -| Upload | `sandbox.files.upload(local, remote)` | `sandbox.files.upload(local, remote)` | N/A | -| Download | `sandbox.files.download(remote, local)` | `sandbox.files.download(remote, local)` | N/A | - -### Command Execution - -| Operation | Code Sandboxes | E2B | Modal | -|-----------|---------------|-----|-------| -| Run command | `sandbox.commands.run(cmd)` | `sandbox.commands.run(cmd)` | `sandbox.exec(cmd)` | -| Streaming exec | `sandbox.commands.exec(*args)` | N/A | `sandbox.exec(*args)` | -| Background process | `sandbox.commands.spawn(cmd)` | `sandbox.process.start(cmd)` | N/A | -| System packages | `sandbox.commands.install_system_packages([...])` | N/A | N/A | - -### Lifecycle Management - -| Operation | Code Sandboxes | E2B | Modal | -|-----------|---------------|-----|-------| -| Set timeout | `sandbox.set_timeout(seconds)` | `sandbox.setTimeout(ms)` | N/A | -| Terminate | `sandbox.terminate()` | `sandbox.close()` | `sandbox.terminate()` | -| Force kill | `sandbox.kill()` | `sandbox.kill()` | N/A | -| Get info | `sandbox.get_info()` | N/A | N/A | - -### Snapshots - -| Operation | Code Sandboxes | E2B | Modal | -|-----------|---------------|-----|-------| -| Create snapshot | `sandbox.create_snapshot(name)` | `sandbox.pause()` | `sandbox.snapshot_filesystem()` | -| List snapshots | `sandbox.list_snapshots()` | N/A | N/A | -| Restore | `Sandbox.create(snapshot_name=...)` | `Sandbox(template=...)` | `Sandbox.from_id(...)` | - -## Architecture Comparison - -### E2B - -E2B focuses on cloud-based code interpretation, primarily designed for AI assistants. It uses microVMs for isolation and provides a JavaScript/TypeScript SDK. - -**Pros:** -- Simple API -- Good for AI chatbots -- Fast cold starts - -**Cons:** -- Cloud-only -- No GPU support -- JavaScript-first (Python SDK is secondary) - -### Modal - -Modal is a serverless platform for running Python code in the cloud. It's designed for ML workloads with strong GPU support. - -**Pros:** -- Excellent GPU support -- Powerful for ML workloads -- Great parallelization - -**Cons:** -- Cloud-only (no self-hosting) -- Closed source -- More complex API - -### Code Sandboxes - -Code Sandboxes provides a unified API across all supported variants (`eval`, `monty`, `docker`, `jupyter-server`, `kaggle`, `google-colab`, `modal`, `datalayer`), with native Jupyter kernel support. - -**Pros:** -- Open source and self-hostable -- Multiple execution variants through one API -- Native Jupyter integration -- GPU support via Datalayer runtime -- Simple, consistent API - -**Cons:** -- Newer project -- Smaller community +```python +from code_sandboxes import Sandbox -## Migration Guides +for variant in ("eval", "e2b", "modal", "daytona", "coreweave"): + with Sandbox.create(variant=variant) as sandbox: + print(variant, sandbox.run_code("40 + 2").text) # "42" on each +``` -### From E2B +What the interface cannot do is give a backend a capability it does not have. +That is the other half of the comparison, and it is the table below. + +## What Each Backend Brings + +| Backend | Runs code in | State between calls | Rich display data | GPU | Management verbs | +| --- | --- | --- | --- | --- | --- | +| [`cloudflare`](/sandboxes/cloudflare) | a container on the edge, through a bridge Worker | none — a process per snippet | no | not exposed here | create, get, delete | +| [`coreweave`](/sandboxes/coreweave) | a container on CoreWeave | a session process, falling back to none | no | `gpu=` | create, list, get, delete | +| [`datalayer`](/sandboxes/datalayer) | a Datalayer runtime | a kernel | yes | `gpu=` | create, list, get, update, delete | +| [`daytona`](/sandboxes/daytona) | a Daytona sandbox | the code interpreter, one namespace per context | no | `gpu=`, spot capacity | create, list, get, update, delete | +| [`e2b`](/sandboxes/e2b) | a Firecracker microVM | a Jupyter kernel per context | yes | not exposed here | create, list, get, delete | +| [`modal`](/sandboxes/modal) | a Modal container | a session process, falling back to none | no | `gpu=` | create, list, get, update, delete | + +"Not exposed here" is about this package, not about the vendor: it means the +variant has no `gpu=` to pass on, not that the platform has no hardware. + +The local backends answer the same interface with none of the accounts: +[`docker`](/sandboxes/docker) runs a container on this machine, +[`jupyter-server`](/sandboxes/jupyter-server) a kernel of a Jupyter Server, +[`monty`](/sandboxes/monty) a restricted Python subset in this process, and +[`eval`](/sandboxes/eval) plain `exec()` — which isolates nothing and is for +development and tests. + +## What The Package Calls On Your Behalf + +The SDKs underneath are still there, and this is roughly what a variant does +with them. The point of the table is not that one column is better: it is that +the left column is the same sentence in every row, and the others are not. + +| Operation | Code Sandboxes | E2B SDK | Modal SDK | +| --- | --- | --- | --- | +| Create a sandbox | `Sandbox.create(variant=...)` | `Sandbox.create()` | `modal.Sandbox.create(app=app, image=image)` | +| Run a snippet | `sandbox.run_code(code)` | `sandbox.run_code(code)` | `sandbox.exec("python", "-c", code)` | +| Stream output | `on_stdout=callback` | `on_stdout=callback` | `for line in process.stdout` | +| A value back from the code | `result.text`, `result.results` | `execution.results` | — read it off stdout yourself | +| Shell command | `sandbox.commands.run(cmd)` | `sandbox.commands.run(cmd)` | `sandbox.exec(*args)` | +| Write a file | `sandbox.files.write(path, content)` | `sandbox.files.write(path, content)` | `sandbox.open(path, "w")` | +| Read a file | `sandbox.files.read(path)` | `sandbox.files.read(path)` | `sandbox.open(path)` | +| Extend the life | `sandbox.set_timeout(seconds)` | `sandbox.set_timeout(seconds)` | — | +| Terminate | `sandbox.terminate()` | `sandbox.kill()` | `sandbox.terminate()` | +| List what is running | `get_manager(variant).list()` | `Sandbox.list()` | `modal.Sandbox.list()` | +| A GPU | `Sandbox.create(variant="modal", gpu="T4")` | — | `modal.Sandbox.create(gpu="T4")` | + +Where a backend has nothing for a row, the variant either builds it — +Cloudflare and CoreWeave run each snippet through a small program that reports +the trailing expression and the traceback, because `exec` on its own reports +neither — or refuses it, and its page says which. + +## Coming From The E2B SDK + +The sandbox is the same sandbox; what changes is the interface in front of it, +and that a second backend costs one string. ```python -# E2B +# E2B, directly from e2b_code_interpreter import Sandbox -sandbox = Sandbox(timeout=60000) -result = sandbox.runCode("print('hello')") -sandbox.close() -# Code Sandboxes +sandbox = Sandbox.create() +execution = sandbox.run_code("print('hello')") +sandbox.kill() + +# Through Code Sandboxes from code_sandboxes import Sandbox -with Sandbox.create(timeout=60) as sandbox: + +with Sandbox.create(variant="e2b") as sandbox: result = sandbox.run_code("print('hello')") + print(result.stdout) ``` -### From Modal +`E2B_API_KEY` is read the same way in both, and `E2B_DOMAIN` still points at a +self-hosted cluster. What the variant adds is the shared result model, the +management verbs, and the refusal where E2B's own model has no answer — see +[E2B](/sandboxes/e2b). + +## Coming From The Modal SDK ```python -# Modal +# Modal, directly import modal -sb = modal.Sandbox.create(gpu="T4") -process = sb.exec("python", "-c", "print('hello')") + +app = modal.App.lookup("my-app", create_if_missing=True) +sandbox = modal.Sandbox.create(app=app, gpu="T4") +process = sandbox.exec("python", "-c", "print('hello')") print(process.stdout.read()) -sb.terminate() +sandbox.terminate() -# Code Sandboxes +# Through Code Sandboxes from code_sandboxes import Sandbox -with Sandbox.create(variant="datalayer", gpu="T4") as sandbox: + +with Sandbox.create(variant="modal", gpu="T4") as sandbox: result = sandbox.run_code("print('hello')") print(result.stdout) ``` -## Choosing the Right Solution +Credentials are Modal's own — `modal token new`, or `MODAL_TOKEN_ID` with +`MODAL_TOKEN_SECRET`. See [Modal](/sandboxes/modal). + +## Choosing A Backend + +| Use case | Backend | +|----------|---------| +| Development and tests, no account, no isolation | `eval` | +| LLM-generated snippets that must not touch the host | `monty` | +| Notebook-shaped work with a kernel, locally | `jupyter-server`, `docker` | +| An agent session that keeps its variables, in a cloud | `e2b`, `daytona`, `datalayer` | +| Figures and HTML back from the code | `e2b`, `datalayer`, `jupyter-server` | +| ML training on a GPU | `coreweave`, `modal`, `daytona`, `datalayer` | +| The cheapest GPU capacity, preemption accepted | `daytona` with `spot=True` | +| Short snippets close to the user, no state to keep | `cloudflare` | +| Production runtimes with snapshots and quotas | `datalayer` | + +## What This Package Is Not + +It is not a compute platform. Capacity, regions, quotas, cold starts and the +bill belong to the backend you chose, and no interface in front of them changes +any of it. It adds no isolation either: an `eval` sandbox is `exec()` in this +process however carefully the result is reported, and a sandbox is only as +contained as the backend running it. -| Use Case | Recommended | -|----------|-------------| -| AI chatbot code execution | Code Sandboxes or E2B | -| ML training with GPUs | Code Sandboxes or Modal | -| development/testing | Code Sandboxes | -| Self-hosted infrastructure | Code Sandboxes or E2B | -| Jupyter notebook workflows | Code Sandboxes | -| Serverless Python functions | Modal | +What it does is make the choice reversible. diff --git a/docs/docs/examples/index.mdx b/docs/docs/examples/index.mdx index 7b4a9fd..2b12712 100644 --- a/docs/docs/examples/index.mdx +++ b/docs/docs/examples/index.mdx @@ -11,9 +11,30 @@ For canonical setup details per variant (requirements, credentials, and parameters), see [Sandboxes](/sandboxes). For installation extras, see [Installation](/installation). +## Cloudflare + +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/cloudflare_sandbox_example.py + +Needs a deployed sandbox bridge Worker, named by `CLOUDFLARE_SANDBOX_API_URL` +with the `CLOUDFLARE_SANDBOX_API_KEY` it generated. The example shows what a +stateless runner means and the two ways round it — one snippet, or a file. + +```bash +make cloudflare +``` + +## CoreWeave + +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/coreweave_sandbox_example.py + +```bash +make coreweave +make coreweave-gpu # COREWEAVE_GPU, default H100 +``` + ## Datalayer -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/datalayer_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/datalayer_sandbox_example.py ```bash make datalayer @@ -29,15 +50,23 @@ make daytona ## Docker -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/docker_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/docker_sandbox_example.py ```bash make docker ``` +## E2B + +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/e2b_sandbox_example.py + +```bash +make e2b +``` + ## Eval -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/eval_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/eval_sandbox_example.py ```bash make eval @@ -51,12 +80,12 @@ make eval make google-colab ``` -## Jupyter +## Jupyter Server -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/jupyter_server_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/jupyter_server_sandbox_example.py ```bash -make jupyter +make jupyter-server ``` ## Kaggle @@ -69,7 +98,7 @@ make kaggle ## Modal -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/modal_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/modal_sandbox_example.py ```bash make modal @@ -77,13 +106,15 @@ make modal ## Monty -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/monty_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/monty_sandbox_example.py ```bash make monty ``` -See the Makefile for all targets: https://github.com/datalayer/code-sandboxes/blob/main/examples/Makefile +See the Makefiles for all targets — one per set: +https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/Makefile +and https://github.com/datalayer/code-sandboxes/blob/main/examples/repl/Makefile Every example runs on the machinery the package exports — `show_and_run` for the exec examples, `run_repl` for the REPL ones — which is the same code diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index 7b17593..75942c8 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -51,7 +51,7 @@ This section clarifies what the package owns versus what is delegated to adjacen - **🧭 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, Jupyter, Monty, Kaggle, Colab, Modal, and Datalayer +- **⚡ Multiple Backends**: eval, Docker, Jupyter, Monty, Kaggle, Colab, Modal, Daytona, E2B, CoreWeave, Cloudflare, and Datalayer - **🔄 State Persistence**: Maintain variables and context between executions - **📊 Rich Output**: Support for text, HTML, images, and structured data - **📸 Snapshots**: Save and restore sandbox state @@ -63,9 +63,12 @@ Code Sandboxes supports these execution variants: | Variant | Isolation Level | Best For | |---------|-----------------|----------| +| `cloudflare` | Managed container on the edge | Short snippets, each on its own | +| `coreweave` | Managed container | GPU work with a stateful session | | `datalayer` | Managed VM/runtime | Production and GPU workloads | | `daytona` | Managed cloud sandbox | Stateful agent sessions | | `docker` | Container | Isolated execution | +| `e2b` | Firecracker microVM | Fast starts, stateful kernel, rich outputs | | `eval` | None (Python exec) | Development, testing | | `google-colab` | Managed notebook runtime | Interactive Colab-connected runs | | `jupyter-server` | Process (Jupyter kernel) | Persistent notebook-style state | diff --git a/docs/docs/installation/index.mdx b/docs/docs/installation/index.mdx index b387e2d..2e8afde 100644 --- a/docs/docs/installation/index.mdx +++ b/docs/docs/installation/index.mdx @@ -18,6 +18,12 @@ pip install code-sandboxes Code Sandboxes supports different execution backends via extras: ```bash +# With Cloudflare variant support (an HTTP client for the sandbox bridge) +pip install code-sandboxes[cloudflare] + +# With CoreWeave variant support +pip install code-sandboxes[coreweave] + # With Datalayer variant support pip install code-sandboxes[datalayer] @@ -27,6 +33,9 @@ pip install code-sandboxes[daytona] # With Docker variant support pip install code-sandboxes[docker] +# With E2B variant support +pip install code-sandboxes[e2b] + # With Kaggle support pip install code-sandboxes[kaggle] @@ -43,10 +52,20 @@ pip install code-sandboxes[all] ## Requirements - Python 3.10 or higher +- For Cloudflare variant: `code-sandboxes[cloudflare]`, a deployed **sandbox + bridge** Worker (`npm create cloudflare -- sandbox-bridge + --template=cloudflare/sandbox-sdk/bridge/worker`), and both + `CLOUDFLARE_SANDBOX_API_URL` and `CLOUDFLARE_SANDBOX_API_KEY`. Cloudflare's + own SDK is a Workers binding, which a Python process cannot hold — the + bridge is what it talks to instead +- For CoreWeave variant: `code-sandboxes[coreweave]` and `CWSANDBOX_API_KEY`, + an access token from the CoreWeave console - For Datalayer variant: valid `DATALAYER_API_KEY` - For Daytona variant: `code-sandboxes[daytona]` and a Daytona API key (`DAYTONA_API_KEY`, from app.daytona.io) - For Docker variant: Docker installed and running +- For E2B variant: `code-sandboxes[e2b]` and an E2B API key (`E2B_API_KEY`, + from e2b.dev) - For Google Colab variant: a Colab runtime assignment (server URL, kernel id, proxy token) - For Kaggle variant: `code-sandboxes[kaggle]` and Kaggle credentials (for batch mode) or runtime connection values (for interactive mode) @@ -67,6 +86,12 @@ pip install code-sandboxes[all] | `DAYTONA_API_KEY` | Daytona API key (Daytona variant) | | `DAYTONA_API_URL` | Daytona API URL (optional, defaults to app.daytona.io) | | `DAYTONA_TARGET` | Daytona region (optional) | +| `E2B_API_KEY` | E2B API key (E2B variant) | +| `E2B_DOMAIN` | E2B domain (optional, for a self-hosted cluster; defaults to e2b.dev) | +| `CWSANDBOX_API_KEY` | CoreWeave access token (CoreWeave variant) | +| `CWSANDBOX_BASE_URL` | CoreWeave control plane (optional, defaults to api.cwsandbox.com) | +| `CLOUDFLARE_SANDBOX_API_URL` | Where the deployed sandbox bridge Worker answers (Cloudflare variant) | +| `CLOUDFLARE_SANDBOX_API_KEY` | The key that bridge was deployed with (Cloudflare variant) | ### Programmatic Configuration diff --git a/docs/docs/sandboxes/cloudflare.mdx b/docs/docs/sandboxes/cloudflare.mdx new file mode 100644 index 0000000..fe22066 --- /dev/null +++ b/docs/docs/sandboxes/cloudflare.mdx @@ -0,0 +1,139 @@ +--- +sidebar_position: 2 +title: Cloudflare +--- + +# Cloudflare + +Runs code in a [Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/) +— a container on Cloudflare's edge, started next to the Worker that owns it. + +Cloudflare's own SDK is a **Workers binding** written in TypeScript, and a +Python process cannot hold one: `getSandbox(env.Sandbox, id)` only means +something inside a Worker. What a Python process can talk to is the **sandbox +bridge**, a small reference-implementation Worker Cloudflare publishes that +exposes the SDK over HTTP, and that is what this variant drives. It is deployed +once per account; see [below](#deploying-the-bridge). + +State does **not** persist between calls. The bridge's exec starts a process +and streams its output back, and gives nothing to write to its stdin, so a +namespace cannot be held open between snippets the way the +[CoreWeave](./coreweave) and [Modal](./modal) variants hold one — each snippet +runs in a process of its own: + +```python +sandbox.run_code("x = 40") +sandbox.run_code("x + 2").code_error.name # "NameError" +``` + +- **Requirements:** `code-sandboxes[cloudflare]` (installs `httpx`), and a + deployed sandbox bridge Worker. +- **Parameters:** `api_url`, `api_key`, `python_executable`, `working_dir`. + +## Deploying The Bridge + +1. Create the Worker from Cloudflare's template, which deploys it and generates + the key it will accept: + ```bash + npm create cloudflare -- sandbox-bridge \ + --template=cloudflare/sandbox-sdk/bridge/worker + ``` + It answers with a URL of the shape + `https://cloudflare-sandbox-bridge..workers.dev` and a + `SANDBOX_API_KEY` secret. +2. Export both — this variant needs **both**, since a bridge on the public + internet with no key is a sandbox anybody may run code in: + ```bash + export CLOUDFLARE_SANDBOX_API_URL="https://cloudflare-sandbox-bridge.example.workers.dev" + export CLOUDFLARE_SANDBOX_API_KEY="..." + ``` + +Either can be passed to the sandbox instead of exported: + +```python +Sandbox.create(variant="cloudflare", api_url="https://...workers.dev", api_key="...") +``` + +The key travels as a bearer token. A bridge that refuses it says so with the +reason rather than with a bare HTTP code — a 401 means the key is wrong, and +the message names the variable to check. + +The bridge documentation is at +[developers.cloudflare.com/sandbox/bridge](https://developers.cloudflare.com/sandbox/bridge/). + +## Usage + +```python +from code_sandboxes import Sandbox + +with Sandbox.create(variant="cloudflare") as sandbox: + result = sandbox.run_code("import sys; sys.version_info[:2]") + print(result.text) # "(3, 11)" +``` + +Snippets run in `/workspace` unless `working_dir=` says otherwise. + +### Carrying State Between Calls + +Since each snippet is its own process, anything that has to survive a call has +to be written down somewhere both processes can see. There are two ways, and +neither is a workaround for a bug — it is what a stateless runner means. + +Put the statements that share state in **one snippet**: + +```python +sandbox.run_code(""" +x = 40 +x + 2 +""").text # "42" +``` + +Or keep the state in a **file**. The filesystem of the sandbox does persist +between calls: + +```python +sandbox.files.write("/workspace/state.json", '{"x": 40}') +sandbox.run_code( + "import json; json.load(open('/workspace/state.json'))['x'] + 2" +).text # "42" +``` + +The same fact is what `set_variable` refuses to pretend about: a variable set +from outside would be gone before the next snippet could read it, so the call +explains that instead of quietly doing nothing. + +### Files + +Files go straight to the bridge's filesystem endpoints rather than through a +program that decodes them, so a large file does not have to become a large +snippet: + +```python +sandbox.files.write_bytes("/workspace/data.parquet", payload) +sandbox.files.read_bytes("/workspace/data.parquet") +``` + +### Management + +`create`, `get` and `delete` are answered; `list` is **not**. The bridge +exposes a sandbox by its id and has no endpoint that enumerates them, so +`code-sandboxes list -v cloudflare` raises with that reason rather than +answering with an empty list — "none" and "cannot know" are different facts, +and a management tool that confuses them loses sandboxes. See the +[management guide](/cli/management). + +## What Is Not Reported + +Each snippet is run by a small program that captures the streams and answers +with one JSON line, so: + +- The value of a trailing expression **is** reported — it is evaluated and its + `repr` carried back on that line, which is what makes `result.text` work + above. +- Rich display data — a figure, an HTML repr, a PNG — has no channel at all and + is **not** returned. `result.results` only ever holds the text value. +- There is no interrupt: `sandbox.interrupt()` answers `False`, and a runaway + execution is stopped by its timeout. +- Reading a variable (`get_variable`, and `sandbox.commands.run` which is built + on it) can only see the snippet it is part of. There is no session to read + from between calls. diff --git a/docs/docs/sandboxes/coreweave.mdx b/docs/docs/sandboxes/coreweave.mdx new file mode 100644 index 0000000..767fee6 --- /dev/null +++ b/docs/docs/sandboxes/coreweave.mdx @@ -0,0 +1,180 @@ +--- +sidebar_position: 3 +title: CoreWeave +--- + +# CoreWeave + +Runs code in a +[CoreWeave Sandbox](https://www.coreweave.com/products/coreweave-sandboxes) — a +container on CoreWeave's own GPU cloud, placed on a managed runner and +addressed through the `cwsandbox` SDK. + +State **persists** between calls, though CoreWeave itself offers nothing that +holds a Python namespace: what it offers is a container and `exec`, a process +at a time. So the variant holds one itself. A single `python -u -c` process is +started with the sandbox and fed JSON lines on stdin, one request and one reply +each — the same arrangement the [Modal](./modal) variant uses, and for the same +reason: + +```python +sandbox.run_code("x = 40") +sandbox.run_code("x + 2").text # "42" +``` + +A session process that cannot be started, or that goes away mid-session, drops +the sandbox back to a process per snippet — working, merely stateless — rather +than failing the run. Which of the two you got is recorded on the sandbox, so a +caller who depends on state can ask rather than assume: + +```python +sandbox.info.metadata["stateful"] # True when the session process is there +``` + +Pass `stateful=False` to turn the session process off and run every snippet on +its own. + +- **Requirements:** `code-sandboxes[coreweave]` (installs `cwsandbox`). +- **Parameters:** `api_key`, `base_url`, `container_image`, `profile_names`, + `runner_ids`, `python_executable`, `stateful`. + +## How To Obtain CoreWeave Credentials + +1. Create an access token in the CoreWeave console and export it: + ```bash + export CWSANDBOX_API_KEY="..." + ``` + It travels as a bearer token. +2. Optionally point at another control plane: + ```bash + export CWSANDBOX_BASE_URL="https://api.cwsandbox.com" # the default + ``` + +Both can be passed to the sandbox instead of exported. The SDK authenticates +from the environment and takes no token argument, so passing one here sets the +variable for this process: + +```python +Sandbox.create(variant="coreweave", api_key="...", base_url="https://api.cwsandbox.com") +``` + +The product documentation is at +[docs.coreweave.com/products/sandboxes](https://docs.coreweave.com/products/sandboxes). + +## Usage + +```python +from code_sandboxes import Sandbox + +with Sandbox.create(variant="coreweave") as sandbox: + result = sandbox.run_code("import sys; sys.version_info[:2]") + print(result.text) # "(3, 11)" +``` + +The container is `python:3.11` unless `container_image=` names another. An +image that carries what the code needs saves installing it on every run: + +```python +Sandbox.create(variant="coreweave", container_image="my-registry/analysis:2026-08") +``` + +### GPUs + +CoreWeave is a GPU cloud, and a GPU is asked for the way it is everywhere else +in this package: + +```python +Sandbox.create(variant="coreweave", gpu="H100") +``` + +CoreWeave places a sandbox on a runner of one kind and has no fallback list of +its own, so a comma-separated list is read as the first name in it rather than +as preferences to fall back along. The image has to carry what the code needs +to use the card — `python:3.11` carries nothing of CUDA. + +### CPU and memory + +`cpu` and `memory` become Kubernetes quantities, sent as both the requests and +the limits of the container — cores as a whole number, memory rounded up to +whole gibibytes: + +```python +Sandbox.create(variant="coreweave", cpu=2.0, memory=4096) +``` + +Left out, CoreWeave's own defaults for the runner apply. + +### Network policy + +The policy of the configuration becomes CoreWeave's own network options: + +```python +# No egress and no ingress at all. +Sandbox.create(variant="coreweave", network_policy="none") + +# Only these hosts, as egress rules — naming what may be reached denies the rest. +Sandbox.create( + variant="coreweave", + network_policy="allowlist", + allowed_hosts=["pypi.org", "files.pythonhosted.org"], +) +``` + +`network_policy="allowlist"` with no `allowed_hosts` is refused with the +reason: a sandbox allowed nothing is a sandbox with no network at all, which is +`network_policy="none"` and should say so. + +### Sandbox profiles and runners + +`profile_names` names CoreWeave's own policy objects — what a sandbox is +allowed to do — and `runner_ids` pins it to particular managed runners. Both +are left to CoreWeave when omitted, which is the usual case: + +```python +Sandbox.create(variant="coreweave", profile_names=["restricted"]) +``` + +### Names and tags + +CoreWeave keeps tags as a flat list of strings rather than as a map, so a pair +is written `key=value`. That is how the name and the tags of the configuration +survive the crossing and can be read back by `list`, next to +`created-by=code-sandboxes`, which is what tells the sandboxes this package +made from the rest of the organization's: + +```python +Sandbox.create(variant="coreweave", name="nightly-report", tags={"team": "ai"}) +``` + +`code-sandboxes list -v coreweave` shows what is there; see the +[management guide](/cli/management). + +## What Is Not Reported + +The session process answers with what the code printed, the value of its +trailing expression, and the error it raised. There is no channel for anything +else, so: + +- The value of a trailing expression **is** reported — it is evaluated and its + `repr` carried back in the reply, which is what makes `result.text` work + above. +- Rich display data — a figure, an HTML repr, a PNG — has no channel at all and + is **not** returned. `result.results` only ever holds the text value. +- There is no interrupt: `sandbox.interrupt()` answers `False`, and a runaway + execution is stopped by its timeout. +- Contexts do not isolate: there is one session process and therefore one + namespace. `create_context()` gives a context back, but two of them see each + other's variables. + +Variables cross as JSON (`get_variable`, `set_variable`, and everything built +on them such as `sandbox.commands.run`), so a value that cannot be encoded +comes back as its `repr` rather than as the object itself, and one that cannot +be *sent* is refused with a reason. + +Binary files go straight to CoreWeave's filesystem API rather than through a +program that decodes them: + +```python +sandbox.files.write_bytes("/tmp/data.parquet", payload) +sandbox.files.read_bytes("/tmp/data.parquet") +``` diff --git a/docs/docs/sandboxes/datalayer.mdx b/docs/docs/sandboxes/datalayer.mdx index c11478a..6f8e3f8 100644 --- a/docs/docs/sandboxes/datalayer.mdx +++ b/docs/docs/sandboxes/datalayer.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 2 +sidebar_position: 4 title: Datalayer --- diff --git a/docs/docs/sandboxes/daytona.mdx b/docs/docs/sandboxes/daytona.mdx index 7c91723..1daafc4 100644 --- a/docs/docs/sandboxes/daytona.mdx +++ b/docs/docs/sandboxes/daytona.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 3 +sidebar_position: 5 title: Daytona --- diff --git a/docs/docs/sandboxes/docker.mdx b/docs/docs/sandboxes/docker.mdx index e99e037..07a6929 100644 --- a/docs/docs/sandboxes/docker.mdx +++ b/docs/docs/sandboxes/docker.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 6 title: Docker --- diff --git a/docs/docs/sandboxes/e2b.mdx b/docs/docs/sandboxes/e2b.mdx new file mode 100644 index 0000000..eeb2ad2 --- /dev/null +++ b/docs/docs/sandboxes/e2b.mdx @@ -0,0 +1,188 @@ +--- +sidebar_position: 7 +title: E2B +--- + +# E2B + +Runs code in an [E2B](https://e2b.dev) sandbox — a Firecracker microVM that +starts in about 150 ms. + +The variant drives E2B through its **code interpreter** SDK +(`e2b-code-interpreter`) rather than through the plain `e2b` SDK, which is what +makes it behave like the rest of this package: the interpreter keeps a Jupyter +kernel per context, so state **persists** between calls, and rich display data +arrives as results rather than being lost. + +```python +sandbox.run_code("x = 40") +sandbox.run_code("x + 2").text # "42" +``` + +- **Requirements:** `code-sandboxes[e2b]` (installs `e2b-code-interpreter`). +- **Parameters:** `api_key`, `domain`, `template`, `allow_internet_access`, + `secure`. + +## How To Obtain E2B Credentials + +1. Sign in at [e2b.dev](https://e2b.dev). +2. Create an API key and export it: + ```bash + export E2B_API_KEY="e2b_..." + ``` +3. Optionally point at a self-hosted cluster rather than at e2b.dev: + ```bash + export E2B_DOMAIN="e2b.example.com" + ``` + +Either can be passed to the sandbox instead of exported. What is left out is +read from the environment by the E2B SDK, so passing nothing is not the same as +passing `None`: + +```python +Sandbox.create(variant="e2b", api_key="e2b_...", domain="e2b.example.com") +``` + +The SDK documentation is at [docs.e2b.dev](https://docs.e2b.dev). + +## Usage + +```python +from code_sandboxes import Sandbox + +with Sandbox.create(variant="e2b") as sandbox: + result = sandbox.run_code("import numpy as np; np.arange(5).sum()") + print(result.text) # "10" +``` + +### Templates + +E2B takes a **template** per sandbox — an image with its packages already +installed — rather than a catalogue of machines. Here it is always +`code-interpreter-v1`, E2B's own interpreter template, unless `template=` names +one of your own: + +```python +Sandbox.create(variant="e2b", template="my-team-template") +``` + +A template named here has to be built **on top of** the interpreter's. This +variant drives the code interpreter SDK, which talks to a Jupyter kernel inside +the sandbox, and only a template carrying such a kernel can answer it. `base` — +the default of the plain `e2b` SDK, where there is no kernel — would start a +sandbox that then failed every execution, which is why it is not what you get +here. + +For the same reason `Sandbox.list_environments(variant="e2b")` ships **one** +environment, `e2b-code-interpreter`, rather than a menu including images that +cannot serve the interpreter. It is what `code-sandboxes environments` shows. + +### Contexts + +The default namespace is shared by every call. `create_context()` asks E2B for +a kernel it keeps apart, so two pieces of work can run in the same sandbox +without seeing each other's variables: + +```python +with Sandbox.create(variant="e2b") as sandbox: + other = sandbox.create_context() + + sandbox.run_code("secret = 1") + sandbox.run_code("secret", context=other).code_error.name # "NameError" +``` + +A context is created on first use, since creating one starts a kernel and most +callers never need a second. + +### Rich Outputs + +A kernel has a channel for display data that a process writing to stdout has +not, so what the code displays comes back as a result keyed by MIME type — a +figure as `image/png`, an HTML repr as `text/html`: + +```python +result = sandbox.run_code(""" +import matplotlib.pyplot as plt +plt.plot([1, 2, 3]) +plt.show() +""") +figure = next(item for item in result.results if item.png) +figure.png # base64 PNG +``` + +E2B names each format with an attribute of its own (`png`, `html`, …) where +this package keys them by MIME type; the translation happens here, including +for a custom MIME type a library declares. + +### Timeouts + +E2B takes a sandbox down when its timeout runs out, whatever it is doing. A +long-running job therefore has to say so before it starts, and `set_timeout` +is how — the count restarts at the moment of the call: + +```python +sandbox.set_timeout(600) # ten more minutes, from now +``` + +`max_lifetime` on the configuration says the same thing at creation, in whole +seconds. + +### Reaching A Service Inside + +Every port inside has a public hostname of its own, which is what makes a +server started in the sandbox — a dashboard, an API under test — reachable +from outside without a tunnel of your own: + +```python +sandbox.run_code("import subprocess; subprocess.Popen(['python', '-m', 'http.server', '8000'])") +print(f"https://{sandbox.get_host(8000)}") +``` + +### Network policy + +E2B offers one switch rather than a list of hosts: + +```python +# No outbound network at all. +Sandbox.create(variant="e2b", network_policy="none") +``` + +`network_policy="allowlist"` is **refused** with the reason: E2B has no host +allowlist, and silently widening a policy that names hosts to the whole +internet is the failure that matters. Use `"none"` to cut the network off, or +`"all"` to allow it. `allow_internet_access=False` says the same thing as +`"none"` for a caller who would rather pass it as an argument. + +`secure=True` — the default here as in the SDK — keeps E2B's own hardening of +the sandbox's control plane. + +### Names and metadata + +E2B keeps a flat map of strings per sandbox and lets one be looked up by it, so +the name and the tags of the configuration travel there, next to +`created-by=code-sandboxes` — which is what tells the sandboxes this package +made from the rest of the account's: + +```python +Sandbox.create(variant="e2b", name="nightly-report", tags={"team": "ai"}) +``` + +`code-sandboxes list -v e2b` shows what is there; see the +[management guide](/cli/management). + +## What Is Not Reported + +- There is no interrupt: `sandbox.interrupt()` answers `False`, and a runaway + execution is stopped by its timeout. +- Variables cross as JSON (`get_variable`, `set_variable`, and everything built + on them such as `sandbox.commands.run`), so a value that cannot be encoded + comes back as its `repr` rather than as the object itself, and one that + cannot be *sent* is refused with a reason. + +Binary files go straight to E2B's filesystem API rather than through a program +that decodes them, so a large file does not have to become a large program: + +```python +sandbox.files.write_bytes("/tmp/data.parquet", payload) +sandbox.files.read_bytes("/tmp/data.parquet") +``` diff --git a/docs/docs/sandboxes/eval.mdx b/docs/docs/sandboxes/eval.mdx index eb12ac7..e02b2d7 100644 --- a/docs/docs/sandboxes/eval.mdx +++ b/docs/docs/sandboxes/eval.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 5 +sidebar_position: 8 title: Eval --- diff --git a/docs/docs/sandboxes/google-colab.mdx b/docs/docs/sandboxes/google-colab.mdx index c81be3c..547a5ad 100644 --- a/docs/docs/sandboxes/google-colab.mdx +++ b/docs/docs/sandboxes/google-colab.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 9 title: Google Colab --- diff --git a/docs/docs/sandboxes/index.mdx b/docs/docs/sandboxes/index.mdx index 24e39bf..ea052a5 100644 --- a/docs/docs/sandboxes/index.mdx +++ b/docs/docs/sandboxes/index.mdx @@ -11,9 +11,9 @@ 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 `datalayer`, `daytona`, `docker`, `eval`, -`google-colab`, `jupyter-server`, `kaggle`, `modal`, and `monty`. Older `local-*` -names are no longer supported. +Canonical variant names are `cloudflare`, `coreweave`, `datalayer`, +`daytona`, `docker`, `e2b`, `eval`, `google-colab`, `jupyter-server`, `kaggle`, +`modal`, and `monty`. Older `local-*` names are no longer supported. A name is read in whatever spelling it arrives in: `google-colab`, `google_colab` and `Google Colab` all name the same variant. @@ -64,9 +64,12 @@ Each page below explains how to configure each variant. | Variant | Summary | |---------|---------| +| [`cloudflare`](./cloudflare) | Container on Cloudflare's edge, through a deployed sandbox bridge Worker | +| [`coreweave`](./coreweave) | Container on CoreWeave with a session process and an optional GPU | | [`datalayer`](./datalayer) | Datalayer managed runtime with optional GPU | | [`daytona`](./daytona) | Daytona cloud sandbox with a stateful interpreter | | [`docker`](./docker) | Jupyter execution in a Docker container | +| [`e2b`](./e2b) | E2B Firecracker microVM with a Jupyter kernel and rich outputs | | [`eval`](./eval) | In-process `exec()` for fast development-only runs | | [`google-colab`](./google-colab) | Google Colab runtime via runtime proxy | | [`jupyter-server`](./jupyter-server) | Jupyter kernel-backed execution with persistent state | @@ -150,7 +153,7 @@ value * 2 ## State Persistence -All sandbox variants keep state (variables, imports, and definitions) +Most sandbox variants keep state (variables, imports, and definitions) within the same sandbox instance. For example: ```python @@ -161,6 +164,12 @@ with Sandbox.create() as sandbox: print(result.text) # 2 ``` +What a variant cannot do is hold a namespace open when its backend gives it +nothing to hold one with. [`cloudflare`](./cloudflare) runs each snippet in a +process of its own for that reason, and its page says what to do instead; +[`coreweave`](./coreweave) keeps a session process and falls back to the same +arrangement when that process cannot be started. + ### Streaming Output ```python diff --git a/docs/docs/sandboxes/jupyter-server.mdx b/docs/docs/sandboxes/jupyter-server.mdx index ee9d948..c25902a 100644 --- a/docs/docs/sandboxes/jupyter-server.mdx +++ b/docs/docs/sandboxes/jupyter-server.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 10 title: Jupyter --- diff --git a/docs/docs/sandboxes/kaggle.mdx b/docs/docs/sandboxes/kaggle.mdx index 360f3b8..40ce35e 100644 --- a/docs/docs/sandboxes/kaggle.mdx +++ b/docs/docs/sandboxes/kaggle.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 8 +sidebar_position: 11 title: Kaggle --- diff --git a/docs/docs/sandboxes/modal.mdx b/docs/docs/sandboxes/modal.mdx index 4133ab4..96d7b98 100644 --- a/docs/docs/sandboxes/modal.mdx +++ b/docs/docs/sandboxes/modal.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 9 +sidebar_position: 12 title: Modal --- @@ -8,9 +8,13 @@ title: 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. +State **persists** between calls, though Modal itself offers nothing that holds +a Python namespace: what it offers is a container and `exec`, a process at a +time. So the variant holds one itself — a single `python -u -c` session process +is started with the sandbox and fed one snippet at a time. A session process +that cannot be started, or that goes away mid-session, drops the sandbox back +to a process per snippet — working, merely stateless — rather than failing the +run. 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`, diff --git a/docs/docs/sandboxes/monty.mdx b/docs/docs/sandboxes/monty.mdx index 64b7549..b13f75d 100644 --- a/docs/docs/sandboxes/monty.mdx +++ b/docs/docs/sandboxes/monty.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 10 +sidebar_position: 13 title: Monty --- diff --git a/examples/README.md b/examples/README.md index 91d5397..ce9b5f1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,9 +23,12 @@ example here: it shows how to use a sandbox, not how to print things. Supported sandbox variants: +- `cloudflare` +- `coreweave` - `datalayer` - `daytona` - `docker` +- `e2b` - `eval` - `google-colab` - `jupyter-server` @@ -37,9 +40,12 @@ Run one-shot examples from `examples/exec/`: ```bash cd exec +python cloudflare_sandbox_example.py +python coreweave_sandbox_example.py python datalayer_sandbox_example.py python daytona_sandbox_example.py python docker_sandbox_example.py +python e2b_sandbox_example.py python eval_sandbox_example.py python google_colab_sandbox_example.py python jupyter_server_sandbox_example.py @@ -52,9 +58,12 @@ Or run one-shot examples via Make targets: ```bash cd exec +make cloudflare +make coreweave make datalayer make daytona make docker +make e2b make eval make google-colab make jupyter-server @@ -67,9 +76,12 @@ Run REPL examples from `examples/repl/`: ```bash cd repl +make cloudflare +make coreweave make datalayer make daytona make docker +make e2b make eval make google-colab make jupyter-server @@ -80,10 +92,20 @@ make monty Notes by variant: +- `cloudflare`: requires `code-sandboxes[cloudflare]` and a deployed sandbox + bridge Worker — `npm create cloudflare -- sandbox-bridge + --template=cloudflare/sandbox-sdk/bridge/worker` — named by + `CLOUDFLARE_SANDBOX_API_URL` with the `CLOUDFLARE_SANDBOX_API_KEY` it + generated. Each snippet runs in a process of its own, so nothing crosses + between them. +- `coreweave`: requires `code-sandboxes[coreweave]` and `CWSANDBOX_API_KEY` + (`CWSANDBOX_BASE_URL` for another control plane). - `datalayer`: requires Datalayer runtime credentials/config. - `daytona`: requires `code-sandboxes[daytona]` and `DAYTONA_API_KEY` (or `DAYTONA_JWT_TOKEN` with `DAYTONA_ORGANIZATION_ID`). - `docker`: requires Docker support and a Docker image (for example `code-sandboxes-jupyter:latest`). +- `e2b`: requires `code-sandboxes[e2b]` and `E2B_API_KEY` (`E2B_DOMAIN` for a + self-hosted cluster). - `google-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`. diff --git a/examples/exec/Makefile b/examples/exec/Makefile index c8f56a3..cd3f01a 100644 --- a/examples/exec/Makefile +++ b/examples/exec/Makefile @@ -2,9 +2,9 @@ PYTHON ?= python -.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu modal modal-gpu daytona daytona-gpu daytona-gpu-spot datalayer +.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu modal modal-gpu daytona daytona-gpu daytona-gpu-spot datalayer e2b coreweave coreweave-gpu cloudflare -all: eval docker jupyter-server monty google-colab modal daytona datalayer +all: eval docker jupyter-server monty google-colab modal daytona datalayer e2b coreweave cloudflare eval: $(PYTHON) eval_sandbox_example.py @@ -64,3 +64,28 @@ daytona-gpu-spot: ## Daytona example on a SPOT GPU (DAYTONA_GPU flavor, default datalayer: $(PYTHON) datalayer_sandbox_example.py + +e2b: ## E2B example (Firecracker microVM with a Jupyter kernel) + @echo "==> E2B example" + @echo " auth: E2B_API_KEY, and E2B_DOMAIN for a self-hosted cluster" + @echo " The kernel holds one namespace, so snippets share their state." + $(PYTHON) e2b_sandbox_example.py + +coreweave: ## CoreWeave example (CPU) + @echo "==> CoreWeave example (CPU)" + @echo " auth: CWSANDBOX_API_KEY, and CWSANDBOX_BASE_URL for another control plane" + $(PYTHON) coreweave_sandbox_example.py + +coreweave-gpu: ## CoreWeave example on a GPU (COREWEAVE_GPU flavor, default H100) + @echo "==> CoreWeave example (GPU: $${COREWEAVE_GPU:-H100})" + @echo " The run fails unless nvidia-smi lists a device in the sandbox." + @echo " COREWEAVE_IMAGE names the image, which has to carry what the code needs." + COREWEAVE_GPU=$${COREWEAVE_GPU:-H100} $(PYTHON) coreweave_sandbox_example.py --gpu "$${COREWEAVE_GPU:-H100}" + +cloudflare: ## Cloudflare example (through a deployed sandbox bridge Worker) + @echo "==> Cloudflare example" + @echo " auth: CLOUDFLARE_SANDBOX_API_URL and CLOUDFLARE_SANDBOX_API_KEY" + @echo " Deploy the bridge once: npm create cloudflare -- sandbox-bridge \\" + @echo " --template=cloudflare/sandbox-sdk/bridge/worker" + @echo " Each snippet runs in a process of its own: nothing crosses between them." + $(PYTHON) cloudflare_sandbox_example.py diff --git a/examples/exec/cloudflare_sandbox_example.py b/examples/exec/cloudflare_sandbox_example.py new file mode 100644 index 0000000..3a8d929 --- /dev/null +++ b/examples/exec/cloudflare_sandbox_example.py @@ -0,0 +1,100 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""Example: cloudflare sandbox (container on Cloudflare, through the bridge). + +Run with: + python examples/exec/cloudflare_sandbox_example.py + +Auth: +- Cloudflare's own SDK is a Workers binding written in TypeScript, and a Python + process cannot hold one. What a Python process can talk to is the SANDBOX + BRIDGE — a reference-implementation Worker Cloudflare publishes, which + exposes the SDK over HTTP. Deploy it once: + + npm create cloudflare -- sandbox-bridge \\ + --template=cloudflare/sandbox-sdk/bridge/worker + +- export CLOUDFLARE_SANDBOX_API_URL (where the Worker answers, e.g. + https://cloudflare-sandbox-bridge..workers.dev) and + CLOUDFLARE_SANDBOX_API_KEY (the secret it generated). + +The bridge's exec gives no stdin, so each snippet runs in a process of its own +and `x = 1` is gone by the next call. That is not worked around here, it is +SHOWN — along with the two ways round it: put the statements that share state +in one snippet, or keep the state in a file, since the filesystem persists. +""" + +import os + +from code_sandboxes import Sandbox, show_and_run + +BRIDGE_DEPLOY_COMMAND = ( + "npm create cloudflare -- sandbox-bridge --template=cloudflare/sandbox-sdk/bridge/worker" +) + + +def main() -> None: + if not os.environ.get("CLOUDFLARE_SANDBOX_API_URL") or not os.environ.get( + "CLOUDFLARE_SANDBOX_API_KEY" + ): + print("Cloudflare sandbox bridge settings not found.") + print("Set CLOUDFLARE_SANDBOX_API_URL and CLOUDFLARE_SANDBOX_API_KEY.") + print("Deploy a bridge Worker with:") + print(f" {BRIDGE_DEPLOY_COMMAND}") + print("See https://developers.cloudflare.com/sandbox/bridge/.") + raise SystemExit(1) + + print("Launching cloudflare sandbox through the bridge at") + print(f" {os.environ['CLOUDFLARE_SANDBOX_API_URL']}") + + try: + with Sandbox.create(variant="cloudflare", timeout=60) as sandbox: + print(f"Sandbox: {sandbox.sandbox_id}") + + show_and_run(sandbox, "import sys; print(sys.version.split()[0])") + + # STATELESS, and demonstrated rather than described: the second + # snippet is a new process, and does not know what the first one + # defined. + show_and_run(sandbox, "x = 40") + gone = show_and_run(sandbox, "x + 2") + if gone.code_error is None or gone.code_error.name != "NameError": + raise RuntimeError( + "A snippet saw the namespace of the one before it, which " + "the bridge does not allow — this example is out of date." + ) + print("as expected: nothing crossed between snippets.") + + # The first way round it: whatever shares state shares a snippet. + together = show_and_run(sandbox, "x = 40\nx + 2") + if together.text != "42": + raise RuntimeError(f"One snippet did not answer with its value: {together.text!r}.") + print("one snippet: the statements that share state ran together.") + + # The second: the filesystem of the sandbox does persist, so state + # that has to outlive a call goes there rather than in a variable. + sandbox.files.write("/workspace/state.json", '{"x": 40}') + kept = show_and_run( + sandbox, + "import json; json.load(open('/workspace/state.json'))['x'] + 2", + ) + if kept.text != "42": + raise RuntimeError(f"The state file did not survive: {kept.text!r}.") + print("a file: the state crossed the call on the filesystem.") + + # The error path, demonstrated ON PURPOSE: the run must not die, + # the failure must come back as a `code_error` on the result. Said + # before it happens, or the example's last lines read as a crash. + print("-- error handling: the next snippet raises deliberately --") + error_result = show_and_run(sandbox, "raise RuntimeError('cloudflare failure example')") + if error_result.code_error is None: + raise RuntimeError("The deliberate failure did not surface as a code_error.") + print("error captured as expected — cloudflare example completed.") + except Exception as exc: + print("cloudflare example failed:", exc) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() diff --git a/examples/exec/coreweave_sandbox_example.py b/examples/exec/coreweave_sandbox_example.py new file mode 100644 index 0000000..cbfdeee --- /dev/null +++ b/examples/exec/coreweave_sandbox_example.py @@ -0,0 +1,140 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""Example: coreweave sandbox (container on CoreWeave, optionally on a GPU). + +Run with: + python examples/exec/coreweave_sandbox_example.py + +Auth: +- create an access token in the CoreWeave console and export CWSANDBOX_API_KEY. +- export CWSANDBOX_BASE_URL to talk to another control plane than + https://api.cwsandbox.com. + +GPU options: +- pass `--gpu H100`, or set COREWEAVE_GPU in the environment. +- a GPU is a machine specification, and the image has to carry what the code + needs — `--image` says which one to run. + +CoreWeave offers a container and `exec`, a process at a time, and nothing that +holds a Python namespace between calls. So the variant holds one itself: a +single session process is started with the sandbox and fed one snippet at a +time. A driver that cannot start, or that goes away mid-session, drops the +sandbox back to a process per snippet — working, merely stateless — which is +why the state check below asks the sandbox which of the two it got. +""" + +import argparse +import os + +from code_sandboxes import Sandbox, show_and_run + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the coreweave sandbox example.") + parser.add_argument( + "--gpu", + default=os.environ.get("COREWEAVE_GPU"), + help="Optional GPU (for example: H100, H200, A100).", + ) + parser.add_argument( + "--image", + default=os.environ.get("COREWEAVE_IMAGE"), + help="The container image the sandbox runs. `python:3.11` unless given.", + ) + return parser.parse_args() + + +def _gpu_probe_code() -> str: + """Code that PROVES a GPU, not merely looks for one. + + Prints one `GPU-PROBE:` line per fact so the caller can assert on them: + the driver must be there, and must list at least one device. + """ + return """ +import shutil +import subprocess + +smi = shutil.which("nvidia-smi") +print("GPU-PROBE: nvidia-smi", "present" if smi else "MISSING") +if smi: + listing = subprocess.run( + ["nvidia-smi", "-L"], check=False, capture_output=True, text=True + ).stdout.strip() + print("GPU-PROBE: devices", listing or "NONE") +""" + + +def _verify_gpu(sandbox, gpu: str) -> None: + """Prove the GPU is there rather than take the request for an answer.""" + probe = show_and_run(sandbox, _gpu_probe_code()).stdout.strip() + if "nvidia-smi present" not in probe: + raise RuntimeError("GPU requested but nvidia-smi is missing in the sandbox.") + if "devices NONE" in probe or "GPU-PROBE: devices" not in probe: + raise RuntimeError("GPU requested but no device is listed by nvidia-smi.") + print(f"GPU verified: {gpu} is present.") + + +def main() -> None: + args = _parse_args() + if not os.environ.get("CWSANDBOX_API_KEY"): + print("CoreWeave auth not found.") + print("Set CWSANDBOX_API_KEY to an access token from the CoreWeave console.") + print("See https://docs.coreweave.com/products/sandboxes.") + raise SystemExit(1) + + if args.gpu: + print(f"Launching coreweave sandbox with GPU: {args.gpu}") + else: + print("Launching coreweave sandbox without GPU.") + + try: + with Sandbox.create( + variant="coreweave", + timeout=60, + gpu=args.gpu, + container_image=args.image, + ) as sandbox: + print(f"Sandbox: {sandbox.sandbox_id}") + + show_and_run(sandbox, "x = 40") + show_and_run(sandbox, "import sys; print(sys.version.split()[0])") + state = show_and_run(sandbox, "x + 2") + + # The sandbox says which arrangement it ended up with, and only a + # session process promises the namespace crosses. Asserting on + # state that was never promised would fail an honest fallback. + info = sandbox.info + stateful = bool(info.metadata.get("stateful")) if info else False + if stateful: + if state.text != "42": + raise RuntimeError( + f"State did not survive between snippets: x + 2 gave {state.text!r}." + ) + print("state verified: the session process shares one namespace.") + else: + print("no session process: each snippet ran on its own, so x did not cross.") + + # Bytes take the filesystem of the container, not a program that + # decodes them. + sandbox.files.write_bytes("/tmp/hello.bin", b"from coreweave") + print("file round trip:", sandbox.files.read_bytes("/tmp/hello.bin")) + + if args.gpu: + _verify_gpu(sandbox, args.gpu) + + # The error path, demonstrated ON PURPOSE: the run must not die, + # the failure must come back as a `code_error` on the result. Said + # before it happens, or the example's last lines read as a crash. + print("-- error handling: the next snippet raises deliberately --") + error_result = show_and_run(sandbox, "raise RuntimeError('coreweave failure example')") + if error_result.code_error is None: + raise RuntimeError("The deliberate failure did not surface as a code_error.") + print("error captured as expected — coreweave example completed.") + except Exception as exc: + print("coreweave example failed:", exc) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() diff --git a/examples/exec/e2b_sandbox_example.py b/examples/exec/e2b_sandbox_example.py new file mode 100644 index 0000000..22bd510 --- /dev/null +++ b/examples/exec/e2b_sandbox_example.py @@ -0,0 +1,107 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""Example: e2b sandbox (Firecracker microVM with a Jupyter kernel). + +Run with: + python examples/exec/e2b_sandbox_example.py + +Auth: +- create an API key at https://e2b.dev and export E2B_API_KEY. +- export E2B_DOMAIN as well to talk to a self-hosted cluster rather than to + e2b.dev. + +The variant drives E2B through its code interpreter SDK, which keeps a Jupyter +kernel per context. So two things hold here that do not hold in a variant +running a process per snippet: definitions persist between snippets, and rich +display data — an HTML repr, a figure — comes back as a result rather than +being lost. +""" + +import argparse +import os + +from code_sandboxes import Sandbox, show_and_run + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the e2b sandbox example.") + parser.add_argument( + "--template", + default=os.environ.get("E2B_TEMPLATE"), + help=( + "The E2B template to create from. `code-interpreter-v1` unless " + "told otherwise — and anything named here has to be built on top " + "of it, since only a template carrying a Jupyter kernel can serve " + "the interpreter this variant drives." + ), + ) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + if not os.environ.get("E2B_API_KEY"): + print("E2B auth not found.") + print("Set E2B_API_KEY. Create a key at https://e2b.dev.") + raise SystemExit(1) + + print(f"Launching e2b sandbox from template: {args.template or 'code-interpreter-v1'}") + + try: + with Sandbox.create(variant="e2b", timeout=60, template=args.template) as sandbox: + print(f"Sandbox: {sandbox.sandbox_id}") + + # What tells this variant apart from a per-snippet runner: the + # kernel holds one namespace, so the second snippet sees what the + # first defined. + show_and_run(sandbox, "x = 40") + show_and_run(sandbox, "import sys; print(sys.version.split()[0])") + state = show_and_run(sandbox, "x + 2") + if state.text != "42": + raise RuntimeError( + f"State did not survive between snippets: x + 2 gave {state.text!r}." + ) + print("state verified: the namespace is shared between snippets.") + + # A kernel has a channel for rich display data, which a process + # writing to stdout has not: what the code displays arrives as a + # result keyed by its MIME type. + rich = show_and_run( + sandbox, + "from IPython.display import HTML\nHTML('from e2b')", + ) + if not any(result.html for result in rich.results): + raise RuntimeError("The HTML repr did not come back as a result.") + print("rich output verified: text/html arrived as a result.") + + # E2B takes a sandbox down when its timeout runs out, whatever it + # is doing — so a long job says so before it starts, and the count + # restarts at the call. + sandbox.set_timeout(300) + print("timeout extended: the sandbox has five minutes from now.") + + # Every port inside has a public host of its own, which is what + # makes a server started in the sandbox reachable without a tunnel. + print("host for port 8000:", sandbox.get_host(8000)) + + # Bytes take the filesystem of the sandbox, not a program that + # decodes them. + sandbox.files.write_bytes("/tmp/hello.bin", b"from e2b") + print("file round trip:", sandbox.files.read_bytes("/tmp/hello.bin")) + + # The error path, demonstrated ON PURPOSE: the run must not die, + # the failure must come back as a `code_error` on the result. Said + # before it happens, or the example's last lines read as a crash. + print("-- error handling: the next snippet raises deliberately --") + error_result = show_and_run(sandbox, "raise RuntimeError('e2b failure example')") + if error_result.code_error is None: + raise RuntimeError("The deliberate failure did not surface as a code_error.") + print("error captured as expected — e2b example completed.") + except Exception as exc: + print("e2b example failed:", exc) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() diff --git a/examples/repl/Makefile b/examples/repl/Makefile index 84b86b9..c3943f5 100644 --- a/examples/repl/Makefile +++ b/examples/repl/Makefile @@ -2,9 +2,9 @@ PYTHON ?= python -.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu kaggle-live kaggle-gpu-live modal modal-gpu daytona daytona-gpu daytona-gpu-spot datalayer +.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu kaggle-live kaggle-gpu-live modal modal-gpu daytona daytona-gpu daytona-gpu-spot datalayer e2b coreweave coreweave-gpu cloudflare -all: eval docker jupyter-server monty google-colab modal daytona datalayer +all: eval docker jupyter-server monty google-colab modal daytona datalayer e2b coreweave cloudflare eval: $(PYTHON) eval_sandbox_example.py @@ -68,3 +68,29 @@ daytona-gpu-spot: ## Daytona REPL on a SPOT GPU (DAYTONA_GPU flavor, default H10 datalayer: $(PYTHON) datalayer_sandbox_example.py + +e2b: ## E2B REPL (Firecracker microVM with a Jupyter kernel) + @echo "==> E2B REPL" + @echo " auth: E2B_API_KEY, and E2B_DOMAIN for a self-hosted cluster" + @echo " Definitions persist between lines: the kernel holds one namespace." + @echo " The sandbox is given five minutes to live; --minutes says otherwise." + $(PYTHON) e2b_sandbox_example.py + +coreweave: ## CoreWeave REPL (CPU) + @echo "==> CoreWeave REPL (CPU)" + @echo " auth: CWSANDBOX_API_KEY, and CWSANDBOX_BASE_URL for another control plane" + @echo " Definitions persist between lines: one session process holds them." + $(PYTHON) coreweave_sandbox_example.py + +coreweave-gpu: ## CoreWeave REPL on a GPU (COREWEAVE_GPU flavor, default H100) + @echo "==> CoreWeave REPL (GPU: $${COREWEAVE_GPU:-H100})" + @echo " COREWEAVE_IMAGE names the image, which has to carry what the code needs." + COREWEAVE_GPU=$${COREWEAVE_GPU:-H100} $(PYTHON) coreweave_sandbox_example.py --gpu "$${COREWEAVE_GPU:-H100}" + +cloudflare: ## Cloudflare REPL (through a deployed sandbox bridge Worker) + @echo "==> Cloudflare REPL" + @echo " auth: CLOUDFLARE_SANDBOX_API_URL and CLOUDFLARE_SANDBOX_API_KEY" + @echo " Deploy the bridge once: npm create cloudflare -- sandbox-bridge \\" + @echo " --template=cloudflare/sandbox-sdk/bridge/worker" + @echo " Each line runs in its own process: definitions do NOT persist." + $(PYTHON) cloudflare_sandbox_example.py diff --git a/examples/repl/cloudflare_sandbox_example.py b/examples/repl/cloudflare_sandbox_example.py new file mode 100644 index 0000000..8910427 --- /dev/null +++ b/examples/repl/cloudflare_sandbox_example.py @@ -0,0 +1,62 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""REPL example: cloudflare sandbox (container on Cloudflare, through the bridge). + +Run with: + python examples/repl/cloudflare_sandbox_example.py + +Auth: +- deploy the sandbox bridge Worker once, since Cloudflare's own SDK is a + Workers binding a Python process cannot hold: + + npm create cloudflare -- sandbox-bridge \\ + --template=cloudflare/sandbox-sdk/bridge/worker + +- export CLOUDFLARE_SANDBOX_API_URL (where the Worker answers) and + CLOUDFLARE_SANDBOX_API_KEY (the secret it generated). + +This prompt is NOT a REPL in the usual sense, and says so before it opens: +each line runs in a process of its own, so `x = 40` on one line leaves nothing +behind for `x + 2` on the next. Write what shares state on a single line, or +keep it in a file — the filesystem of the sandbox does persist. +""" + +import os + +from code_sandboxes import Sandbox, run_repl + +BRIDGE_DEPLOY_COMMAND = ( + "npm create cloudflare -- sandbox-bridge --template=cloudflare/sandbox-sdk/bridge/worker" +) + + +def main() -> None: + if not os.environ.get("CLOUDFLARE_SANDBOX_API_URL") or not os.environ.get( + "CLOUDFLARE_SANDBOX_API_KEY" + ): + print("Cloudflare sandbox bridge settings not found.") + print("Set CLOUDFLARE_SANDBOX_API_URL and CLOUDFLARE_SANDBOX_API_KEY.") + print("Deploy a bridge Worker with:") + print(f" {BRIDGE_DEPLOY_COMMAND}") + print("See https://developers.cloudflare.com/sandbox/bridge/.") + raise SystemExit(1) + + print("Launching cloudflare sandbox REPL through the bridge at") + print(f" {os.environ['CLOUDFLARE_SANDBOX_API_URL']}") + + try: + with Sandbox.create(variant="cloudflare", timeout=60) as sandbox: + print(f"Sandbox: {sandbox.sandbox_id}") + # Said before the prompt opens rather than discovered at the first + # NameError: nothing defined on one line reaches the next. + print("Each line runs in its own process — definitions do NOT persist.") + print("Use one line for what shares state, or a file: `open('/workspace/x', 'w')`.") + run_repl(sandbox) + except Exception as exc: + print("cloudflare REPL failed:", exc) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() diff --git a/examples/repl/coreweave_sandbox_example.py b/examples/repl/coreweave_sandbox_example.py new file mode 100644 index 0000000..b3a7550 --- /dev/null +++ b/examples/repl/coreweave_sandbox_example.py @@ -0,0 +1,74 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""REPL example: coreweave sandbox (container on CoreWeave, optionally on a GPU). + +Run with: + python examples/repl/coreweave_sandbox_example.py + +Auth: +- create an access token in the CoreWeave console and export CWSANDBOX_API_KEY. +- export CWSANDBOX_BASE_URL to talk to another control plane than + https://api.cwsandbox.com. + +Definitions persist between lines because the variant keeps one session process +for the sandbox and feeds it a line at a time. When that process cannot be +started, the sandbox falls back to a process per line — still working, no +longer stateful — and the prompt says so before it opens. +""" + +import argparse +import os + +from code_sandboxes import Sandbox, run_repl + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the coreweave sandbox REPL example.") + parser.add_argument( + "--gpu", + default=os.environ.get("COREWEAVE_GPU"), + help="Optional GPU (for example: H100, H200, A100).", + ) + parser.add_argument( + "--image", + default=os.environ.get("COREWEAVE_IMAGE"), + help="The container image the sandbox runs. `python:3.11` unless given.", + ) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + if not os.environ.get("CWSANDBOX_API_KEY"): + print("CoreWeave auth not found.") + print("Set CWSANDBOX_API_KEY to an access token from the CoreWeave console.") + print("See https://docs.coreweave.com/products/sandboxes.") + raise SystemExit(1) + + if args.gpu: + print(f"Launching coreweave sandbox REPL with GPU: {args.gpu}") + else: + print("Launching coreweave sandbox REPL without GPU.") + + try: + with Sandbox.create( + variant="coreweave", + timeout=60, + gpu=args.gpu, + container_image=args.image, + ) as sandbox: + print(f"Sandbox: {sandbox.sandbox_id}") + info = sandbox.info + if info and info.metadata.get("stateful"): + print("Definitions persist between lines: one session process holds them.") + else: + print("No session process: each line runs on its own, and nothing crosses.") + run_repl(sandbox) + except Exception as exc: + print("coreweave REPL failed:", exc) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() diff --git a/examples/repl/e2b_sandbox_example.py b/examples/repl/e2b_sandbox_example.py new file mode 100644 index 0000000..94e02cd --- /dev/null +++ b/examples/repl/e2b_sandbox_example.py @@ -0,0 +1,72 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""REPL example: e2b sandbox (Firecracker microVM with a Jupyter kernel). + +Run with: + python examples/repl/e2b_sandbox_example.py + +Auth: +- create an API key at https://e2b.dev and export E2B_API_KEY. +- export E2B_DOMAIN as well to talk to a self-hosted cluster rather than to + e2b.dev. + +The prompt behaves as a REPL should: the kernel holds one namespace, so +definitions persist between lines, and a line that is an expression answers +with its value. +""" + +import argparse +import os + +from code_sandboxes import Sandbox, run_repl + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the e2b sandbox REPL example.") + parser.add_argument( + "--template", + default=os.environ.get("E2B_TEMPLATE"), + help=( + "The E2B template to create from. `code-interpreter-v1` unless " + "told otherwise — and anything named here has to be built on top " + "of it, since only a template carrying a Jupyter kernel can serve " + "the interpreter this variant drives." + ), + ) + parser.add_argument( + "--minutes", + type=float, + default=5.0, + help=( + "How long the sandbox may live. E2B takes one down when its " + "timeout runs out, whatever it is doing — including a prompt " + "somebody is still typing at." + ), + ) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + if not os.environ.get("E2B_API_KEY"): + print("E2B auth not found.") + print("Set E2B_API_KEY. Create a key at https://e2b.dev.") + raise SystemExit(1) + + print(f"Launching e2b sandbox REPL from template: {args.template or 'code-interpreter-v1'}") + + try: + with Sandbox.create(variant="e2b", timeout=60, template=args.template) as sandbox: + print(f"Sandbox: {sandbox.sandbox_id}") + # A REPL is read at human speed, and the default life of a sandbox + # is shorter than a session usually is. + sandbox.set_timeout(args.minutes * 60) + run_repl(sandbox) + except Exception as exc: + print("e2b REPL failed:", exc) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 1cdd7fd..8ea13af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,17 +37,23 @@ code-sandbox = "code_sandboxes.cli:main" code-sandboxes = "code_sandboxes.cli:main" [project.optional-dependencies] +cloudflare = ["httpx>=0.27"] +coreweave = ["cwsandbox>=1.6"] datalayer = ["agent_runtimes>=1.0.16"] daytona = ["daytona>=0.100"] docker = ["docker>=6.0"] +e2b = ["e2b-code-interpreter>=2.0"] google-colab = [] kaggle = ["kaggle>=1.6"] monty = ["pydantic-monty"] modal = ["modal>=0.64"] all = [ "agent_runtimes", + "cwsandbox>=1.6", "daytona>=0.100", "docker>=6.0", + "e2b-code-interpreter>=2.0", + "httpx>=0.27", "kaggle>=1.6", "pydantic-monty", "modal>=0.64", @@ -148,3 +154,4 @@ ignore = [ "code_sandboxes/docker_sandbox.py" = ["C901", "S110", "UP007"] "code_sandboxes/eval_sandbox.py" = ["C901", "S102", "S307"] "code_sandboxes/jupyter_sandbox.py" = ["C901", "S110", "S603", "UP007"] +"code_sandboxes/e2b_sandbox.py" = ["C901"] diff --git a/tests/test_cloudflare.py b/tests/test_cloudflare.py new file mode 100644 index 0000000..ce8ac69 --- /dev/null +++ b/tests/test_cloudflare.py @@ -0,0 +1,400 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""The Cloudflare sandbox, against a bridge that really answers. + +The fake below is an HTTP transport, not a mock of the variant's methods: it +speaks the bridge's protocol — a server-sent-event stream of base64 chunks and +a terminal event — and runs the program the variant asks it to run. That is +what is worth testing here, because the protocol is where this variant lives: +everything else is the same `ExecutionResult` as every other variant's. +""" + +from __future__ import annotations + +import base64 +import json +import subprocess +import sys + +import pytest + +from code_sandboxes.base import Sandbox +from code_sandboxes.cloudflare_sandbox import ( + _RUNNER_SOURCE, + API_KEY_ENV_VAR, + API_URL_ENV_VAR, + CloudflareSandbox, + _reply_of, + _sse_events, +) +from code_sandboxes.exceptions import ( + SandboxConfigurationError, + SandboxConnectionError, +) +from code_sandboxes.manage import get_manager, manageable_variants +from code_sandboxes.models import SandboxConfig, SandboxVariant +from code_sandboxes.providers import get_provider + +BRIDGE_URL = "https://bridge.example.workers.dev" + + +def _sse(*events: tuple[str, object]) -> bytes: + """Those events as the bridge would write them on the wire.""" + records = [] + for name, payload in events: + if name in ("stdout", "stderr"): + data = base64.b64encode(str(payload).encode()).decode() + else: + data = json.dumps(payload) + records.append(f"event: {name}\ndata: {data}\n\n") + # A keep-alive comment, which the reader must skip rather than choke on. + return (":\n\n" + "".join(records)).encode() + + +class _FakeBridge: + """The sandbox bridge Worker, answering over httpx's transport hook.""" + + def __init__(self) -> None: + self.sandboxes: list[str] = [] + self.deleted: list[str] = [] + self.files: dict[str, bytes] = {} + self.requests: list[tuple[str, str]] = [] + self.authorization: str | None = None + self.refuse = False + + def handle(self, request): + import httpx + + self.requests.append((request.method, request.url.path)) + self.authorization = request.headers.get("authorization") + if self.refuse: + return httpx.Response(401, text="invalid key") + + path = request.url.path + if request.method == "POST" and path == "/v1/sandbox": + sandbox_id = f"cf-sbx-{len(self.sandboxes) + 1}" + self.sandboxes.append(sandbox_id) + return httpx.Response(200, json={"id": sandbox_id}) + if request.method == "DELETE" and path.count("/") == 3: + self.deleted.append(path.rsplit("/", 1)[-1]) + return httpx.Response(204) + if path.endswith("/running"): + sandbox_id = path.split("/")[3] + running = sandbox_id in self.sandboxes and sandbox_id not in self.deleted + return httpx.Response(200, json={"running": running}) + if path.endswith("/exec"): + return self._exec(httpx, json.loads(request.content)) + if "/file/" in path: + name = path.split("/file/", 1)[1] + if request.method == "PUT": + self.files[name] = request.content + return httpx.Response(200, json={"ok": True}) + if name not in self.files: + return httpx.Response(404, text="no such file") + return httpx.Response(200, content=self.files[name]) + return httpx.Response(404, text=f"no route for {path}") + + def _exec(self, httpx, body): + """Really run the argv, and stream what it wrote.""" + argv = list(body["argv"]) + # The variant asks for `python3`; this machine's Python is the one + # that is actually here. + argv[0] = sys.executable + finished = subprocess.run( # noqa: S603 + argv, capture_output=True, text=True, timeout=60, check=False + ) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_sse( + ("stdout", finished.stdout), + ("stderr", finished.stderr), + ("exit", {"exit_code": finished.returncode}), + ), + ) + + +def _started(bridge: _FakeBridge | None = None, config: SandboxConfig | None = None, **kwargs): + """A sandbox talking to the fake bridge over a real httpx client.""" + import httpx + + bridge = bridge or _FakeBridge() + sandbox = CloudflareSandbox(config=config, api_url=BRIDGE_URL, api_key="cf_key", **kwargs) + # The client is built in `start`; here it is built with the transport that + # answers, so the protocol is exercised end to end. + sandbox._client = httpx.Client( + base_url=BRIDGE_URL, + headers={"Authorization": "Bearer cf_key"}, + transport=httpx.MockTransport(bridge.handle), + ) + response = sandbox._client.post("/v1/sandbox") + sandbox._sandbox_id = response.json()["id"] + sandbox._started = True + sandbox._default_context = sandbox.create_context("default") + sandbox.bridge = bridge # for the assertions + return sandbox + + +pytest.importorskip("httpx") + + +# --- The stream ------------------------------------------------------------ + + +def test_the_events_of_a_stream_are_read_the_way_the_specification_says(): + stream = ( + ":keep-alive\n" + "event: stdout\n" + "data: aGVsbG8=\n" + "\n" + "event: message\n" + "data: one\n" + "data: two\n" + "\n" + "event: exit\n" + 'data: {"exit_code": 0}\n' + "\n" + ) + + events = list(_sse_events(iter(stream.split("\n")))) + + assert events[0] == ("stdout", "aGVsbG8=") + # Two data lines in one record are joined with a newline, not dropped. + assert events[1] == ("message", "one\ntwo") + assert events[2] == ("exit", '{"exit_code": 0}') + + +def test_the_reply_is_taken_from_the_last_line_the_process_wrote(): + """Anything the container printed on its own account comes before it.""" + stdout = "warning: something\n" + json.dumps({"status": "ok", "result": "42"}) + + assert _reply_of(stdout) == {"status": "ok", "result": "42"} + assert _reply_of("nothing json here") is None + + +def test_the_runner_source_is_a_program_that_answers_what_it_promises(): + request = json.dumps({"code": "print('hi'); 40 + 2"}) + finished = subprocess.run( # noqa: S603 + [sys.executable, "-u", "-c", _RUNNER_SOURCE, request], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + reply = _reply_of(finished.stdout) + assert reply["status"] == "ok" + assert reply["stdout"] == "hi\n" + assert reply["result"] == "42" + + +# --- Executing ------------------------------------------------------------- + + +def test_a_snippet_runs_and_its_output_comes_back(): + sandbox = _started() + streamed: list[str] = [] + + execution = sandbox.run_code( + "import sys\nprint('one')\nprint('bad', file=sys.stderr)", + on_stdout=lambda message: streamed.append(message.line), + ) + + assert execution.execution_ok + assert streamed == ["one"] + assert [message.line for message in execution.logs.stderr] == ["bad"] + + +def test_the_value_of_a_trailing_expression_is_answered_with(): + execution = _started().run_code("1 + 1") + + assert execution.text == "2" + + +def test_a_raising_snippet_is_reported_as_the_codes_error_not_the_sandboxs(): + execution = _started().run_code("1 / 0") + + assert execution.execution_ok + assert execution.code_error is not None + assert execution.code_error.name == "ZeroDivisionError" + + +def test_each_snippet_runs_in_a_process_of_its_own(): + """This variant cannot hold a namespace; the docs say so, and so does this.""" + sandbox = _started() + + sandbox.run_code("x = 41") + execution = sandbox.run_code("print(x)") + + assert execution.code_error is not None + assert execution.code_error.name == "NameError" + + +def test_an_environment_asked_for_reaches_the_snippet(): + execution = _started().run_code("import os\nprint(os.environ['TOKEN'])", envs={"TOKEN": "shhh"}) + + assert [message.line for message in execution.logs.stdout] == ["shhh"] + + +def test_a_bridge_that_refuses_the_key_says_which_variable_to_check(): + sandbox = _started() + sandbox.bridge.refuse = True + + execution = sandbox.run_code("1 + 1") + + assert not execution.execution_ok + assert API_KEY_ENV_VAR in (execution.execution_error or "") + + +def test_only_python_is_offered(): + with pytest.raises(ValueError, match="only supports Python"): + _started().run_code("console.log(1)", language="javascript") + + +# --- Files and lifetime ---------------------------------------------------- + + +def test_files_go_through_the_bridge_not_through_the_code(): + sandbox = _started() + + sandbox._write_file("/workspace/notes.txt", b"hello") + + assert sandbox._read_file("/workspace/notes.txt") == b"hello" + assert ("PUT", f"/v1/sandbox/{sandbox._sandbox_id}/file/workspace/notes.txt") in ( + sandbox.bridge.requests + ) + + +def test_a_file_that_is_not_there_is_not_read_as_an_empty_one(): + with pytest.raises(FileNotFoundError): + _started()._read_file("/workspace/missing.txt") + + +def test_a_sandbox_says_whether_it_is_still_up(): + sandbox = _started() + + assert sandbox.is_running() + + sandbox.stop() + + assert not sandbox.is_running() + + +def test_stopping_destroys_the_container(): + sandbox = _started() + bridge, sandbox_id = sandbox.bridge, sandbox._sandbox_id + + sandbox.stop() + + assert bridge.deleted == [sandbox_id] + assert not sandbox.is_started + + +def test_a_variable_cannot_be_set_for_a_later_snippet_and_says_why(): + """Refused rather than silently lost between two processes.""" + sandbox = _started() + + with pytest.raises(SandboxConfigurationError, match="process of its own"): + sandbox.set_variable("payload", {"a": 1}) + + +# --- Configuring ----------------------------------------------------------- + + +def test_starting_without_a_bridge_says_how_to_get_one(monkeypatch): + monkeypatch.delenv(API_URL_ENV_VAR, raising=False) + monkeypatch.delenv(API_KEY_ENV_VAR, raising=False) + + with pytest.raises(SandboxConfigurationError, match="sandbox bridge"): + CloudflareSandbox().start() + + +def test_a_gpu_that_cannot_be_given_is_refused_rather_than_ignored(): + """Running on a CPU while looking as though it asked for a GPU is worse.""" + sandbox = CloudflareSandbox(SandboxConfig(gpu="H100"), api_url=BRIDGE_URL, api_key="k") + + with pytest.raises(SandboxConfigurationError, match="no GPU"): + sandbox.start() + + +def test_the_bridge_is_read_from_the_environment_when_it_is_not_given(monkeypatch): + monkeypatch.setenv(API_URL_ENV_VAR, f"{BRIDGE_URL}/") + monkeypatch.setenv(API_KEY_ENV_VAR, "from-env") + + sandbox = CloudflareSandbox() + + # The trailing slash is dropped: the paths this variant builds start with one. + assert sandbox._api_url == BRIDGE_URL + assert sandbox._api_key == "from-env" + + +def test_a_refused_call_names_the_bridge_and_the_key(): + sandbox = _started() + sandbox.bridge.refuse = True + + with pytest.raises(SandboxConnectionError, match=API_KEY_ENV_VAR): + sandbox._write_file("/workspace/x", b"1") + + +# --- Registration ---------------------------------------------------------- + + +def test_the_variant_is_registered_everywhere_a_variant_is_named(): + assert SandboxVariant.CLOUDFLARE.value == "cloudflare" + assert isinstance(Sandbox.create(variant="cloudflare"), CloudflareSandbox) + assert [env.name for env in Sandbox.list_environments(variant="cloudflare")] == [ + "cloudflare-default", + ] + assert get_provider("cloudflare") is not None + assert "cloudflare" in manageable_variants() + assert get_manager("cloudflare").variant == "cloudflare" + + +def test_the_provider_says_what_it_needs(): + provider = get_provider("cloudflare") + + assert provider.extra == "cloudflare" + assert not provider.is_available({}) + # Both halves: a URL without a key is a bridge that will refuse it. + assert not provider.is_available({API_URL_ENV_VAR: BRIDGE_URL}) + assert provider.is_available({API_URL_ENV_VAR: BRIDGE_URL, API_KEY_ENV_VAR: "k"}) + + +def test_the_manager_deletes_by_id_without_creating_a_container_to_do_it(): + """A container made merely to hold a client is a container left billed.""" + import httpx + + from code_sandboxes import cloudflare_sandbox + + bridge = _FakeBridge() + bridge.sandboxes.append("cf-sbx-existing") + manager = get_manager("cloudflare", api_url=BRIDGE_URL, api_key="cf_key") + # Every client the manager builds answers from the fake bridge. + original = cloudflare_sandbox.CloudflareSandbox.build_client + cloudflare_sandbox.CloudflareSandbox.build_client = lambda self: httpx.Client( + base_url=BRIDGE_URL, + headers={"Authorization": "Bearer cf_key"}, + transport=httpx.MockTransport(bridge.handle), + ) + try: + assert manager.get("cf-sbx-existing").status.value == "running" + assert manager.delete("cf-sbx-existing") is True + assert manager.get("cf-sbx-existing").status.value == "stopped" + finally: + cloudflare_sandbox.CloudflareSandbox.build_client = original + + # Nothing was created along the way: only the one that was already there. + assert bridge.sandboxes == ["cf-sbx-existing"] + assert ("POST", "/v1/sandbox") not in bridge.requests + assert bridge.deleted == ["cf-sbx-existing"] + + +def test_the_manager_says_it_cannot_list_rather_than_answering_none(): + """ "None" and "cannot know" are different facts, and must read differently.""" + manager = get_manager("cloudflare") + + assert "list" not in manager.capabilities + with pytest.raises(Exception, match="list"): + manager.list() diff --git a/tests/test_coreweave.py b/tests/test_coreweave.py new file mode 100644 index 0000000..ec2f7f3 --- /dev/null +++ b/tests/test_coreweave.py @@ -0,0 +1,439 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""The CoreWeave sandbox, against a container that really runs the driver. + +The fake below does not answer with canned replies: it runs the driver source +the variant sends — as a real subprocess, in +:func:`test_the_driver_source_is_a_program_that_answers_what_it_promises`, and +in-process everywhere else — because the driver IS the protocol here. A change +to it that broke the framing would otherwise pass every test in this file. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from types import SimpleNamespace + +import pytest + +from code_sandboxes.base import Sandbox +from code_sandboxes.coreweave_sandbox import ( + _DRIVER_SOURCE, + DEFAULT_CONTAINER_IMAGE, + CoreWeaveSandbox, + _with_envs, +) +from code_sandboxes.exceptions import SandboxConfigurationError +from code_sandboxes.manage import get_manager, manageable_variants +from code_sandboxes.models import SandboxConfig, SandboxVariant +from code_sandboxes.providers import get_provider + + +class _FakeStdin: + def __init__(self, on_line, on_close=None) -> None: + self._on_line = on_line + self._on_close = on_close + self.closed = False + + def writeline(self, text: str): + self._on_line(text) + return SimpleNamespace(result=lambda timeout=None: None) + + def close(self): + self.closed = True + if self._on_close is not None: + self._on_close() + return SimpleNamespace(result=lambda timeout=None: None) + + +def _serve(code: str, namespace: dict, seq=None) -> dict: + """Run one request the way the driver does, and answer as it answers.""" + import ast + import contextlib + import io + import traceback + + out, err = io.StringIO(), io.StringIO() + reply: dict = {"status": "ok"} + if seq is not None: + reply["seq"] = seq + try: + tree = ast.parse(code, mode="exec") + trailing = None + if tree.body and isinstance(tree.body[-1], ast.Expr): + trailing = ast.Expression(tree.body.pop(-1).value) + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + if tree.body: + exec(compile(tree, "", "exec"), namespace) # noqa: S102 + if trailing is not None: + value = eval(compile(trailing, "", "eval"), namespace) # noqa: S307 + if value is not None: + reply["result"] = repr(value) + except BaseException as error: # the point of the fake: run it for real + reply["status"] = "error" + reply["error"] = { + "name": type(error).__name__, + "value": str(error), + "traceback": traceback.format_exc(), + } + reply["stdout"] = out.getvalue() + reply["stderr"] = err.getvalue() + return reply + + +class _FakeDriver: + """The session process: one namespace, one reply per request. + + Its stdout BLOCKS the way a real stream does — the variant reads it from a + thread, and a stream that ended as soon as it was empty would look like a + driver that had died before the first request was even written. + """ + + def __init__(self) -> None: + import queue + + self.namespace: dict = {"__name__": "__main__"} + self.requests: list[dict] = [] + self._replies: queue.Queue = queue.Queue() + self.stdin = _FakeStdin(self.serve, on_close=lambda: self._replies.put(None)) + self.stdout = self._lines() + + def _lines(self): + while True: + line = self._replies.get() + if line is None: + return + yield line + + def serve(self, line: str) -> None: + request = json.loads(line) + self.requests.append(request) + reply = _serve(request.get("code", ""), self.namespace, seq=request.get("seq")) + self._replies.put(json.dumps(reply)) + + +class _FakeProcess: + """One process of its own: the stateless fallback, and what it answers.""" + + def __init__(self) -> None: + self._reply: dict | None = None + self.stdin = _FakeStdin(self._run) + + def _run(self, line: str) -> None: + request = json.loads(line) + self._reply = _serve(request.get("code", ""), {"__name__": "__main__"}) + + def result(self, timeout=None): + printed = json.dumps(self._reply or {}) + "\n" + return SimpleNamespace(stdout_bytes=printed.encode(), stderr_bytes=b"") + + +class _FakeCoreWeaveSandbox: + def __init__(self, **params) -> None: + self.params = params + self.sandbox_id = "cw-sbx-1" + self.runner_id = "runner-1" + self.files: dict[str, bytes] = {} + self.execs: list[list[str]] = [] + self.driver: _FakeDriver | None = None + self.stopped = False + self.waited = False + + def wait(self, timeout=None): + self.waited = True + return self + + def exec(self, command, *, cwd=None, check=False, timeout_seconds=None, stdin=False): + self.execs.append(list(command)) + # Which program was started decides what it is: the session driver, or + # one process for one snippet. + if _DRIVER_SOURCE in command: + self.driver = _FakeDriver() + return self.driver + return _FakeProcess() + + def stop(self, **_): + self.stopped = True + return SimpleNamespace(result=lambda timeout=None: None) + + def write_file(self, filepath, contents, **_): + self.files[filepath] = contents + return SimpleNamespace(result=lambda timeout=None: None) + + def read_file(self, filepath, **_): + content = self.files.get(filepath) + return SimpleNamespace(result=lambda timeout=None: content) + + +def _started(config: SandboxConfig | None = None, *, stateful: bool = True, **kwargs): + """A sandbox that believes it started, holding the fake above.""" + sandbox = CoreWeaveSandbox(config=config, stateful=stateful, **kwargs) + sandbox._sandbox = _FakeCoreWeaveSandbox() + sandbox._started = True + if stateful: + sandbox._start_driver() + sandbox._default_context = sandbox.create_context("default") + return sandbox + + +# --- The driver, as a real program ----------------------------------------- + + +def test_the_driver_source_is_a_program_that_answers_what_it_promises(): + """Run it for real: the driver is the protocol, and must hold its shape.""" + process = subprocess.run( # noqa: S603 + [sys.executable, "-u", "-c", _DRIVER_SOURCE], + input='{"seq": 1, "code": "x = 21"}\n{"seq": 2, "code": "print(x); x * 2"}\n', + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + replies = [json.loads(line) for line in process.stdout.splitlines() if line.strip()] + assert [reply["seq"] for reply in replies] == [1, 2] + # The namespace is shared, the trailing expression is reported, and what + # was printed stays separate from it. + assert replies[1]["stdout"] == "21\n" + assert replies[1]["result"] == "42" + assert replies[1]["status"] == "ok" + + +def test_the_driver_reports_a_raising_snippet_without_dying(): + process = subprocess.run( # noqa: S603 + [sys.executable, "-u", "-c", _DRIVER_SOURCE], + input='{"seq": 1, "code": "1 / 0"}\n{"seq": 2, "code": "40 + 2"}\n', + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + replies = [json.loads(line) for line in process.stdout.splitlines() if line.strip()] + assert replies[0]["status"] == "error" + assert replies[0]["error"]["name"] == "ZeroDivisionError" + # Still serving afterwards: one bad snippet does not end the session. + assert replies[1]["result"] == "42" + + +# --- Executing -------------------------------------------------------------- + + +def test_a_snippet_keeps_what_the_one_before_it_defined(): + sandbox = _started() + + sandbox.run_code("x = 41") + execution = sandbox.run_code("print(x + 1)") + + assert [message.line for message in execution.logs.stdout] == ["42"] + assert execution.execution_ok + assert execution.code_error is None + + +def test_the_value_of_a_trailing_expression_is_answered_with(): + sandbox = _started() + + execution = sandbox.run_code("1 + 1") + + assert [result.data["text/plain"] for result in execution.results] == ["2"] + assert execution.text == "2" + + +def test_output_reaches_a_caller_that_streams(): + sandbox = _started() + streamed: list[str] = [] + errors: list[str] = [] + + sandbox.run_code( + "import sys\nprint('one')\nprint('bad', file=sys.stderr)", + on_stdout=lambda message: streamed.append(message.line), + on_stderr=lambda message: errors.append(message.line), + ) + + assert streamed == ["one"] + assert errors == ["bad"] + + +def test_a_raising_snippet_is_reported_as_the_codes_error_not_the_sandboxs(): + sandbox = _started() + + execution = sandbox.run_code("1 / 0") + + assert execution.execution_ok + assert execution.code_error is not None + assert execution.code_error.name == "ZeroDivisionError" + + +def test_a_session_that_never_answers_times_out_rather_than_hanging(): + sandbox = _started() + # A driver that takes the request and says nothing. + sandbox._driver.stdin.writeline = lambda text: None + + execution = sandbox.run_code("1 + 1", timeout=0.05) + + assert not execution.execution_ok + assert "within" in (execution.execution_error or "") + + +def test_a_session_that_went_away_falls_back_to_a_process_per_snippet(): + """Working, merely stateless: the sandbox must not stop being usable.""" + sandbox = _started() + + def gone(text): + raise BrokenPipeError("the driver is gone") + + sandbox._driver.stdin.writeline = gone + execution = sandbox.run_code("print('still here')") + + assert sandbox._driver is None + assert execution.execution_ok + assert [message.line for message in execution.logs.stdout] == ["still here"] + + +def test_without_a_driver_each_snippet_runs_in_its_own_process(): + sandbox = _started(stateful=False) + + assert sandbox._driver is None + execution = sandbox.run_code("21 * 2") + + assert execution.text == "42" + + +def test_only_python_is_offered(): + with pytest.raises(ValueError, match="only supports Python"): + _started().run_code("console.log(1)", language="javascript") + + +def test_an_environment_asked_for_reaches_the_snippet(): + """The session process is started once, so envs are set in the namespace.""" + sandbox = _started() + + execution = sandbox.run_code("import os\nprint(os.environ['TOKEN'])", envs={"TOKEN": "shhh"}) + + assert [message.line for message in execution.logs.stdout] == ["shhh"] + assert "TOKEN" in _with_envs("pass", {"TOKEN": "shhh"}) + + +# --- Variables and files ---------------------------------------------------- + + +def test_a_variable_crosses_as_json_in_both_directions(): + sandbox = _started() + + sandbox.set_variable("payload", {"a": [1, 2]}) + + assert sandbox.get_variable("payload") == {"a": [1, 2]} + + +def test_a_value_that_cannot_be_encoded_is_refused_where_it_is_set(): + with pytest.raises(SandboxConfigurationError, match="cannot be encoded"): + _started().set_variable("payload", object()) + + +def test_files_go_through_the_filesystem_api_not_through_the_code(): + sandbox = _started() + + sandbox._write_file("/workspace/notes.txt", b"hello") + + assert sandbox._sandbox.files["/workspace/notes.txt"] == b"hello" + assert sandbox._read_file("/workspace/notes.txt") == b"hello" + + +def test_a_file_that_is_not_there_is_not_read_as_an_empty_one(): + with pytest.raises(FileNotFoundError): + _started()._read_file("/workspace/missing.txt") + + +# --- Creating --------------------------------------------------------------- + + +def test_the_configuration_becomes_what_coreweave_is_asked_for(): + cwsandbox = pytest.importorskip("cwsandbox") + sandbox = CoreWeaveSandbox( + SandboxConfig( + name="mine", + max_lifetime=600.0, + env_vars={"TOKEN": "t"}, + cpu_limit=2.0, + memory_limit=4 * 1024**3, + gpu="H100", + ) + ) + + params = sandbox._run_params(cwsandbox) + + assert params["container_image"] == DEFAULT_CONTAINER_IMAGE + assert params["max_lifetime_seconds"] == 600.0 + assert params["environment_variables"] == {"TOKEN": "t"} + assert "name=mine" in params["tags"] + assert "created-by=code-sandboxes" in params["tags"] + assert params["resources"].requests == {"cpu": "2", "memory": "4Gi"} + assert params["resources"].gpu == {"count": 1, "type": "H100"} + + +def test_a_sandbox_cut_off_from_the_network_says_so(): + cwsandbox = pytest.importorskip("cwsandbox") + + options = CoreWeaveSandbox(SandboxConfig(network_policy="none"))._network_params(cwsandbox) + + assert options.deny_egress is True + + +def test_an_allowlist_of_nothing_is_refused_rather_than_silently_meaning_none(): + cwsandbox = pytest.importorskip("cwsandbox") + sandbox = CoreWeaveSandbox(SandboxConfig(network_policy="allowlist")) + + with pytest.raises(SandboxConfigurationError, match="needs allowed_hosts"): + sandbox._network_params(cwsandbox) + + +def test_the_defaults_ask_for_no_particular_machine(): + cwsandbox = pytest.importorskip("cwsandbox") + + assert CoreWeaveSandbox()._resources(cwsandbox) is None + + +def test_stopping_stops_the_sandbox_and_closes_the_session(): + sandbox = _started() + fake = sandbox._sandbox + driver = sandbox._driver + + sandbox.stop() + + assert driver.stdin.closed + assert fake.stopped + assert not sandbox.is_started + + +# --- Registration ----------------------------------------------------------- + + +def test_the_variant_is_registered_everywhere_a_variant_is_named(): + assert SandboxVariant.COREWEAVE.value == "coreweave" + assert isinstance(Sandbox.create(variant="coreweave"), CoreWeaveSandbox) + assert [env.name for env in Sandbox.list_environments(variant="coreweave")] == [ + "coreweave-default", + "coreweave-gpu", + ] + assert get_provider("coreweave") is not None + assert "coreweave" in manageable_variants() + assert get_manager("coreweave").variant == "coreweave" + + +def test_the_provider_says_what_it_needs(): + provider = get_provider("coreweave") + + assert provider.extra == "coreweave" + assert not provider.is_available({}) + assert provider.is_available({"CWSANDBOX_API_KEY": "cw_key"}) + + +def test_the_sdk_is_only_needed_when_the_sandbox_starts(): + sandbox = Sandbox.create(variant="coreweave") + + assert isinstance(sandbox, CoreWeaveSandbox) + assert not sandbox.is_started diff --git a/tests/test_e2b.py b/tests/test_e2b.py new file mode 100644 index 0000000..7b388a9 --- /dev/null +++ b/tests/test_e2b.py @@ -0,0 +1,395 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""The E2B sandbox, against an interpreter that really runs the code. + +The fake below is not a mock answering with canned strings: it executes what +the variant sends it and answers in E2B's own shapes. What is worth testing +here is the translation — E2B names each rich format with an attribute where +this package keys them by MIME type, and stamps its output in milliseconds +where this package counts in seconds — so the fake has to speak E2B, and the +assertions read this package's models. +""" + +from __future__ import annotations + +import contextlib +import io +import traceback +from types import SimpleNamespace + +import pytest + +from code_sandboxes.base import Sandbox +from code_sandboxes.e2b_sandbox import E2BSandbox, _result_data, _timestamp +from code_sandboxes.exceptions import SandboxConfigurationError +from code_sandboxes.manage import get_manager, manageable_variants +from code_sandboxes.models import SandboxConfig, SandboxVariant +from code_sandboxes.providers import get_provider + + +class _FakeResult: + """One of E2B's results: named formats, and `formats()` over them.""" + + def __init__(self, **data) -> None: + self.is_main_result = bool(data.pop("is_main_result", False)) + self.extra = data.pop("extra", {}) or {} + for name, value in data.items(): + setattr(self, name, value) + self._names = list(data) + list(self.extra) + + def formats(self): + return list(self._names) + + +class _FakeFiles: + def __init__(self) -> None: + self.written: dict[str, bytes] = {} + + def write(self, path, data, **_): + self.written[path] = data if isinstance(data, bytes) else str(data).encode() + + def read(self, path, format="text", **_): # noqa: A002 - E2B names it so + if path not in self.written: + raise FileNotFoundError(path) + content = self.written[path] + return content if format == "bytes" else content.decode() + + +class _FakeE2BSandbox: + """E2B's code interpreter, executed here instead of over there.""" + + def __init__(self, **params) -> None: + self.params = params + self.sandbox_id = "e2b-sbx-1" + self.files = _FakeFiles() + self.namespaces: dict[str | None, dict] = {None: {"__name__": "__main__"}} + self.contexts: list[SimpleNamespace] = [] + self.calls: list[dict] = [] + self.killed = False + self.timeouts: list[int] = [] + + def create_code_context(self, cwd=None, language=None, **_): + context = SimpleNamespace( + context_id=f"ctx-{len(self.contexts)}", cwd=cwd, language=language + ) + self.contexts.append(context) + self.namespaces[context.context_id] = {"__name__": "__main__"} + return context + + def run_code( + self, + code, + language=None, + context=None, + on_stdout=None, + on_stderr=None, + on_result=None, + envs=None, + timeout=None, + **_, + ): + self.calls.append({"code": code, "context": context, "envs": envs, "timeout": timeout}) + key = context.context_id if context is not None else None + namespace = self.namespaces.setdefault(key, {"__name__": "__main__"}) + out, err = io.StringIO(), io.StringIO() + error = None + try: + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + exec(compile(code, "", "exec"), namespace) # noqa: S102 + except BaseException as raised: # the point of the fake: run it for real + error = SimpleNamespace( + name=type(raised).__name__, + value=str(raised), + traceback=traceback.format_exc(), + ) + + stdout_lines = out.getvalue().splitlines() + stderr_lines = err.getvalue().splitlines() + for line in stdout_lines: + if on_stdout: + # E2B stamps in MILLISECONDS since the epoch. + on_stdout(SimpleNamespace(line=line, timestamp=1_700_000_000_000, error=False)) + for line in stderr_lines: + if on_stderr: + on_stderr(SimpleNamespace(line=line, timestamp=1_700_000_000_000, error=True)) + + results = list(namespace.pop("_test_results", [])) + for result in results: + if on_result: + on_result(result) + return SimpleNamespace( + results=results, + logs=SimpleNamespace(stdout=stdout_lines, stderr=stderr_lines), + error=error, + execution_count=len(self.calls), + ) + + def kill(self, **_): + self.killed = True + return True + + def set_timeout(self, timeout, **_): + self.timeouts.append(timeout) + + def get_host(self, port): + return f"{port}-{self.sandbox_id}.e2b.app" + + +def _started(config: SandboxConfig | None = None, **kwargs) -> E2BSandbox: + """A sandbox that believes it started, holding the fake above.""" + sandbox = E2BSandbox(config=config, **kwargs) + sandbox._sandbox = _FakeE2BSandbox() + sandbox._started = True + sandbox._default_context = sandbox.create_context("default") + return sandbox + + +# --- Executing ------------------------------------------------------------ + + +def test_a_snippet_keeps_what_the_one_before_it_defined(): + sandbox = _started() + + sandbox.run_code("x = 41") + execution = sandbox.run_code("print(x + 1)") + + assert [message.line for message in execution.logs.stdout] == ["42"] + assert execution.code_error is None + assert execution.execution_ok + + +def test_output_arrives_as_it_is_written_and_is_kept(): + sandbox = _started() + streamed: list[str] = [] + + execution = sandbox.run_code( + "import sys\nprint('one')\nprint('two')\nprint('bad', file=sys.stderr)", + on_stdout=lambda message: streamed.append(message.line), + ) + + assert streamed == ["one", "two"] + assert [message.line for message in execution.logs.stdout] == ["one", "two"] + assert [message.line for message in execution.logs.stderr] == ["bad"] + + +def test_output_is_stamped_in_the_seconds_this_package_counts_in(): + """E2B stamps in milliseconds; a line an epoch in the future is the bug.""" + sandbox = _started() + + execution = sandbox.run_code("print('now')") + + assert execution.logs.stdout[0].timestamp == pytest.approx(1_700_000_000.0) + + +def test_a_raising_snippet_is_reported_as_the_codes_error_not_the_sandboxs(): + sandbox = _started() + errors: list = [] + + execution = sandbox.run_code("1 / 0", on_error=errors.append) + + # The sandbox did its job; the code is what failed. + assert execution.execution_ok + assert execution.code_error is not None + assert execution.code_error.name == "ZeroDivisionError" + assert errors and errors[0].name == "ZeroDivisionError" + + +def test_a_sandbox_that_went_away_is_reported_rather_than_raised(): + sandbox = _started() + + def gone(*_, **__): + raise ConnectionError("sandbox not found") + + sandbox._sandbox.run_code = gone + execution = sandbox.run_code("1 + 1") + + assert not execution.execution_ok + assert "sandbox not found" in (execution.execution_error or "") + + +def test_rich_formats_are_keyed_by_mime_type(): + result = _FakeResult( + text="
", + png="aGk=", + html="hi", + extra={"application/vnd.plotly.v1+json": {"data": []}}, + is_main_result=True, + ) + + data = _result_data(result) + + assert data["text/plain"] == "
" + assert data["image/png"] == "aGk=" + assert data["text/html"] == "hi" + # A format E2B has no attribute for keeps the name it arrived under. + assert data["application/vnd.plotly.v1+json"] == {"data": []} + + +def test_a_result_the_sandbox_only_reported_at_the_end_is_not_lost(): + """E2B skips `on_result` for a replayed execution; the answer still has it.""" + sandbox = _started() + sandbox._sandbox.run_code = lambda *args, **kwargs: SimpleNamespace( + results=[_FakeResult(text="42", is_main_result=True)], + logs=SimpleNamespace(stdout=[], stderr=[]), + error=None, + execution_count=1, + ) + + execution = sandbox.run_code("40 + 2") + + assert [result.data["text/plain"] for result in execution.results] == ["42"] + + +def test_a_second_context_is_a_namespace_of_its_own(): + sandbox = _started() + other = sandbox.create_context("other") + + sandbox.run_code("x = 'default'") + execution = sandbox.run_code("print('x' in dir())", context=other) + + assert [message.line for message in execution.logs.stdout] == ["False"] + + +def test_only_python_is_offered(): + with pytest.raises(ValueError, match="only supports Python"): + _started().run_code("console.log(1)", language="javascript") + + +# --- Variables and files --------------------------------------------------- + + +def test_a_variable_crosses_as_json_in_both_directions(): + sandbox = _started() + + sandbox.set_variable("payload", {"a": [1, 2]}) + + assert sandbox.get_variable("payload") == {"a": [1, 2]} + + +def test_a_value_that_cannot_be_encoded_is_refused_where_it_is_set(): + sandbox = _started() + + with pytest.raises(SandboxConfigurationError, match="cannot be encoded"): + sandbox.set_variable("payload", object()) + + +def test_files_go_through_the_filesystem_api_not_through_the_code(): + sandbox = _started() + + sandbox._write_file("/home/user/notes.txt", b"hello") + + assert sandbox._sandbox.files.written["/home/user/notes.txt"] == b"hello" + assert sandbox._read_file("/home/user/notes.txt") == b"hello" + + +# --- Creating -------------------------------------------------------------- + + +def test_the_configuration_becomes_what_e2b_is_asked_for(): + sandbox = E2BSandbox( + SandboxConfig(name="mine", max_lifetime=600.0, env_vars={"TOKEN": "t"}), + api_key="e2b_key", + ) + + params = sandbox._create_params() + + # The interpreter's template, not E2B's `base`: `base` carries no kernel + # for the interpreter to talk to. + assert params["template"] == "code-interpreter-v1" + assert params["timeout"] == 600 + assert params["envs"] == {"TOKEN": "t"} + assert params["api_key"] == "e2b_key" + assert params["metadata"]["name"] == "mine" + assert params["metadata"]["created-by"] == "code-sandboxes" + + +def test_a_setting_that_was_not_given_is_left_for_the_environment(): + """Passing an explicit None is not the same as passing nothing.""" + params = E2BSandbox()._create_params() + + assert "api_key" not in params + assert "domain" not in params + + +def test_a_gpu_that_cannot_be_given_is_refused_rather_than_ignored(): + """Running on a CPU while looking as though it asked for a GPU is worse.""" + sandbox = E2BSandbox(SandboxConfig(gpu="H100")) + + with pytest.raises(SandboxConfigurationError, match="no GPU"): + sandbox._create_params() + + +def test_a_sandbox_cut_off_from_the_network_says_so(): + params = E2BSandbox(SandboxConfig(network_policy="none"))._create_params() + + assert params["allow_internet_access"] is False + + +def test_an_allowlist_is_refused_rather_than_widened_to_the_whole_internet(): + sandbox = E2BSandbox(SandboxConfig(network_policy="allowlist", allowed_hosts=["pypi.org"])) + + with pytest.raises(SandboxConfigurationError, match="no host allowlist"): + sandbox._create_params() + + +def test_stopping_kills_the_sandbox_and_says_it_stopped(): + sandbox = _started() + fake = sandbox._sandbox + sandbox._info = None + + sandbox.stop() + + assert fake.killed + assert not sandbox.is_started + + +def test_the_life_of_a_sandbox_can_be_extended_while_it_runs(): + sandbox = _started() + + sandbox.set_timeout(120.4) + + assert sandbox._sandbox.timeouts == [120] + + +def test_a_port_inside_has_a_host_outside(): + assert _started().get_host(8888).startswith("8888-") + + +def test_a_timestamp_that_is_not_one_falls_back_to_now(): + assert _timestamp(None) > 0 + assert _timestamp("nonsense") > 0 + # Seconds are left alone; milliseconds are divided. + assert _timestamp(1_700_000_000) == pytest.approx(1_700_000_000.0) + assert _timestamp(1_700_000_000_000) == pytest.approx(1_700_000_000.0) + + +# --- Registration ---------------------------------------------------------- + + +def test_the_variant_is_registered_everywhere_a_variant_is_named(): + assert SandboxVariant.E2B.value == "e2b" + assert isinstance(Sandbox.create(variant="e2b"), E2BSandbox) + assert [env.name for env in Sandbox.list_environments(variant="e2b")] == [ + "e2b-code-interpreter", + ] + assert get_provider("e2b") is not None + assert "e2b" in manageable_variants() + assert get_manager("e2b").variant == "e2b" + + +def test_the_provider_says_what_it_needs(): + provider = get_provider("e2b") + + assert provider.extra == "e2b" + assert not provider.is_available({}) + assert provider.is_available({"E2B_API_KEY": "e2b_key"}) + + +def test_the_sdk_is_only_needed_when_the_sandbox_starts(): + """Creating one must not import e2b: a listing names every variant.""" + sandbox = Sandbox.create(variant="e2b") + + assert isinstance(sandbox, E2BSandbox) + assert not sandbox.is_started diff --git a/tests/test_manage.py b/tests/test_manage.py index 61b8bd5..8febcfe 100644 --- a/tests/test_manage.py +++ b/tests/test_manage.py @@ -32,9 +32,12 @@ def test_every_variant_of_the_enum_has_a_manager_in_any_spelling(): def test_every_variant_has_a_manager(): assert manageable_variants() == [ + "cloudflare", + "coreweave", "datalayer", "daytona", "docker", + "e2b", "eval", "google-colab", "jupyter-server", From a403de78a9b7eb58067ed2ed42cfa43e3b9d3b23 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Fri, 21 Aug 2026 19:50:53 +0200 Subject: [PATCH 2/2] lint --- CHANGELOG.md | 14 +++++ code_sandboxes/__version__.py | 2 +- code_sandboxes/cloudflare_sandbox.py | 91 ++++++++++++++++++++-------- code_sandboxes/coreweave_sandbox.py | 62 ++++++++++++++++++- docs/docs/sandboxes/cloudflare.mdx | 20 +++++- docs/docs/sandboxes/coreweave.mdx | 13 +++- examples/README.md | 3 +- tests/test_cloudflare.py | 71 ++++++++++++++++++++++ tests/test_coreweave.py | 28 +++++++++ 9 files changed, 269 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f037f..4211dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,20 @@ gets and deletes; it cannot list, because the bridge has no endpoint that enumerates sandboxes, and says so rather than answering with an empty list. +- Hardened the three new variants against silently doing something other than + what was asked. `cloudflare` now carries `SandboxConfig.env_vars` into every + snippet — the bridge takes no environment when it creates a sandbox, so they + had been accepted and dropped — refuses a `network_policy` it cannot apply + rather than leaving a sandbox believed to be cut off connected, refuses + `get_variable` with the reason instead of answering the misleading "no such + variable", and serves `files.read`/`files.write` through the bridge's own + file endpoints so they need no session at all. `coreweave` refuses the + variable APIs when there is no session process — under `stateful=False`, or + after one was lost — rather than reporting a successful set that vanishes + with the process, and a snippet that runs past its timeout now has its + session STOPPED rather than left running and changing the namespace behind a + call that already returned. + - A GPU asked of a variant that has none is now REFUSED rather than dropped. `--gpu` reaches `coreweave`, `datalayer`, `daytona`, `kaggle` and `modal`, and `code-sandboxes exec -v e2b --gpu H100` says which variants can give one diff --git a/code_sandboxes/__version__.py b/code_sandboxes/__version__.py index 1eead6c..f30f69b 100644 --- a/code_sandboxes/__version__.py +++ b/code_sandboxes/__version__.py @@ -3,4 +3,4 @@ """Code Sandboxes.""" -__version__ = "1.1.1" +__version__ = "1.1.2" diff --git a/code_sandboxes/cloudflare_sandbox.py b/code_sandboxes/cloudflare_sandbox.py index 06d84f7..239469c 100644 --- a/code_sandboxes/cloudflare_sandbox.py +++ b/code_sandboxes/cloudflare_sandbox.py @@ -49,8 +49,8 @@ SandboxConnectionError, SandboxExecutionError, SandboxNotStartedError, - VariableNotFoundError, ) +from .filesystem import SandboxFilesystem from .models import ( CodeError, Context, @@ -154,6 +154,27 @@ def _sse_events(lines: Iterator[str]) -> Iterator[tuple[str, str]]: yield event, "\n".join(data) +class _CloudflareFilesystem(SandboxFilesystem): + """The filesystem of a Cloudflare sandbox, read through the bridge. + + The base class reads a text file by running a snippet that binds the + contents to a name and then reading that name back in a SECOND execution. + That works wherever a namespace outlives a snippet, and here nothing does + — so text reads and writes are served by the bridge's own file endpoints + instead, which is one round trip rather than two and needs no session at + all. The binary forms already went this way: they call `_read_file` and + `_write_file`, which this variant overrides. + """ + + def read(self, path: str) -> str: + return self._sandbox._read_file(path).decode("utf-8") + + def write(self, path: str, content: str, make_dirs: bool = True) -> None: + # `make_dirs` is not honoured by the bridge's PUT, which creates the + # parents it needs; the argument is kept for the shared interface. + self._sandbox._write_file(path, content.encode("utf-8")) + + class CloudflareSandbox(Sandbox): """Sandbox backed by a Cloudflare container, through the sandbox bridge. @@ -227,6 +248,18 @@ def start(self) -> None: "variant for a GPU." ) + # The bridge exposes no networking controls at all — no egress rules, + # no allowlist, no switch — so a policy asked for here could only be + # accepted and then not applied. A sandbox believed to be cut off from + # the network while it is not is the failure that matters. + if self.config.network_policy in ("none", "allowlist") or self.config.allowed_hosts: + raise SandboxConfigurationError( + f"Cloudflare sandboxes cannot restrict the network, so " + f"network_policy={self.config.network_policy!r} cannot be " + "honoured. Use the e2b variant to cut a sandbox off, or the " + "daytona or coreweave variant for an allowlist." + ) + self._client = self.build_client() response = self._client.post("/v1/sandbox") self._raise_for_status(response, "create a sandbox") @@ -349,7 +382,12 @@ def run_code( started_at = time.time() self._execution_count += 1 seconds = timeout if timeout is not None else self.config.timeout - request = json.dumps({"code": _with_envs(code, envs)}) + # The environment of the CONFIGURATION as well as the one asked for + # here. The bridge takes no environment when it creates a sandbox — it + # has nowhere to put one — so `env_vars` would otherwise be accepted + # and then silently dropped from every snippet. Per-call values win. + environment = {**(self.config.env_vars or {}), **(envs or {})} + request = json.dumps({"code": _with_envs(code, environment)}) try: stdout, stderr, exit_code = self._exec( @@ -495,31 +533,34 @@ def _do_interrupt(self) -> bool: """The bridge takes no interrupt; a timeout is the only stop.""" return False - def _get_internal_variable(self, name: str, context: Context | None = None) -> Any: - """The value of a variable, carried back as JSON. - - Each snippet runs in a process of its own here, so this can only read - a variable of the snippet it is part of — which is what the base class - asks it for. There is no session to read from between calls. + @property + def files(self) -> SandboxFilesystem: + """The bridge-backed filesystem, rather than the base's code-driven one.""" + if self._files is None: + self._files = _CloudflareFilesystem(self) + return self._files + + def get_variable(self, name: str, context: Context | None = None) -> Any: + """Refused: reading a variable takes a session, and there is none. + + The base class reads a variable in TWO executions — it binds the value + to a name of its own, then reads that name back — which every other + variant serves because its namespace outlives a snippet. Here the + first execution's process is gone before the second starts, so the + read would fail as "no such variable": true of the name, and entirely + misleading about the reason. """ - if not self._started: - raise SandboxNotStartedError() - execution = self.run_code( - "import json as _code_sandboxes_json\n" - f"print(_code_sandboxes_json.dumps({name}, default=repr))\n" - "del _code_sandboxes_json\n", - context=context, + raise SandboxConfigurationError( + f"A Cloudflare sandbox runs each snippet in a process of its own, " + f"so there is no session to read {name!r} from. Have the snippet " + "print what you need and read it from the execution, or keep it " + "in a file — the filesystem of the sandbox does persist." ) - if not execution.execution_ok: - raise SandboxExecutionError( - "SandboxError", execution.execution_error or "Sandbox execution failed" - ) - if execution.code_error is not None: - raise VariableNotFoundError(name) - printed = "\n".join(message.line for message in execution.logs.stdout).strip() - if not printed: - raise VariableNotFoundError(name) - return json.loads(printed) + + def _get_internal_variable(self, name: str, context: Context | None = None) -> Any: + """The base class reaches this only through `get_variable`, which is + refused above; it is implemented so the abstraction stays satisfied.""" + return self.get_variable(name, context) def _set_internal_variable(self, name: str, value: Any, context: Context | None = None) -> None: if not self._started: diff --git a/code_sandboxes/coreweave_sandbox.py b/code_sandboxes/coreweave_sandbox.py index 6121b08..5f95c18 100644 --- a/code_sandboxes/coreweave_sandbox.py +++ b/code_sandboxes/coreweave_sandbox.py @@ -424,13 +424,24 @@ def _driver_request(self, code: str, timeout: float) -> dict | None: self._driver.stdin.writeline(json.dumps({"seq": self._driver_seq, "code": code})) except Exception: logger.warning("The CoreWeave session driver went away; restarting stateless.") - self._driver = None + self._discard_driver() return None deadline = time.monotonic() + timeout while True: remaining = deadline - time.monotonic() if remaining <= 0: - raise TimeoutError(f"No reply from the CoreWeave session within {timeout:.0f}s.") + # Giving up on the ANSWER is not giving up on the work: the + # session process is still running that snippet, and would go + # on mutating the namespace long after this call reported a + # timeout. So the process is cancelled and dropped. The next + # call starts a fresh session, which costs the state — the + # honest price of a snippet that would not stop. + self._discard_driver() + raise TimeoutError( + f"No reply from the CoreWeave session within {timeout:.0f}s. " + "The session was stopped, so the next execution starts a " + "new one and nothing defined before it is still there." + ) try: line = self._driver_replies.get(timeout=remaining) except queue.Empty: @@ -446,6 +457,22 @@ def _driver_request(self, code: str, timeout: float) -> dict | None: if reply.get("seq") == self._driver_seq: return reply + def _discard_driver(self) -> None: + """Stop the session process and forget it. + + Called when it cannot be trusted any more — it never answered, or it + went away. `cancel` is what CoreWeave offers to end a running process; + closing stdin alone would leave a snippet that ignores EOF running. + """ + driver, self._driver, self._driver_replies = self._driver, None, None + if driver is None: + return + for stop in (lambda: driver.cancel(), lambda: driver.stdin.close()): + try: + stop() + except Exception: + logger.debug("Ignoring error while stopping the session", exc_info=True) + def _stateless_request(self, code: str, timeout: float) -> dict: """One snippet in a process of its own, for when there is no driver.""" process = self._sandbox.exec( @@ -600,10 +627,40 @@ def _do_interrupt(self) -> bool: """The session process takes no interrupt; a timeout is the only stop.""" return False + def _needs_a_session(self, what: str) -> None: + """Refuse a read or a write that only a session process can serve. + + Without one, every snippet runs in a process of its own: an assignment + made by one is gone before the next starts. Reporting success for a + variable that will not be there is worse than refusing it, so the two + variable APIs ask this first. `stateful=False` chooses this, and a + session that died falls into it. + """ + if self._driver is None: + raise SandboxConfigurationError( + f"This CoreWeave sandbox has no session process, so {what} " + "cannot be kept: each snippet runs in a process of its own. " + "Start the sandbox with stateful=True (the default), or keep " + "the value in a file — the filesystem does persist." + ) + + def get_variable(self, name: str, context: Context | None = None) -> Any: + """The value of a variable — which takes a session to be worth asking. + + Guarded HERE rather than in `_get_internal_variable`: the base class + binds the name to one of its own in a first execution and reads it + back in a second, so without a session the first snippet's process is + gone and the read fails as "no such variable" — true of the name, and + entirely misleading about the reason. + """ + self._needs_a_session(f"{name!r}") + return super().get_variable(name, context) + def _get_internal_variable(self, name: str, context: Context | None = None) -> Any: """The value of a variable, carried back as JSON.""" if not self._started or self._sandbox is None: raise SandboxNotStartedError() + self._needs_a_session(f"{name!r}") execution = self.run_code( "import json as _code_sandboxes_json\n" f"print(_code_sandboxes_json.dumps({name}, default=repr))\n" @@ -624,6 +681,7 @@ def _get_internal_variable(self, name: str, context: Context | None = None) -> A def _set_internal_variable(self, name: str, value: Any, context: Context | None = None) -> None: if not self._started or self._sandbox is None: raise SandboxNotStartedError() + self._needs_a_session(f"{name!r}") try: payload = json.dumps(value) except TypeError as error: diff --git a/docs/docs/sandboxes/cloudflare.mdx b/docs/docs/sandboxes/cloudflare.mdx index fe22066..4c01e47 100644 --- a/docs/docs/sandboxes/cloudflare.mdx +++ b/docs/docs/sandboxes/cloudflare.mdx @@ -134,6 +134,20 @@ with one JSON line, so: is **not** returned. `result.results` only ever holds the text value. - There is no interrupt: `sandbox.interrupt()` answers `False`, and a runaway execution is stopped by its timeout. -- Reading a variable (`get_variable`, and `sandbox.commands.run` which is built - on it) can only see the snippet it is part of. There is no session to read - from between calls. +- Reading a variable is **refused**, with the reason: `get_variable` binds the + value in one execution and reads it back in a second, and this backend's + first process is gone before the second starts. `sandbox.commands.run`, which + is built on it, is refused the same way. Have the snippet print what you + need, or keep it in a file. +- `sandbox.files.read` and `.write` **do** work: they go through the bridge's + own file endpoints rather than through a variable, which is one round trip + and needs no session. So do `upload_file` and `download_file`. +- The network cannot be restricted. The bridge exposes no egress rules, no + allowlist and no switch, so `network_policy="none"` or `"allowlist"` is + refused at `start()` rather than accepted and left unapplied — believing a + sandbox is cut off while it is not is the failure that matters. Use the + `e2b` variant to cut one off, or `daytona` / `coreweave` for an allowlist. +- There is no GPU, and `gpu=` is refused for the same reason. +- `SandboxConfig.env_vars` is honoured: the bridge takes no environment when it + creates a sandbox, so the configured variables are set at the top of every + snippet instead. Anything passed to `run_code(envs=...)` wins over them. diff --git a/docs/docs/sandboxes/coreweave.mdx b/docs/docs/sandboxes/coreweave.mdx index 767fee6..9aae8ce 100644 --- a/docs/docs/sandboxes/coreweave.mdx +++ b/docs/docs/sandboxes/coreweave.mdx @@ -160,8 +160,11 @@ else, so: above. - Rich display data — a figure, an HTML repr, a PNG — has no channel at all and is **not** returned. `result.results` only ever holds the text value. -- There is no interrupt: `sandbox.interrupt()` answers `False`, and a runaway - execution is stopped by its timeout. +- There is no interrupt: `sandbox.interrupt()` answers `False`. A snippet that + runs past its timeout has the SESSION stopped — not just the wait for its + answer — because a snippet nobody is waiting for would otherwise go on + changing the namespace. The next execution starts a new session, so nothing + defined before the timeout is still there; the timeout message says so. - Contexts do not isolate: there is one session process and therefore one namespace. `create_context()` gives a context back, but two of them see each other's variables. @@ -171,6 +174,12 @@ on them such as `sandbox.commands.run`), so a value that cannot be encoded comes back as its `repr` rather than as the object itself, and one that cannot be *sent* is refused with a reason. +They need the session process, and say so when there is none — under +`stateful=False`, or after a session was stopped. Without it each snippet runs +in a process of its own, so a variable set by one would be gone before the next +could read it; refusing is the honest answer, and reporting a successful set +would not be. + Binary files go straight to CoreWeave's filesystem API rather than through a program that decodes them: diff --git a/examples/README.md b/examples/README.md index ce9b5f1..0af36f7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -93,8 +93,7 @@ make monty Notes by variant: - `cloudflare`: requires `code-sandboxes[cloudflare]` and a deployed sandbox - bridge Worker — `npm create cloudflare -- sandbox-bridge - --template=cloudflare/sandbox-sdk/bridge/worker` — named by + bridge Worker — `npm create cloudflare -- sandbox-bridge --template=cloudflare/sandbox-sdk/bridge/worker` — named by `CLOUDFLARE_SANDBOX_API_URL` with the `CLOUDFLARE_SANDBOX_API_KEY` it generated. Each snippet runs in a process of its own, so nothing crosses between them. diff --git a/tests/test_cloudflare.py b/tests/test_cloudflare.py index ce8ac69..9c6d3e7 100644 --- a/tests/test_cloudflare.py +++ b/tests/test_cloudflare.py @@ -292,6 +292,58 @@ def test_stopping_destroys_the_container(): assert not sandbox.is_started +def test_the_configured_environment_reaches_every_snippet(): + """`env_vars` has nowhere to go at creation, so it rides with each call.""" + sandbox = _started(config=SandboxConfig(env_vars={"TOKEN": "from-config"})) + + execution = sandbox.run_code("import os\nprint(os.environ['TOKEN'])") + + assert [message.line for message in execution.logs.stdout] == ["from-config"] + + +def test_a_per_call_environment_wins_over_the_configured_one(): + sandbox = _started(config=SandboxConfig(env_vars={"TOKEN": "from-config", "KEEP": "yes"})) + + execution = sandbox.run_code( + "import os\nprint(os.environ['TOKEN'], os.environ['KEEP'])", + envs={"TOKEN": "from-call"}, + ) + + assert [message.line for message in execution.logs.stdout] == ["from-call yes"] + + +def test_a_network_policy_that_cannot_be_honoured_is_refused_not_ignored(): + """Believing a sandbox is cut off while it is not is the failure here.""" + for policy in ("none", "allowlist"): + sandbox = CloudflareSandbox( + SandboxConfig(network_policy=policy, allowed_hosts=["pypi.org"]), + api_url=BRIDGE_URL, + api_key="cf_key", + ) + with pytest.raises(SandboxConfigurationError, match="cannot restrict the network"): + sandbox.start() + + +def test_reading_a_variable_says_there_is_no_session_rather_than_no_variable(): + """The base class reads in two executions; the first process is gone.""" + sandbox = _started() + + with pytest.raises(SandboxConfigurationError, match="no session to read"): + sandbox.get_variable("x") + + +def test_text_files_go_through_the_bridge_so_they_need_no_session(): + sandbox = _started() + + sandbox.files.write("/workspace/notes.txt", "hello") + + assert sandbox.files.read("/workspace/notes.txt") == "hello" + # One round trip each, straight at the file endpoints. + assert ("PUT", f"/v1/sandbox/{sandbox._sandbox_id}/file/workspace/notes.txt") in ( + sandbox.bridge.requests + ) + + def test_a_variable_cannot_be_set_for_a_later_snippet_and_says_why(): """Refused rather than silently lost between two processes.""" sandbox = _started() @@ -330,6 +382,25 @@ def test_the_bridge_is_read_from_the_environment_when_it_is_not_given(monkeypatc assert sandbox._api_key == "from-env" +def test_the_client_sends_the_key_it_was_given_as_a_bearer_token(): + """Every authenticated call rides on this header; a placeholder here would + make the bridge refuse all of them.""" + client = CloudflareSandbox(api_url=BRIDGE_URL, api_key="cf_secret_value").build_client() + + try: + assert client.headers["authorization"] == "Bearer cf_secret_value" + finally: + client.close() + + # And no header at all when there is no key: a bridge run locally for + # development has none, and `Bearer ` alone would be a wrong answer. + anonymous = CloudflareSandbox(api_url=BRIDGE_URL, api_key="").build_client() + try: + assert "authorization" not in anonymous.headers + finally: + anonymous.close() + + def test_a_refused_call_names_the_bridge_and_the_key(): sandbox = _started() sandbox.bridge.refuse = True diff --git a/tests/test_coreweave.py b/tests/test_coreweave.py index ec2f7f3..0859927 100644 --- a/tests/test_coreweave.py +++ b/tests/test_coreweave.py @@ -334,6 +334,34 @@ def test_a_value_that_cannot_be_encoded_is_refused_where_it_is_set(): _started().set_variable("payload", object()) +def test_without_a_session_a_variable_is_refused_rather_than_lost(): + """A set that reports success and then vanishes is the worse answer.""" + sandbox = _started(stateful=False) + + with pytest.raises(SandboxConfigurationError, match="no session process"): + sandbox.set_variable("payload", {"a": 1}) + with pytest.raises(SandboxConfigurationError, match="no session process"): + sandbox.get_variable("payload") + + +def test_a_timed_out_snippet_has_its_session_stopped(): + """Giving up on the answer is not giving up on the work: it must be cut.""" + sandbox = _started() + driver = sandbox._driver + cancelled: list[bool] = [] + driver.cancel = lambda: cancelled.append(True) + # A session that takes the request and never answers. + driver.stdin.writeline = lambda text: None + + execution = sandbox.run_code("1 + 1", timeout=0.05) + + assert not execution.execution_ok + assert "stopped" in (execution.execution_error or "") + assert cancelled == [True] + # Dropped, so the next execution starts a session of its own. + assert sandbox._driver is None + + def test_files_go_through_the_filesystem_api_not_through_the_code(): sandbox = _started()