diff --git a/CHANGELOG.md b/CHANGELOG.md index 4211dcb..571963a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ ## Unreleased +- Numbered the prompt's examples, and made one runnable by its number: + `:examples` lists them `1.`, `2.`, … and `:examples:2` prints the second and + then executes it, for a reader who wants the answer rather than the paste. + They are now declared where the sandbox is made — + `Sandbox.create(..., examples=[...])`, carried on `SandboxConfig` — so + `run_repl(sandbox)` finds them without being told twice; passing them to + `run_repl` still overrides for one prompt. Snippets are printed with Rich's + markup off, since `[...]` was being read as a style tag and a snippet + holding `list[str]` printed as `list = []`, wrong exactly where someone was + about to copy it. + +- Added `:examples` to the sandbox prompt. `run_repl(sandbox, examples=[...])` + takes title-and-code pairs and prints them on request, for a reader to copy + into the prompt; every REPL example under `examples/repl` ships its own, and + the ones that can take a GPU offer device discovery and a timed matmul + instead of their general set when `--gpu` was asked for. The snippets avoid + blocks on purpose: the prompt reads one line at a time, so a pasted `for` or + `def` would arrive without its body. + +- Fixed a `daytona` GPU sandbox failing to be created at all unless it was + also asking for preemptible capacity. Daytona requires every GPU sandbox to + be ephemeral — *"GPU sandboxes must be ephemeral; set autoDeleteInterval to + 0"* — and `auto_delete_interval=0` was being set only on the `spot=True` + path, so a plain `gpu="H100"` was refused by the API. It now follows the GPU + itself, which is what Daytona ties it to. + - Added three cloud variants: `e2b`, `coreweave` and `cloudflare`. `e2b` runs in a Firecracker microVM through E2B's code interpreter SDK, so it diff --git a/README.md b/README.md index 99015b8..b1d50e5 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,34 @@ with Sandbox.create( print(sandbox.run_code("x + 2").text) # 42 ``` +### Jupyter over provider ingress + +Daytona, E2B, and Modal sandboxes can prepare a real Jupyter Server and +return the provider HTTPS/WebSocket ingress needed to reach it: + +```python +from code_sandboxes import JupyterServerOptions, Sandbox + +sandbox = Sandbox.create(variant="daytona") # also: e2b, modal +sandbox.start() +endpoint = sandbox.prepare_jupyter_server( + JupyterServerOptions(port=8888, install_if_missing=True) +) +``` + +Preparation first checks for `jupyter-server` and `ipykernel`, installs them +only when absent, launches Jupyter in the background, and waits for its port +to accept connections. Calling the method again on the same sandbox returns +the cached endpoint. Provider-ingress credentials are in `endpoint.headers`; +the separate Jupyter token is in `endpoint.query`. Do not send either to the +browser: a server-side gateway should apply them while proxying HTTP and +WebSocket traffic. + +`install_if_missing=False` makes a prebuilt template or snapshot mandatory. +Templates and snapshots are the intended cold-start optimization; the +conditional installation is the preliminary path for ordinary provider base +images. + ## Kaggle Sandbox Kaggle supports both batch execution and interactive connections through the diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index 775923d..a24d14c 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -67,10 +67,12 @@ from .commands import CommandResult, ProcessHandle, SandboxCommands from .console import ( EXIT_COMMANDS, + example_code, repl_prompt, run_repl, show_and_run, show_code, + show_examples, show_result, ) from .coreweave_sandbox import CoreWeaveSandbox @@ -123,6 +125,8 @@ Context, ExecutionResult, GPUType, + JupyterServerEndpoint, + JupyterServerOptions, Logs, MIMEType, OutputHandler, @@ -146,6 +150,7 @@ available_providers, get_provider, ) +from .provider_ingress import provider_ingress_execution #: Everything this package exports, in one sorted list — the groups it #: used to be split into stopped matching what they sat above. @@ -176,6 +181,8 @@ "GoogleColabSandbox", "ISandboxClient", "JupyterServerSandbox", + "JupyterServerEndpoint", + "JupyterServerOptions", "KaggleExecutionResult", "KaggleKernelClient", "KaggleKernelExecutor", @@ -216,6 +223,7 @@ "TunnelInfo", "VariableNotFoundError", "available_providers", + "example_code", "execution_result_to_reply", "get_manager", "get_provider", @@ -223,9 +231,11 @@ "normalize_variant", "parse_google_colab_channels_url", "parse_kaggle_channels_url", + "provider_ingress_execution", "repl_prompt", "run_repl", "show_and_run", "show_code", + "show_examples", "show_result", ] diff --git a/code_sandboxes/__version__.py b/code_sandboxes/__version__.py index f30f69b..925795c 100644 --- a/code_sandboxes/__version__.py +++ b/code_sandboxes/__version__.py @@ -3,4 +3,4 @@ """Code Sandboxes.""" -__version__ = "1.1.2" +__version__ = "1.2.1" diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index db0a876..d5614b1 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -19,6 +19,8 @@ CodeError, Context, ExecutionResult, + JupyterServerEndpoint, + JupyterServerOptions, OutputHandler, OutputMessage, Result, @@ -138,6 +140,20 @@ def kernel_client(self) -> ISandboxClient | None: """Expose an optional kernel client interface for kernel-backed variants.""" return None + def prepare_jupyter_server( + self, options: JupyterServerOptions | None = None + ) -> JupyterServerEndpoint: + """Install, start and expose a real Jupyter Server in this sandbox. + + Cloud-container providers override this method. It is intentionally + separate from :meth:`start`: callers using the lightweight code API + should not pay the Jupyter installation and startup cost. + """ + del options + raise NotImplementedError( + f"{type(self).__name__} does not expose Jupyter over provider ingress" + ) + def interrupt(self) -> bool: """Request interruption of the currently running code. @@ -220,6 +236,7 @@ def create( # noqa: C901 network_policy: str | None = None, allowed_hosts: list[str] | None = None, tags: dict[str, str] | None = None, + examples: list[tuple[str, str]] | None = None, **kwargs, ) -> Sandbox: """Factory method to create a sandbox of the specified variant. @@ -283,7 +300,13 @@ def create( # noqa: C901 name=name or generate_sandbox_name(), network_policy=network_policy or "inherit", allowed_hosts=allowed_hosts or [], + examples=examples or [], ) + elif examples: + # A caller who brought a whole config AND a list of examples means + # the examples: the config is the machine, these are what to try on + # it, and silently dropping them would be the surprising reading. + config = config.model_copy(update={"examples": list(examples)}) from .eval_sandbox import EvalSandbox diff --git a/code_sandboxes/console.py b/code_sandboxes/console.py index 8a38f63..b34d263 100644 --- a/code_sandboxes/console.py +++ b/code_sandboxes/console.py @@ -19,20 +19,24 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from collections.abc import Sequence +from textwrap import dedent +from typing import TYPE_CHECKING, Any, Callable from rich.console import Console if TYPE_CHECKING: from .base import Sandbox - from .models import ExecutionResult + from .models import CodeError, ExecutionResult, OutputMessage, Result __all__ = [ "EXIT_COMMANDS", + "example_code", "repl_prompt", "run_repl", "show_and_run", "show_code", + "show_examples", "show_result", ] @@ -123,10 +127,75 @@ def show_and_run( console: Console | None = None, **kwargs: Any, ) -> ExecutionResult: - """Print the code, run it, print what came back, and return the result.""" + """Print the code and each output as it arrives, then return the result.""" show_code(code, console=console) + return _run_and_show(sandbox, code, console=console, labelled=True, **kwargs) + + +def _chain(first: Callable | None, second: Callable) -> Callable: + """Preserve a caller's callback while adding the console renderer.""" + + def chained(value: Any) -> None: + if first is not None: + first(value) + second(value) + + return chained + + +def _run_and_show( + sandbox: Sandbox, + code: str, + *, + console: Console | None, + labelled: bool, + **kwargs: Any, +) -> ExecutionResult: + """Run once, rendering callback output immediately without replaying it.""" + out = _out(console) + emitted = False + stream_started: set[str] = set() + + def stream(message: OutputMessage, name: str) -> None: + nonlocal emitted + emitted = True + if labelled and name not in stream_started: + out.print(f"<<< {name}:", style="yellow" if name == "stderr" else "cyan") + stream_started.add(name) + _write( + out, + message.line, + style="yellow" if name == "stderr" else None, + indent=labelled, + ) + + def value(result: Result) -> None: + nonlocal emitted + emitted = True + text = result.text + if text is not None: + _write(out, f"<<< result: {text}" if labelled else text) + + def error(code_error: CodeError) -> None: + nonlocal emitted + emitted = True + text = f"{code_error.name}: {code_error.value}" + _write(out, f"<<< error: {text}" if labelled else text, style="red") + + kwargs["on_stdout"] = _chain(kwargs.get("on_stdout"), lambda msg: stream(msg, "stdout")) + kwargs["on_stderr"] = _chain(kwargs.get("on_stderr"), lambda msg: stream(msg, "stderr")) + kwargs["on_result"] = _chain(kwargs.get("on_result"), value) + kwargs["on_error"] = _chain(kwargs.get("on_error"), error) result = sandbox.run_code(code, **kwargs) - show_result(result, console=console) + + # Adapters without callback support still get the traditional complete + # rendering. Streaming adapters have already shown every event and must + # not replay the accumulated result a second time. + if not emitted: + show_result(result, console=out, labelled=labelled) + elif not result.execution_ok: + failed = result.execution_error or "the sandbox could not run this" + _write(out, f"<<< execution error: {failed}" if labelled else failed, style="red") return result @@ -144,7 +213,7 @@ def repl_prompt(sandbox: Sandbox) -> str: return f"sandbox({info.variant or 'unknown'}:{name})>>> " -def _show_help(console: Console) -> None: +def _show_help(console: Console, has_examples: bool = False) -> None: console.print("Type Python statements or expressions.", style="dim") console.print( "State is kept between lines, and the value of an expression is shown.", @@ -154,25 +223,85 @@ def _show_help(console: Console) -> None: f"{', '.join(sorted(EXIT_COMMANDS))} — leave, terminating the sandbox.", style="dim", ) + if has_examples: + console.print(":examples — the snippets for this sandbox, numbered.", style="dim") + console.print(":examples:2 — run the second one, without pasting it.", style="dim") console.print(":help — this.", style="dim") -def run_repl( +def example_code(examples: Sequence[tuple[str, str]], number: int) -> str | None: + """The code of example `number`, counted from one, or None if there is no + such example.""" + if 1 <= number <= len(examples): + return dedent(examples[number - 1][1]).strip("\n") + return None + + +def show_examples( + examples: Sequence[tuple[str, str]], + console: Console | None = None, +) -> None: + """Print the snippets, numbered, each under what it does. + + Numbered so they can be asked for by number — `:examples:2` runs the + second — and printed plainly rather than boxed, because a reader who wants + to paste one instead selects it with the cursor and anything drawn around + it would come along. + + `markup=False` throughout: Rich reads `[...]` as a style tag, so a snippet + holding `list[str]` or `data[1:3]` would print with the brackets eaten and + be wrong in exactly the place someone was about to copy. + """ + out = _out(console) + if not examples: + out.print("This sandbox ships no examples.", style="dim") + return + out.print("") + for number, (title, code) in enumerate(examples, start=1): + out.print(f"# {number}. {title}", style="cyan", markup=False, highlight=False) + for line in dedent(code).strip("\n").splitlines(): + out.print(line, style="white", markup=False, highlight=False) + out.print("") + out.print( + f":examples:N runs one of them — 1 to {len(examples)}.", + style="dim", + markup=False, + ) + + +def run_repl( # noqa: C901 sandbox: Sandbox, *, console: Console | None = None, banner: bool = True, + examples: Sequence[tuple[str, str]] | None = None, ) -> None: """Hold a prompt open against a sandbox that is already started. Leaving the loop does NOT stop the sandbox: whoever started it decides when it goes, which for every caller here is the `with` block around this. + + Args: + examples: Title-and-code pairs, overriding whatever the sandbox was + created with. Normally left out: they are declared once at + `Sandbox.create(examples=...)` and read from there, so a caller + holding a sandbox already has them and a prompt opened on it + offers the right ones without being told twice. """ out = _out(console) prompt = repl_prompt(sandbox) + # The sandbox's own, unless this call brought its own list. + if examples is None: + # Reached through two `getattr`s on purpose: `run_repl` takes anything + # that runs code, and a stand-in without a `config` should open a + # prompt with no examples rather than fail to open one at all. + examples = list(getattr(getattr(sandbox, "config", None), "examples", None) or []) if banner: out.print("Sandbox REPL ready. Type Python and press Enter.", style="green") - out.print(":exit or Ctrl-D to leave, :help for help.", style="dim") + hint = ":exit or Ctrl-D to leave, :help for help." + if examples: + hint = ":examples for snippets to paste, :exit to leave, :help for help." + out.print(hint, style="dim") while True: try: @@ -191,11 +320,29 @@ def run_repl( if code in EXIT_COMMANDS: break if code == ":help": - _show_help(out) + _show_help(out, has_examples=bool(examples)) + continue + if code == ":examples": + show_examples(examples or [], console=out) continue + if code.startswith(":examples:"): + asked = code[len(":examples:") :].strip() + wanted = int(asked) if asked.isdigit() else 0 + chosen = example_code(examples or [], wanted) + if chosen is None: + out.print( + f"There is no example {asked!r}. :examples lists them.", + style="yellow", + markup=False, + ) + continue + # Shown before it runs: an example that executed invisibly would + # leave the reader with an answer and no idea what produced it. + show_code(chosen, console=out) + code = chosen try: - result = sandbox.run_code(code) + result = _run_and_show(sandbox, code, console=out, labelled=False) except KeyboardInterrupt: out.print("\nExecution interrupted.", style="yellow") continue @@ -205,6 +352,4 @@ def run_repl( _write(out, f"Execution failed: {exc}", style="red") continue - show_result(result, console=out, labelled=False) - out.print("REPL closed.", style="green") diff --git a/code_sandboxes/datalayer_sandbox.py b/code_sandboxes/datalayer_sandbox.py index 64f40a6..bb32eb6 100644 --- a/code_sandboxes/datalayer_sandbox.py +++ b/code_sandboxes/datalayer_sandbox.py @@ -47,31 +47,25 @@ def _urls_for_run(run_url: str): prefix, so one URL is enough to reach all of them. `DatalayerURLs` has no constructor for that shape — it takes the services one by one — so they are filled in here rather than in the SDK. + + Which services those are is read off the SDK rather than written down + here. A list copied from it goes stale the moment a URL is renamed there, + and it went stale exactly that way: `mcp_server_url` became + `jupyter_mcp_server_url` and every execution died on the unexpected + keyword, far from the rename that caused it. Asking the signature means a + new service is picked up for free and a renamed one cannot break this. """ + import inspect + from datalayer_core.utils.urls import DatalayerURLs base = (run_url or "").rstrip("/") - return DatalayerURLs.from_environment( - **dict.fromkeys( - [ - "iam_url", - "runtimes_url", - "spacer_url", - "library_url", - "manager_url", - "ai_agents_url", - "ai_inference_url", - "otel_url", - "growth_url", - "success_url", - "status_url", - "support_url", - "mcp_server_url", - "scheduler_url", - ], - base, - ) - ) + services = [ + name + for name in inspect.signature(DatalayerURLs.from_environment).parameters + if name.endswith("_url") + ] + return DatalayerURLs.from_environment(**dict.fromkeys(services, base)) class DatalayerSandbox(Sandbox): @@ -273,9 +267,18 @@ def start(self) -> None: from agent_runtimes.client import AgentClient from agent_runtimes.client.agent_client import DEFAULT_TIME_RESERVATION except ImportError as e: + # What actually failed, not what usually fails. + # + # The message used to name the missing package and the command + # that installs it, whatever the import error said. When the + # package WAS installed and one of these names had moved, it sent + # the reader to reinstall a dependency that was already there — + # the real reason, `cannot import name X`, was thrown away with + # the exception it was written on. raise SandboxConfigurationError( - "agent-runtimes package is required for DatalayerSandbox. " - "Install it with: pip install code-sandboxes[datalayer]" + f"DatalayerSandbox cannot be used: {e}. " + "If the package is missing, install it with: " + "pip install code-sandboxes[datalayer]" ) from e try: diff --git a/code_sandboxes/daytona_sandbox.py b/code_sandboxes/daytona_sandbox.py index 0f50a94..f4d0c88 100644 --- a/code_sandboxes/daytona_sandbox.py +++ b/code_sandboxes/daytona_sandbox.py @@ -37,10 +37,13 @@ SandboxNotStartedError, VariableNotFoundError, ) +from .jupyter_ingress import preparation_command, resolved_options, websocket_url from .models import ( CodeError, Context, ExecutionResult, + JupyterServerEndpoint, + JupyterServerOptions, Logs, OutputHandler, OutputMessage, @@ -50,6 +53,7 @@ SandboxEnvironment, SandboxInfo, SandboxStatus, + gpu_memory, ) logger = logging.getLogger(__name__) @@ -216,6 +220,15 @@ def _gpu_types(flavors: str, daytona: Any) -> list[Any]: return wanted +def _asks_for_a_gpu(resources: Any | None) -> bool: + """Whether this specification carries a GPU. + + `Resources` sets `gpu` to a count, so "no GPU" arrives as either no + specification at all or a count of zero. + """ + return resources is not None and getattr(resources, "gpu", None) not in (None, 0) + + def _import_daytona() -> Any: try: import daytona @@ -291,8 +304,37 @@ def __init__( #: default namespace and never need a second. self._contexts: dict[str, Any] = {} self._execution_count = 0 + self._jupyter_endpoint: JupyterServerEndpoint | None = None self._extra_kwargs = kwargs + def prepare_jupyter_server( + self, options: JupyterServerOptions | None = None + ) -> JupyterServerEndpoint: + """Prepare Jupyter and expose it through Daytona's preview ingress.""" + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + if self._jupyter_endpoint is not None: + return self._jupyter_endpoint + + value = resolved_options(options) + response = self._sandbox.process.exec( + preparation_command(value), timeout=max(1, math.ceil(value.install_timeout)) + ) + if getattr(response, "exit_code", 0) not in (0, None): + raise SandboxConfigurationError( + "Could not install and start Jupyter Server in the Daytona sandbox: " + + str(getattr(response, "result", "unknown error")) + ) + preview = self._sandbox.get_preview_link(value.port) + self._jupyter_endpoint = JupyterServerEndpoint( + port=value.port, + http_url=preview.url.rstrip("/"), + websocket_url=websocket_url(preview.url.rstrip("/")), + headers={"X-Daytona-Preview-Token": preview.token}, + query={"token": value.token or ""}, + ) + return self._jupyter_endpoint + @classmethod def list_environments(cls) -> list[SandboxEnvironment]: """The environments this provider ships. @@ -322,6 +364,9 @@ def list_environments(cls) -> list[SandboxEnvironment]: owner="daytona", visibility="cloud", burning_rate=0.0, + gpu="H100", + gpu_count=1, + gpu_memory=gpu_memory("H100"), metadata={"variant": "daytona", "gpu": "H100", "spot": False}, ), SandboxEnvironment( @@ -331,6 +376,9 @@ def list_environments(cls) -> list[SandboxEnvironment]: owner="daytona", visibility="cloud", burning_rate=0.0, + gpu="H100", + gpu_count=1, + gpu_memory=gpu_memory("H100"), metadata={"variant": "daytona", "gpu": "H100", "spot": True}, ), ] @@ -395,6 +443,13 @@ def _create_params(self, daytona: Any) -> Any: common.update(self._network_params()) resources = self._resources(daytona) + if _asks_for_a_gpu(resources): + # Daytona will not create a GPU sandbox that outlives its stop: + # "GPU sandboxes must be ephemeral; set autoDeleteInterval to 0". + # It is a property of asking for a GPU at all, not of asking for + # preemptible capacity — which is where this used to live, so an + # on-demand `gpu=` was refused by the API on creation. + common["auto_delete_interval"] = 0 if self._spot: common.update(self._spot_params(resources)) if self._image is not None or resources is not None: @@ -412,11 +467,13 @@ def _spot_params(self, resources: Any | None) -> dict[str, Any]: """What asking for preemptible capacity commits the sandbox to. Spot is GPU-only — Daytona refuses it for a sandbox that asks for no - GPU — and it is built from an IMAGE with `auto_delete_interval=0`, so - a reclaimed sandbox does not linger. Both are said here rather than - left to come back as an API error a caller cannot act on. + GPU — and it is built from an IMAGE rather than from a snapshot. Both + are said here rather than left to come back as an API error a caller + cannot act on. Being ephemeral is asked for alongside the GPU itself, + in `_create_params`, since Daytona requires it of every GPU sandbox + and not only of the preemptible ones. """ - if resources is None or getattr(resources, "gpu", None) in (None, 0): + if not _asks_for_a_gpu(resources): raise SandboxConfigurationError( "spot=True asks for preemptible GPU capacity, so it needs a " "GPU: pass gpu=... (for example gpu='H100', or " @@ -429,7 +486,7 @@ def _spot_params(self, resources: Any | None) -> dict[str, Any]: "machine specification; snapshot= cannot be used with it. " "Pass image=, or leave both out for a Debian image." ) - return {"spot": True, "auto_delete_interval": 0} + return {"spot": True} def _labels(self) -> dict[str, str]: """The metadata the sandbox carries in Daytona. @@ -496,6 +553,7 @@ def stop(self) -> None: logger.debug("Ignoring error while stopping the Daytona sandbox", exc_info=True) self._sandbox = None self._daytona = None + self._jupyter_endpoint = None self._contexts.clear() self._started = False if self._info: diff --git a/code_sandboxes/e2b_sandbox.py b/code_sandboxes/e2b_sandbox.py index 976322e..f344630 100644 --- a/code_sandboxes/e2b_sandbox.py +++ b/code_sandboxes/e2b_sandbox.py @@ -35,10 +35,13 @@ SandboxNotStartedError, VariableNotFoundError, ) +from .jupyter_ingress import preparation_command, resolved_options, websocket_url from .models import ( CodeError, Context, ExecutionResult, + JupyterServerEndpoint, + JupyterServerOptions, Logs, MIMEType, OutputHandler, @@ -173,8 +176,41 @@ def __init__( #: namespace and never need a second. self._contexts: dict[str, Any] = {} self._execution_count = 0 + self._jupyter_endpoint: JupyterServerEndpoint | None = None self._extra_kwargs = kwargs + def prepare_jupyter_server( + self, options: JupyterServerOptions | None = None + ) -> JupyterServerEndpoint: + """Prepare Jupyter and expose it through E2B's per-port ingress.""" + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + if self._jupyter_endpoint is not None: + return self._jupyter_endpoint + + value = resolved_options(options) + response = self._sandbox.commands.run( + preparation_command(value), timeout=value.install_timeout + ) + if getattr(response, "exit_code", 0) not in (0, None): + raise SandboxConfigurationError( + "Could not install and start Jupyter Server in the E2B sandbox: " + + str(getattr(response, "stderr", "unknown error")) + ) + url = f"https://{self._sandbox.get_host(value.port)}" + traffic_token = getattr(self._sandbox, "traffic_access_token", None) + headers = ( + {"E2B-Traffic-Access-Token": traffic_token} if traffic_token else {} + ) + self._jupyter_endpoint = JupyterServerEndpoint( + port=value.port, + http_url=url, + websocket_url=websocket_url(url), + headers=headers, + query={"token": value.token or ""}, + ) + return self._jupyter_endpoint + @classmethod def list_environments(cls) -> list[SandboxEnvironment]: """The environments this provider ships. @@ -314,6 +350,7 @@ def stop(self) -> None: logger.debug("Ignoring error while killing the E2B sandbox", exc_info=True) self._sandbox = None self._contexts.clear() + self._jupyter_endpoint = None self._started = False if self._info: self._info.status = SandboxStatus.STOPPED diff --git a/code_sandboxes/jupyter_ingress.py b/code_sandboxes/jupyter_ingress.py new file mode 100644 index 0000000..807fedd --- /dev/null +++ b/code_sandboxes/jupyter_ingress.py @@ -0,0 +1,74 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Shared commands for Jupyter Servers exposed through provider ingress.""" + +from __future__ import annotations + +import secrets +import shlex + +from .models import JupyterServerOptions + + +def resolved_options(options: JupyterServerOptions | None) -> JupyterServerOptions: + """Return options carrying a fresh Jupyter token when none was supplied.""" + value = options or JupyterServerOptions() + if value.token: + return value + return value.model_copy(update={"token": secrets.token_urlsafe(32)}) + + +def preparation_command(options: JupyterServerOptions) -> str: + """A fast, idempotent install check followed by a background server.""" + packages = "jupyter-server ipykernel" + check = "python -c 'import jupyter_server, ipykernel'" + if options.install_if_missing: + install = ( + "python -m pip install --disable-pip-version-check --quiet " + packages + ) + prerequisite = f"{check} >/dev/null 2>&1 || {install}" + else: + prerequisite = check + + token = shlex.quote(options.token or "") + launch = " ".join( + [ + "python -m jupyter_server", + "--ServerApp.ip=0.0.0.0", + f"--ServerApp.port={options.port}", + "--ServerApp.port_retries=0", + "--ServerApp.open_browser=False", + "--ServerApp.allow_remote_access=True", + "--ServerApp.allow_origin='*'", + "--ServerApp.allow_root=True", + f"--IdentityProvider.token={token}", + "--ServerApp.password=''", + ] + ) + pid_file = f"/tmp/code-sandboxes-jupyter-{options.port}.pid" + probe = ( + 'python -c "import socket; ' + f"socket.create_connection(('127.0.0.1', {options.port}), 1).close()\"" + ) + # Do not return an ingress URL until the socket can accept connections. + # This is the preliminary cold-start work; a template can later make the + # import/install branch disappear without changing this contract. + return ( + f"set -e; {prerequisite}; " + f"(nohup {launch} >/tmp/code-sandboxes-jupyter.log 2>&1 & " + f"echo $! >{pid_file}); " + f"i=0; until {probe} >/dev/null 2>&1; do " + "i=$((i + 1)); [ $i -lt 120 ] || { " + "cat /tmp/code-sandboxes-jupyter.log >&2; exit 1; }; sleep 0.25; done" + ) + + +def websocket_url(http_url: str) -> str: + """Translate an ingress HTTP URL to the corresponding WebSocket URL.""" + if http_url.startswith("https://"): + return "wss://" + http_url.removeprefix("https://") + if http_url.startswith("http://"): + return "ws://" + http_url.removeprefix("http://") + raise ValueError(f"Provider returned a non-HTTP ingress URL: {http_url!r}") diff --git a/code_sandboxes/jupyter_server_sandbox.py b/code_sandboxes/jupyter_server_sandbox.py index 59a8f38..c97a3e0 100644 --- a/code_sandboxes/jupyter_server_sandbox.py +++ b/code_sandboxes/jupyter_server_sandbox.py @@ -21,6 +21,7 @@ import threading import time import uuid +from collections import deque from pathlib import Path from urllib.parse import parse_qs, urlparse, urlunparse @@ -47,6 +48,10 @@ DEFAULT_PORT = 0 DEFAULT_STARTUP_TIMEOUT = 30.0 +#: How many of the server's last lines are kept, to quote when it will not +#: start. Enough for a traceback, not enough to hold a log in memory. +SERVER_OUTPUT_LINES = 50 + logger = logging.getLogger(__name__) @@ -111,6 +116,8 @@ def __init__( self._server_app = None self._server_thread: threading.Thread | None = None self._server_process: subprocess.Popen | None = None + #: The last lines the server wrote, to quote when it fails to start. + self._server_output: deque[str] = deque(maxlen=SERVER_OUTPUT_LINES) self._client: ISandboxClient | None = None self._sandbox_id = str(uuid.uuid4()) self._workdir: str | None = None @@ -241,16 +248,46 @@ def _start_local_server_subprocess(self, workdir: str, port: int) -> None: workdir, ) + # Kept, not discarded. + # + # This was `DEVNULL` on both streams, so a server that failed to start + # — a missing package, a port already taken, a bad argument — said why + # into nothing, and the only thing anyone ever saw was "Timed out + # waiting for Jupyter Server" thirty seconds later. Read on a thread so + # the pipe cannot fill and block the server that IS starting. self._server_process = subprocess.Popen( # noqa: S603 — argv built above, no shell cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, # Start in its own process group so we can kill the tree. preexec_fn=os.setsid if hasattr(os, "setsid") else None, ) + self._server_output: deque[str] = deque(maxlen=SERVER_OUTPUT_LINES) + threading.Thread( + target=self._drain_server_output, + name=f"jupyter-server-{port}", + daemon=True, + ).start() self._server_url = f"http://{self._host}:{port}" + def _drain_server_output(self) -> None: + """Keep what the server says, so a failure can be quoted.""" + stream = getattr(self._server_process, "stdout", None) + if stream is None: + return + with contextlib.suppress(Exception): + for line in stream: + text = line.rstrip() + if text: + self._server_output.append(text) + logger.debug("[jupyter-server] %s", text) + + def _server_said(self) -> str: + """The last of the server's own output, for an error message.""" + return "\n".join(getattr(self, "_server_output", ())) + def _start_local_server_inprocess(self, workdir: str, port: int) -> None: """Start the Jupyter server in a daemon thread (legacy mode). @@ -301,6 +338,16 @@ def _wait_for_server(self, timeout: float = DEFAULT_STARTUP_TIMEOUT) -> None: raise SandboxConfigurationError("Server URL not available") deadline = time.time() + timeout while time.time() < deadline: + # A server that has already exited is not going to answer, and + # waiting out the timeout to say so turns a plain error — the + # module is not installed, the port is taken — into a mystery. + process = self._server_process + if process is not None and process.poll() is not None: + said = self._server_said() + raise SandboxConfigurationError( + f"The Jupyter Server exited with code {process.returncode} " + f"before it was ready" + (f": {said}" if said else " and said nothing") + ) try: response = requests.get( f"{self._server_url}/api/status", @@ -312,7 +359,11 @@ def _wait_for_server(self, timeout: float = DEFAULT_STARTUP_TIMEOUT) -> None: return except Exception: time.sleep(0.5) - raise SandboxConfigurationError("Timed out waiting for Jupyter Server") + said = self._server_said() + raise SandboxConfigurationError( + f"Timed out waiting for Jupyter Server after {timeout:.0f}s" + + (f": {said}" if said else "") + ) def _find_existing_kernel(self) -> str | None: """Find an existing pre-warmed kernel to reuse. @@ -529,43 +580,30 @@ def run_code( # noqa: C901 env_code = "\n".join(f"import os; os.environ[{k!r}] = {v!r}" for k, v in envs.items()) code = f"{env_code}\n{code}" - try: - reply = self._client.execute(code, timeout=timeout or self.config.timeout) - except Exception as e: - # Infrastructure failure or interruption - self._executing_event.clear() - was_interrupted = self._interrupt_requested.is_set() - self._interrupt_requested.clear() - return ExecutionResult( - execution_ok=not was_interrupted, - execution_error=f"Failed to execute code: {e}" if not was_interrupted else None, - started_at=started_at, - completed_at=time.time(), - context_id=context.id if context else "default", - interrupted=was_interrupted, - code_error=CodeError( - name="KeyboardInterrupt", - value="Execution was interrupted", - traceback="", - ) - if was_interrupted - else None, - ) - stdout_messages: list[OutputMessage] = [] stderr_messages: list[OutputMessage] = [] results: list[Result] = [] code_error: CodeError | None = None exit_code: int | None = None - current_time = time.time() - for output in reply.get("outputs", []): - output_type = output.get("output_type") + def consume(message: dict) -> None: + """Normalize one IOPub message while it is still arriving.""" + nonlocal code_error, exit_code + + header = message.get("header", {}) + output_type = header.get("msg_type") or message.get("msg_type") + content = message.get("content", {}) + current_time = time.time() + if output_type == "stream": - name = output.get("name") - text = output.get("text", "") + name = content.get("name") + text = content.get("text", "") for line in text.splitlines(): - msg = OutputMessage(line=line, timestamp=current_time, error=name == "stderr") + msg = OutputMessage( + line=line, + timestamp=current_time, + error=name == "stderr", + ) if name == "stderr": stderr_messages.append(msg) if on_stderr: @@ -576,18 +614,16 @@ def run_code( # noqa: C901 on_stdout(msg) elif output_type in ("execute_result", "display_data"): result = Result( - data=output.get("data", {}), + data=content.get("data", {}), is_main_result=output_type == "execute_result", - extra=output.get("metadata", {}), + extra=content.get("metadata", {}), ) results.append(result) if on_result: on_result(result) elif output_type == "error": - ename = output.get("ename", "Error") - evalue = output.get("evalue", "") - - # Handle SystemExit specially - extract exit code + ename = content.get("ename", "Error") + evalue = content.get("evalue", "") if ename == "SystemExit": try: exit_code = int(evalue) if evalue else 0 @@ -597,10 +633,41 @@ def run_code( # noqa: C901 code_error = CodeError( name=ename, value=evalue, - traceback="\n".join(output.get("traceback", [])), + traceback="\n".join(content.get("traceback", [])), ) if on_error: on_error(code_error) + elif output_type == "clear_output" and not content.get("wait", False): + stdout_messages.clear() + stderr_messages.clear() + results.clear() + + try: + reply = self._client.execute_interactive( + code, + timeout=timeout or self.config.timeout, + output_hook=consume, + ) + except Exception as e: + # Infrastructure failure or interruption + self._executing_event.clear() + was_interrupted = self._interrupt_requested.is_set() + self._interrupt_requested.clear() + return ExecutionResult( + execution_ok=not was_interrupted, + execution_error=f"Failed to execute code: {e}" if not was_interrupted else None, + started_at=started_at, + completed_at=time.time(), + context_id=context.id if context else "default", + interrupted=was_interrupted, + code_error=CodeError( + name="KeyboardInterrupt", + value="Execution was interrupted", + traceback="", + ) + if was_interrupted + else None, + ) # Clear execution tracking self._executing_event.clear() @@ -613,7 +680,7 @@ def run_code( # noqa: C901 execution_ok=True, code_error=code_error, exit_code=exit_code, - execution_count=reply.get("execution_count", 0), + execution_count=reply.get("content", {}).get("execution_count", 0), context_id=context.id if context else "default", started_at=started_at, completed_at=time.time(), diff --git a/code_sandboxes/kaggle_sandbox.py b/code_sandboxes/kaggle_sandbox.py index 16092e8..1d3199b 100644 --- a/code_sandboxes/kaggle_sandbox.py +++ b/code_sandboxes/kaggle_sandbox.py @@ -50,6 +50,7 @@ SandboxEnvironment, SandboxInfo, SandboxStatus, + gpu_memory, ) logger = logging.getLogger(__name__) @@ -124,6 +125,9 @@ def list_environments(cls) -> list[SandboxEnvironment]: owner="kaggle", visibility="cloud", burning_rate=0.0, + gpu="T4", + gpu_count=1, + gpu_memory=gpu_memory("T4"), metadata={"variant": "kaggle", "accelerator": "T4"}, ), ] diff --git a/code_sandboxes/modal_sandbox.py b/code_sandboxes/modal_sandbox.py index d87899d..887eae8 100644 --- a/code_sandboxes/modal_sandbox.py +++ b/code_sandboxes/modal_sandbox.py @@ -27,10 +27,13 @@ from .base import Sandbox from .exceptions import SandboxConfigurationError, SandboxNotStartedError +from .jupyter_ingress import preparation_command, resolved_options, websocket_url from .models import ( CodeError, Context, ExecutionResult, + JupyterServerEndpoint, + JupyterServerOptions, Logs, OutputHandler, OutputMessage, @@ -39,6 +42,7 @@ SandboxEnvironment, SandboxInfo, SandboxStatus, + gpu_memory, ) logger = logging.getLogger(__name__) @@ -165,8 +169,40 @@ def __init__( self._sandbox = None self._sandbox_id = str(uuid.uuid4()) self._execution_count = 0 + self._jupyter_endpoint: JupyterServerEndpoint | None = None self._extra_kwargs = kwargs + def prepare_jupyter_server( + self, options: JupyterServerOptions | None = None + ) -> JupyterServerEndpoint: + """Prepare Jupyter and expose it through a Modal connect token.""" + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + if self._jupyter_endpoint is not None: + return self._jupyter_endpoint + + value = resolved_options(options) + process = self._sandbox.exec( + "sh", "-lc", preparation_command(value), timeout=math.ceil(value.install_timeout) + ) + process.wait() + if getattr(process, "returncode", 0) not in (0, None): + stderr = process.stderr.read() if getattr(process, "stderr", None) else "" + raise SandboxConfigurationError( + "Could not install and start Jupyter Server in the Modal sandbox: " + + str(stderr) + ) + credentials = self._sandbox.create_connect_token(port=value.port) + url = credentials.url.rstrip("/") + self._jupyter_endpoint = JupyterServerEndpoint( + port=value.port, + http_url=url, + websocket_url=websocket_url(url), + headers={"Authorization": f"Bearer {credentials.token}"}, + query={"token": value.token or ""}, + ) + return self._jupyter_endpoint + @classmethod def list_environments(cls) -> list[SandboxEnvironment]: """The environments this provider ships. @@ -193,6 +229,9 @@ def list_environments(cls) -> list[SandboxEnvironment]: owner="modal", visibility="cloud", burning_rate=0.0, + gpu="T4", + gpu_count=1, + gpu_memory=gpu_memory("T4"), metadata={"variant": "modal", "gpu": "T4"}, ), ] @@ -335,6 +374,7 @@ def stop(self) -> None: logger.debug("Ignoring error while detaching Modal sandbox", exc_info=True) self._sandbox = None self._app = None + self._jupyter_endpoint = None self._started = False if self._info: self._info.status = SandboxStatus.STOPPED diff --git a/code_sandboxes/models.py b/code_sandboxes/models.py index fd2324c..dfa6f19 100644 --- a/code_sandboxes/models.py +++ b/code_sandboxes/models.py @@ -35,6 +35,24 @@ class SandboxEnvironment(BaseModel): burning_rate: float = 0.0 metadata: Optional[dict[str, Any]] = None + #: What the environment runs on, where the provider says. + #: + #: Named rather than buried in `metadata` because these are what a person + #: choosing an environment compares — a surface listing them should not + #: have to know which key each provider happened to use. Every one is + #: optional and absent means UNKNOWN, not zero: a provider that does not + #: publish its CPU allocation should be reported as not saying, rather + #: than as giving none. + #: + #: `gpu` is the card as the provider names it, `gpu_memory` what that card + #: carries — see `gpu_memory()`, which knows the hardware rather than the + #: provider. Sizes are written the way the platform writes them: `16Gi`. + cpu: Optional[str] = None + memory: Optional[str] = None + gpu: Optional[str] = None + gpu_count: Optional[int] = None + gpu_memory: Optional[str] = None + class MIMEType(str, Enum): """Common MIME types for execution results.""" @@ -105,6 +123,44 @@ class GPUType(str, Enum): L4 = "L4" +#: What each card carries, by the name a provider offers it under. +#: +#: A property of the HARDWARE, not of any provider: an H100 has 80 GB whoever +#: rents it out. Kept here so a surface listing environments can say what a +#: GPU environment actually gets without every provider repeating it, and so +#: that a name nobody here knows is reported as unknown rather than guessed +#: at. Aliases are spelled the way the providers spell them. +GPU_MEMORY: dict[str, str] = { + "T4": "16Gi", + "L4": "24Gi", + "A10G": "24Gi", + "A100": "40Gi", + "A100-80GB": "80Gi", + "H100": "80Gi", + "H200": "141Gi", + "RTX-4090": "24Gi", + # Kaggle names its accelerators in full. + "NvidiaTeslaT4": "16Gi", + "NvidiaTeslaP100": "16Gi", +} + + +def gpu_memory(gpu: str | None) -> str | None: + """How much memory that card has, or None when it is not one we know. + + The name arrives as a provider spells it, and several are asked for as an + ordered list of preferences — `"H100,H200"` — of which the first is the + one that would be given. + """ + if not gpu: + return None + first = gpu.split(",")[0].strip() + for name, memory in GPU_MEMORY.items(): + if name.lower() == first.lower(): + return memory + return None + + class ResourceConfig(BaseModel): """Resource configuration for sandbox. @@ -465,6 +521,15 @@ class SandboxConfig(BaseModel): idle_timeout: Optional[float] = None max_lifetime: float = 86400.0 # 24 hours default like Modal + #: Snippets worth running in THIS sandbox, as (title, code) pairs. + #: + #: They belong to the sandbox rather than to the prompt because what is + #: worth trying depends on what was created: a sandbox with an H100 in it + #: wants device discovery and a matmul, one that runs in this very process + #: wants neither. `run_repl` reads them from here, so a caller passes them + #: once, at creation, and the prompt needs no arrangement of its own. + examples: list[tuple[str, str]] = Field(default_factory=list) + class SandboxInfo(BaseModel): """Information about a running sandbox. @@ -553,3 +618,32 @@ class TunnelInfo(BaseModel): def __repr__(self) -> str: return f"TunnelInfo(port={self.port}, url={self.url!r})" + + +class JupyterServerEndpoint(BaseModel): + """A provider ingress endpoint for a Jupyter Server in a sandbox. + + ``headers`` authenticate the provider ingress and ``query`` authenticates + Jupyter itself. They are deliberately separate: Modal, for example, + consumes the HTTP Authorization header before the request reaches + Jupyter. The values are secrets and are therefore omitted from reprs. + """ + + model_config = ConfigDict(extra="forbid") + + port: int + http_url: str + websocket_url: str + headers: dict[str, str] = Field(default_factory=dict, repr=False) + query: dict[str, str] = Field(default_factory=dict, repr=False) + + +class JupyterServerOptions(BaseModel): + """Options for preparing a real Jupyter Server inside a sandbox.""" + + model_config = ConfigDict(extra="forbid") + + port: int = 8888 + token: Optional[str] = Field(default=None, repr=False) + install_if_missing: bool = True + install_timeout: float = 180.0 diff --git a/code_sandboxes/provider_ingress.py b/code_sandboxes/provider_ingress.py new file mode 100644 index 0000000..a0be13e --- /dev/null +++ b/code_sandboxes/provider_ingress.py @@ -0,0 +1,50 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Execute through a real Jupyter Server exposed by provider ingress.""" + +from __future__ import annotations + +import contextlib +from collections.abc import Iterator + +from .base import Sandbox +from .jupyter_server_sandbox import JupyterServerSandbox +from .models import JupyterServerOptions + + +@contextlib.contextmanager +def provider_ingress_execution( + provider_sandbox: Sandbox, + *, + direct: bool = False, + options: JupyterServerOptions | None = None, +) -> Iterator[Sandbox]: + """Yield the execution sandbox for one cloud-provider container. + + The default prepares a real Jupyter Server inside the already-started + Daytona, E2B, or Modal sandbox and connects a Jupyter kernel client through + the provider's authenticated ingress. ``direct=True`` yields the provider + SDK execution adapter unchanged. + + The caller continues to own ``provider_sandbox`` and must stop it. The + Jupyter client created here is always closed before returning. + """ + if direct: + yield provider_sandbox + return + + endpoint = provider_sandbox.prepare_jupyter_server(options) + jupyter = JupyterServerSandbox( + config=provider_sandbox.config, + server_url=endpoint.http_url, + token=endpoint.query.get("token"), + headers=endpoint.headers, + reuse_kernel=False, + ) + jupyter.start() + try: + yield jupyter + finally: + jupyter.stop() diff --git a/code_sandboxes/providers.py b/code_sandboxes/providers.py index 1e59283..cb22ccf 100644 --- a/code_sandboxes/providers.py +++ b/code_sandboxes/providers.py @@ -81,6 +81,12 @@ class SandboxProvider: variant: SandboxVariant title: str description: str + #: The mark this provider is drawn with, as a slug of the Datalayer icon + #: set — `daytona` for `DaytonaIcon`. Named here rather than by whoever + #: draws it, so the CLI, the operator and the web all show one provider as + #: one thing. None where the set has no mark for it yet; a reader then + #: falls back to whatever it uses for the unknown. + icon: str | None = None #: Any one of these satisfies the provider; empty means nothing is needed. requirements: tuple[ProviderRequirement, ...] = () #: Extra packages needed, as the extra of this distribution. @@ -194,6 +200,7 @@ def read(**kwargs) -> list[SandboxEnvironment]: ), SandboxProvider( variant=SandboxVariant.KAGGLE, + icon="kaggle", title="Kaggle", description=( "Kaggle notebook sessions, interactively against a running kernel or as a batch job." @@ -221,6 +228,7 @@ def read(**kwargs) -> list[SandboxEnvironment]: ), SandboxProvider( variant=SandboxVariant.MODAL, + icon="modal", title="Modal", description="Containers on Modal, with or without a GPU attached.", extra="modal", @@ -238,6 +246,7 @@ def read(**kwargs) -> list[SandboxEnvironment]: ), SandboxProvider( variant=SandboxVariant.DAYTONA, + icon="daytona", title="Daytona", description=( "Sandboxes on Daytona, with a stateful Python interpreter and an optional GPU." @@ -257,6 +266,7 @@ def read(**kwargs) -> list[SandboxEnvironment]: ), SandboxProvider( variant=SandboxVariant.E2B, + icon="e2b", title="E2B", description=( "Sandboxes on E2B, in Firecracker microVMs that start in about 150 ms, " @@ -366,6 +376,7 @@ def provider_catalog( "name": provider.name, "title": provider.title, "description": provider.description, + "icon": provider.icon, "enabled": enabled, "needs_credentials": provider.needs_credentials, "requirements": [ @@ -377,10 +388,25 @@ def provider_catalog( for requirement in provider.requirements ], "environments": [ + # What the environment runs on travels with it: a service + # listing environments is asked which has a GPU and which + # card it is, and that cannot be answered from a name. + # Keys the provider did not declare are left out rather + # than sent as null, so "did not say" stays tellable from + # "has none". { - "name": environment.name, - "title": environment.title, - "language": environment.language, + key: value + for key, value in { + "name": environment.name, + "title": environment.title, + "language": environment.language, + "cpu": environment.cpu, + "memory": environment.memory, + "gpu": environment.gpu, + "gpu_count": environment.gpu_count, + "gpu_memory": environment.gpu_memory, + }.items() + if value is not None } for environment in (provider.environments(secrets) if enabled else []) ], diff --git a/docs/docs/cli/index.mdx b/docs/docs/cli/index.mdx index 1bbdcf6..5d7ace2 100644 --- a/docs/docs/cli/index.mdx +++ b/docs/docs/cli/index.mdx @@ -73,6 +73,67 @@ sandbox(daytona:tan-law-5384)>>> x + 2 Use any of `:exit`, `:quit`, `exit`, `quit` or `Ctrl+D` to leave, and `:help` for a reminder. On exit, the sandbox is terminated. +#### Examples At The Prompt + +A sandbox can carry snippets worth running in it, and the prompt offers them: + +```text +sandbox(daytona:tan-law-5384)>>> :examples + +# 1. What the GPU is, straight from the driver +import subprocess +print(subprocess.run(["nvidia-smi"], capture_output=True, text=True).stdout) + +# 2. A workload that actually uses it: a matmul, timed on the device +import time, torch +... + +:examples:N runs one of them — 1 to 5. +``` + +Two ways to use one. Copy it into the prompt, or ask for it by number and let +the prompt run it — `:examples:2` prints the snippet and then executes it, so +the answer arrives with its cause above it: + +```text +sandbox(daytona:tan-law-5384)>>> :examples:2 +>>> code: + import time, torch + ... +'312.4 TFLOP/s' +``` + +They are declared once, where the sandbox is made, because what is worth +running depends on what was created — a sandbox with an H100 in it wants +device discovery, one that runs in this very process wants neither: + +```python +from code_sandboxes import Sandbox, run_repl + +examples = [ + ("What the GPU is", "import torch\ntorch.cuda.get_device_name(0)"), + ("How much memory it has", "import torch\ntorch.cuda.mem_get_info()"), +] + +with Sandbox.create(variant="daytona", gpu="H100", examples=examples) as sandbox: + run_repl(sandbox) # reads them off the sandbox +``` + +`run_repl(sandbox, examples=[...])` still overrides them for one prompt, and +`:help` lists both forms whenever a sandbox has any. + +:::note + +A snippet asked for by number runs as ONE execution, so it may contain a +`for` or a `def`. A snippet meant to be PASTED cannot: the prompt reads a line +at a time, and a block would arrive without its body. The examples that ship +with this package are written to be safe either way. + +::: + +Every REPL example under `examples/repl` ships its own set; see +[Examples](/examples). + ### Variant Selection Supported variants: diff --git a/docs/docs/examples/index.mdx b/docs/docs/examples/index.mdx index 5669661..a6684e0 100644 --- a/docs/docs/examples/index.mdx +++ b/docs/docs/examples/index.mdx @@ -5,6 +5,11 @@ sidebar_position: 6 # Examples +The Daytona, E2B, and Modal `exec` and `repl` examples use a real Jupyter +Server over provider ingress by default. Pass `--direct` to use the provider +SDK adapter instead. See +[Jupyter over provider ingress](/guide/provider-ingress). + The repository carries a runnable example for every variant, in two sets that differ only in what they do with the sandbox once it is open. @@ -44,6 +49,26 @@ make coreweave-gpu # COREWEAVE_GPU, default H100 make kaggle-gpu # KAGGLE_GPU, default T4 ``` +## `:examples`, Once You Are In + +Every REPL example answers `:examples` at its prompt: a handful of snippets to +copy straight in, chosen for the sandbox you actually opened. A GPU run offers +device discovery and a timed matmul; a plain one offers state, packages and +files; the Cloudflare one demonstrates its own statelessness and the two ways +round it. + +```text +sandbox(daytona:tan-law-5384)>>> :examples + +# What the GPU is, straight from the driver +import subprocess +print(subprocess.run(["nvidia-smi"], capture_output=True, text=True).stdout) +... +``` + +Each line stands on its own, because the prompt reads one at a time — so a +whole snippet can be pasted at once. + ## What Each One Needs Nothing here is a substitute for the per-variant pages — this is only enough diff --git a/docs/docs/guide/index.mdx b/docs/docs/guide/index.mdx index 4aaea4c..24d184f 100644 --- a/docs/docs/guide/index.mdx +++ b/docs/docs/guide/index.mdx @@ -5,6 +5,10 @@ sidebar_position: 1 # Guide +For Daytona, E2B, and Modal notebook execution, see +[Jupyter over provider ingress](./provider-ingress). It documents the +real-Jupyter transport and direct provider mode. + One package, many places to run code. This page is the one to read before the rest: what Code Sandboxes actually gives you over calling a backend's own SDK, what each backend still decides for you, and how to choose between them. diff --git a/docs/docs/guide/provider-ingress.mdx b/docs/docs/guide/provider-ingress.mdx new file mode 100644 index 0000000..21a8c3b --- /dev/null +++ b/docs/docs/guide/provider-ingress.mdx @@ -0,0 +1,91 @@ +--- +title: Jupyter over Provider Ingress +sidebar_position: 2 +--- + +# Jupyter over provider ingress + +Daytona, E2B, and Modal support two execution modes. New applications should +use **Jupyter over provider ingress**, which is the default. + +## Provider-ingress mode (default) + +The sandbox is allocated first. `code-sandboxes` then checks whether +`jupyter-server` and `ipykernel` are present, installs only what is missing, +starts a real Jupyter Server inside the sandbox, and waits for its port to be +ready. A Jupyter kernel client connects through the provider HTTPS/WebSocket +ingress. + +```text +client + -> provider HTTPS/WSS ingress + -> Jupyter Server in the Daytona, E2B, or Modal sandbox + -> Python kernel +``` + +This mode preserves the complete Jupyter protocol: kernel state, incremental +streaming, rich display messages, interrupts, restarts, comms, and normal +kernel status transitions. Provider ingress credentials and the Jupyter token +are separate and should remain in the server-side client or proxy. + +```python +from code_sandboxes import Sandbox, provider_ingress_execution + +with Sandbox.create(variant="daytona") as provider: + with provider_ingress_execution(provider) as sandbox: + result = sandbox.run_code("print('executed by the real Jupyter kernel')") +``` + +The preparation step is idempotent for a sandbox. Prebuilt provider templates +or snapshots can include Jupyter later to remove installation time; ordinary +base images use the conditional preliminary installation today. + +## Direct provider mode + +The former mode calls the provider execution API directly—Daytona's code +interpreter, E2B's code-interpreter adapter, or Modal's persistent process +driver. Consumers then have to translate those provider results into +Jupyter-like behavior. That translation implements only a subset of the +protocol, but can be useful when only the provider execution API is needed. + +Select it explicitly with `direct=True`: + +```python +with Sandbox.create(variant="daytona") as provider: + with provider_ingress_execution(provider, direct=True) as sandbox: + result = sandbox.run_code("print('direct provider adapter')") +``` + +## REPL and execution examples + +The Daytona, E2B, and Modal examples use provider-ingress mode by default: + +```bash +python examples/exec/daytona_sandbox_example.py +python examples/exec/e2b_sandbox_example.py +python examples/exec/modal_sandbox_example.py + +python examples/repl/daytona_sandbox_example.py +python examples/repl/e2b_sandbox_example.py +python examples/repl/modal_sandbox_example.py +``` + +Pass `--direct` to any of those commands to use the direct provider adapter: + +```bash +python examples/exec/daytona_sandbox_example.py --direct +python examples/repl/modal_sandbox_example.py --direct +``` + +## Datalayer Runtimes URLs + +The Datalayer Runtimes gateway exposes the corresponding choices as stable +base URLs: + +| Mode | External sandbox base URL | +| --- | --- | +| Provider ingress (default) | `/api/runtimes/v1/external/{pod}/jupyter` | +| Direct | `/api/runtimes/v1/external/{pod}` | + +One live sandbox cannot mix the two transports. Close it before changing +mode, otherwise the two clients would disagree about kernel identity. diff --git a/docs/docs/providers/daytona.mdx b/docs/docs/providers/daytona.mdx index 5cde16d..cc718ff 100644 --- a/docs/docs/providers/daytona.mdx +++ b/docs/docs/providers/daytona.mdx @@ -107,10 +107,15 @@ warning, when on-demand capacity needs it. Sandbox.create(variant="daytona", gpu="H100,H200", spot=True) ``` -Spot is GPU-only, and it is built from an image with `auto_delete_interval=0` -so a reclaimed sandbox does not linger. Both are checked here: `spot=True` -without a `gpu=`, or together with a `snapshot=`, is refused with the reason -rather than coming back as an API error. +Spot is GPU-only and is built from an image rather than from a snapshot. Both +are checked here: `spot=True` without a `gpu=`, or together with a `snapshot=`, +is refused with the reason rather than coming back as an API error. + +Every GPU sandbox is created as **ephemeral** — `auto_delete_interval=0`, so it +is deleted rather than kept when it stops. That is Daytona's rule, not this +package's: it refuses any other value with *"GPU sandboxes must be ephemeral"*. +It applies to on-demand GPUs as much as to preemptible ones, so a GPU sandbox +cannot be created detached and picked up later the way a plain one can. An ordered list of GPUs is worth more on spot than anywhere else — what is free changes minute to minute. diff --git a/examples/README.md b/examples/README.md index 0af36f7..b6da9ae 100644 --- a/examples/README.md +++ b/examples/README.md @@ -72,6 +72,26 @@ make modal make monty ``` +The Daytona, E2B, and Modal exec examples include a timed loop that prints the +numbers 1 through 9 one per second. Each number is rendered as it arrives; the +example does not wait for the loop to finish before displaying its output. + +Daytona, E2B, and Modal use Jupyter over provider ingress by default. Use +`--direct` to execute through the provider SDK adapter instead: + +```bash +# Python entry points accept the flag directly. +python daytona_sandbox_example.py --direct +python e2b_sandbox_example.py --direct +python modal_sandbox_example.py --direct + +# GNU Make consumes command-line options itself, so pass example flags through +# the ARGS variable rather than writing `make daytona --direct`. +make daytona ARGS=--direct +make e2b ARGS=--direct +make modal ARGS=--direct +``` + Run REPL examples from `examples/repl/`: ```bash @@ -90,6 +110,13 @@ make modal make monty ``` +The REPL entry points use the same mode selection: + +```bash +python daytona_sandbox_example.py --direct +make daytona ARGS=--direct +``` + Notes by variant: - `cloudflare`: requires `code-sandboxes[cloudflare]` and a deployed sandbox diff --git a/examples/exec/Makefile b/examples/exec/Makefile index cd3f01a..f85dafd 100644 --- a/examples/exec/Makefile +++ b/examples/exec/Makefile @@ -1,6 +1,7 @@ # Code Sandboxes examples PYTHON ?= python +ARGS ?= .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 @@ -36,31 +37,31 @@ kaggle-gpu: ## Kaggle example on a GPU (KAGGLE_GPU flavor, default T4) — batch modal: ## Modal example (CPU) @echo "==> Modal example (CPU)" @echo " auth: MODAL_TOKEN_ID/MODAL_TOKEN_SECRET or ~/.modal.toml" - $(PYTHON) modal_sandbox_example.py + $(PYTHON) modal_sandbox_example.py $(ARGS) modal-gpu: ## Modal example on a GPU (MODAL_GPU flavor, default T4) @echo "==> Modal example (GPU: $${MODAL_GPU:-T4})" @echo " The run fails unless nvidia-smi lists a $${MODAL_GPU:-T4} in the sandbox." - MODAL_GPU=$${MODAL_GPU:-T4} $(PYTHON) modal_sandbox_example.py --gpu "$${MODAL_GPU:-T4}" + MODAL_GPU=$${MODAL_GPU:-T4} $(PYTHON) modal_sandbox_example.py --gpu "$${MODAL_GPU:-T4}" $(ARGS) daytona: ## Daytona example (CPU) @echo "==> Daytona example (CPU)" @echo " auth: DAYTONA_API_KEY, or DAYTONA_JWT_TOKEN with DAYTONA_ORGANIZATION_ID" - $(PYTHON) daytona_sandbox_example.py + $(PYTHON) daytona_sandbox_example.py $(ARGS) daytona-gpu: ## Daytona example on a GPU (DAYTONA_GPU flavor, default H100) @echo "==> Daytona example (GPU: $${DAYTONA_GPU:-H100})" @echo " A GPU sandbox is built from an image rather than the default" @echo " snapshot, so it takes longer to come up. The run fails unless" @echo " nvidia-smi lists a device in the sandbox." - DAYTONA_GPU=$${DAYTONA_GPU:-H100} $(PYTHON) daytona_sandbox_example.py --gpu "$${DAYTONA_GPU:-H100}" + DAYTONA_GPU=$${DAYTONA_GPU:-H100} $(PYTHON) daytona_sandbox_example.py --gpu "$${DAYTONA_GPU:-H100}" $(ARGS) daytona-gpu-spot: ## Daytona example on a SPOT GPU (DAYTONA_GPU flavor, default H100,H200) @echo "==> Daytona example (spot GPU: $${DAYTONA_GPU:-H100,H200})" @echo " Preemptible capacity: cheaper, outside the GPU quota, and" @echo " reclaimed without warning. Creation fails at once when there" @echo " is none free — there is no queue to wait in." - DAYTONA_GPU=$${DAYTONA_GPU:-H100,H200} $(PYTHON) daytona_sandbox_example.py --gpu "$${DAYTONA_GPU:-H100,H200}" --spot + DAYTONA_GPU=$${DAYTONA_GPU:-H100,H200} $(PYTHON) daytona_sandbox_example.py --gpu "$${DAYTONA_GPU:-H100,H200}" --spot $(ARGS) datalayer: $(PYTHON) datalayer_sandbox_example.py @@ -69,7 +70,7 @@ 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 + $(PYTHON) e2b_sandbox_example.py $(ARGS) coreweave: ## CoreWeave example (CPU) @echo "==> CoreWeave example (CPU)" diff --git a/examples/exec/daytona_sandbox_example.py b/examples/exec/daytona_sandbox_example.py index d1a5aed..483fc08 100644 --- a/examples/exec/daytona_sandbox_example.py +++ b/examples/exec/daytona_sandbox_example.py @@ -26,7 +26,7 @@ import argparse import os -from code_sandboxes import Sandbox, show_and_run +from code_sandboxes import Sandbox, provider_ingress_execution, show_and_run def _has_daytona_auth() -> bool: @@ -55,6 +55,11 @@ def _parse_args() -> argparse.Namespace: "quota, and reclaimed without warning. Needs --gpu." ), ) + parser.add_argument( + "--direct", + action="store_true", + help="Execute directly through the Daytona SDK adapter.", + ) return parser.parse_args() @@ -78,7 +83,7 @@ def _gpu_probe_code() -> str: """ -def _verify_gpu(sandbox, gpu: str, *, spot: bool) -> None: +def _verify_gpu(sandbox, provider, gpu: str, *, spot: bool) -> None: """Prove the GPU is there, and say whether spot capacity still holds it.""" probe = show_and_run(sandbox, _gpu_probe_code()).stdout.strip() # A GPU run must PROVE the GPU: the driver present, and at least one @@ -91,7 +96,7 @@ def _verify_gpu(sandbox, gpu: str, *, spot: bool) -> None: if spot: # No warning is given before spot capacity is taken back, so the only # way to know is to ask. - print("spot: reclaimed at", sandbox.preempted_at() or "not yet") + print("spot: reclaimed at", provider.preempted_at() or "not yet") def main() -> None: @@ -112,8 +117,12 @@ def main() -> None: print("Launching daytona sandbox without GPU.") try: - with Sandbox.create(variant="daytona", timeout=60, gpu=args.gpu, spot=args.spot) as sandbox: - print(f"Sandbox: {sandbox.sandbox_id}") + with Sandbox.create( + variant="daytona", timeout=60, gpu=args.gpu, spot=args.spot + ) as provider, provider_ingress_execution( + provider, direct=args.direct + ) as sandbox: + print(f"Sandbox: {provider.sandbox_id}") # What tells this variant apart from a per-snippet runner: the # interpreter holds one namespace, so the second snippet sees what @@ -127,13 +136,19 @@ def main() -> None: ) print("state verified: the namespace is shared between snippets.") + print("-- streaming: one number should appear every second --") + show_and_run( + sandbox, + "import time\nfor i in range(1, 10):\n print(i)\n time.sleep(1)", + ) + # Bytes take the filesystem of the sandbox, not a program that # decodes them. - sandbox.files.write_bytes("/tmp/hello.bin", b"from daytona") - print("file round trip:", sandbox.files.read_bytes("/tmp/hello.bin")) + provider.files.write_bytes("/tmp/hello.bin", b"from daytona") + print("file round trip:", provider.files.read_bytes("/tmp/hello.bin")) if args.gpu: - _verify_gpu(sandbox, args.gpu, spot=args.spot) + _verify_gpu(sandbox, provider, args.gpu, spot=args.spot) # The error path, demonstrated ON PURPOSE: the run must not die, # the failure must come back as a `code_error` on the result. Said diff --git a/examples/exec/e2b_sandbox_example.py b/examples/exec/e2b_sandbox_example.py index 22bd510..0abae6e 100644 --- a/examples/exec/e2b_sandbox_example.py +++ b/examples/exec/e2b_sandbox_example.py @@ -21,7 +21,7 @@ import argparse import os -from code_sandboxes import Sandbox, show_and_run +from code_sandboxes import Sandbox, provider_ingress_execution, show_and_run def _parse_args() -> argparse.Namespace: @@ -36,6 +36,11 @@ def _parse_args() -> argparse.Namespace: "the interpreter this variant drives." ), ) + parser.add_argument( + "--direct", + action="store_true", + help="Execute directly through the E2B code-interpreter adapter.", + ) return parser.parse_args() @@ -49,8 +54,12 @@ def main() -> None: 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}") + with Sandbox.create( + variant="e2b", timeout=60, template=args.template + ) as provider, provider_ingress_execution( + provider, direct=args.direct + ) as sandbox: + print(f"Sandbox: {provider.sandbox_id}") # What tells this variant apart from a per-snippet runner: the # kernel holds one namespace, so the second snippet sees what the @@ -64,6 +73,12 @@ def main() -> None: ) print("state verified: the namespace is shared between snippets.") + print("-- streaming: one number should appear every second --") + show_and_run( + sandbox, + "import time\nfor i in range(1, 10):\n print(i)\n time.sleep(1)", + ) + # 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. @@ -78,17 +93,17 @@ def main() -> None: # 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) + provider.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)) + print("host for port 8000:", provider.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")) + provider.files.write_bytes("/tmp/hello.bin", b"from e2b") + print("file round trip:", provider.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 diff --git a/examples/exec/modal_sandbox_example.py b/examples/exec/modal_sandbox_example.py index a659e68..d59f5b2 100644 --- a/examples/exec/modal_sandbox_example.py +++ b/examples/exec/modal_sandbox_example.py @@ -19,7 +19,7 @@ import os from pathlib import Path -from code_sandboxes import Sandbox, show_and_run +from code_sandboxes import Sandbox, provider_ingress_execution, show_and_run def _has_modal_auth() -> bool: @@ -35,6 +35,11 @@ def _parse_args() -> argparse.Namespace: default=os.environ.get("MODAL_GPU"), help="Optional GPU flavor (for example: T4, A10G, A100, H100).", ) + parser.add_argument( + "--direct", + action="store_true", + help="Execute directly through the Modal process adapter.", + ) return parser.parse_args() @@ -87,9 +92,17 @@ def main() -> None: timeout=60, gpu=gpu, pip_packages=["numpy"], + ) as provider, provider_ingress_execution( + provider, direct=args.direct ) as sandbox: show_and_run(sandbox, "import numpy as np; print(int(np.arange(5).sum()))") + print("-- streaming: one number should appear every second --") + show_and_run( + sandbox, + "import time\nfor i in range(1, 10):\n print(i)\n time.sleep(1)", + ) + if gpu: gpu_result = show_and_run(sandbox, _gpu_probe_code()) probe = gpu_result.stdout.strip() diff --git a/examples/repl/Makefile b/examples/repl/Makefile index c3943f5..8425033 100644 --- a/examples/repl/Makefile +++ b/examples/repl/Makefile @@ -1,6 +1,7 @@ # Code Sandboxes REPL examples PYTHON ?= python +ARGS ?= .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 @@ -42,29 +43,29 @@ kaggle: ## Kaggle REPL — batch job per input by default, live session when RUN modal: ## Modal REPL (CPU) @echo "==> Modal REPL (CPU)" @echo " auth: MODAL_TOKEN_ID/MODAL_TOKEN_SECRET or ~/.modal.toml" - $(PYTHON) modal_sandbox_example.py + $(PYTHON) modal_sandbox_example.py $(ARGS) modal-gpu: ## Modal REPL on a GPU (MODAL_GPU flavor, default T4) @echo "==> Modal REPL (GPU: $${MODAL_GPU:-T4})" @echo " The run fails unless nvidia-smi lists a $${MODAL_GPU:-T4} in the sandbox." - MODAL_GPU=$${MODAL_GPU:-T4} $(PYTHON) modal_sandbox_example.py --gpu "$${MODAL_GPU:-T4}" + MODAL_GPU=$${MODAL_GPU:-T4} $(PYTHON) modal_sandbox_example.py --gpu "$${MODAL_GPU:-T4}" $(ARGS) daytona: ## Daytona REPL (CPU) @echo "==> Daytona REPL (CPU)" @echo " auth: DAYTONA_API_KEY, or DAYTONA_JWT_TOKEN with DAYTONA_ORGANIZATION_ID" @echo " Definitions persist between lines: the interpreter holds one namespace." - $(PYTHON) daytona_sandbox_example.py + $(PYTHON) daytona_sandbox_example.py $(ARGS) daytona-gpu: ## Daytona REPL on a GPU (DAYTONA_GPU flavor, default H100) @echo "==> Daytona REPL (GPU: $${DAYTONA_GPU:-H100}; built from an image, so slower to start)" - DAYTONA_GPU=$${DAYTONA_GPU:-H100} $(PYTHON) daytona_sandbox_example.py --gpu "$${DAYTONA_GPU:-H100}" + DAYTONA_GPU=$${DAYTONA_GPU:-H100} $(PYTHON) daytona_sandbox_example.py --gpu "$${DAYTONA_GPU:-H100}" $(ARGS) daytona-gpu-spot: ## Daytona REPL on a SPOT GPU (DAYTONA_GPU flavor, default H100,H200) @echo "==> Daytona REPL (spot GPU: $${DAYTONA_GPU:-H100,H200})" @echo " Preemptible capacity: cheaper, outside the GPU quota, and" @echo " reclaimed without warning. Creation fails at once when there" @echo " is none free — there is no queue to wait in." - DAYTONA_GPU=$${DAYTONA_GPU:-H100,H200} $(PYTHON) daytona_sandbox_example.py --gpu "$${DAYTONA_GPU:-H100,H200}" --spot + DAYTONA_GPU=$${DAYTONA_GPU:-H100,H200} $(PYTHON) daytona_sandbox_example.py --gpu "$${DAYTONA_GPU:-H100,H200}" --spot $(ARGS) datalayer: $(PYTHON) datalayer_sandbox_example.py @@ -74,7 +75,7 @@ e2b: ## E2B REPL (Firecracker microVM with a Jupyter kernel) @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 + $(PYTHON) e2b_sandbox_example.py $(ARGS) coreweave: ## CoreWeave REPL (CPU) @echo "==> CoreWeave REPL (CPU)" diff --git a/examples/repl/cloudflare_sandbox_example.py b/examples/repl/cloudflare_sandbox_example.py index 8910427..357f69e 100644 --- a/examples/repl/cloudflare_sandbox_example.py +++ b/examples/repl/cloudflare_sandbox_example.py @@ -31,6 +31,46 @@ ) +def _examples() -> list[tuple[str, str]]: + """Snippets worth pasting into this sandbox, for `:examples`.""" + return [ + ( + "This backend is STATELESS — the second line cannot see the first", + """ + x = 21 + """, + ), + ( + "…so it fails. Send what shares state as ONE snippet instead", + """ + x = 21 + x * 2 + """, + ), + ( + "Or keep it in a file: the filesystem DOES persist between snippets", + """ + from pathlib import Path + Path("/workspace/total.txt").write_text("42") + """, + ), + ( + "…and the next snippet reads it back", + """ + from pathlib import Path + int(Path("/workspace/total.txt").read_text()) + """, + ), + ( + "Where this is running", + """ + import platform, sys + platform.node(), platform.platform(), sys.version.split()[0] + """, + ), + ] + + def main() -> None: if not os.environ.get("CLOUDFLARE_SANDBOX_API_URL") or not os.environ.get( "CLOUDFLARE_SANDBOX_API_KEY" @@ -46,7 +86,7 @@ def main() -> None: print(f" {os.environ['CLOUDFLARE_SANDBOX_API_URL']}") try: - with Sandbox.create(variant="cloudflare", timeout=60) as sandbox: + with Sandbox.create(variant="cloudflare", timeout=60, examples=_examples()) 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. diff --git a/examples/repl/coreweave_sandbox_example.py b/examples/repl/coreweave_sandbox_example.py index b3a7550..286235a 100644 --- a/examples/repl/coreweave_sandbox_example.py +++ b/examples/repl/coreweave_sandbox_example.py @@ -38,6 +38,91 @@ def _parse_args() -> argparse.Namespace: return parser.parse_args() +def _examples(gpu: str | None) -> list[tuple[str, str]]: + """Snippets worth pasting into this sandbox, for `:examples`. + + A sandbox with a card in it is worth different lines from one without, so + the GPU set replaces the general one rather than being appended to it. + """ + if gpu: + return [ + ( + "What the GPU is, straight from the driver", + """ + import subprocess + print(subprocess.run(["nvidia-smi"], capture_output=True, text=True).stdout) + """, + ), + ( + "The same from Python, once torch is there", + """ + import torch + torch.cuda.is_available(), torch.cuda.device_count(), torch.cuda.get_device_name(0) + """, + ), + ( + "Install torch if the image has none (a minute or two)", + """ + import subprocess, sys + subprocess.run([sys.executable, "-m", "pip", "install", "-q", "torch"], check=True) + """, + ), + ( + "A workload that actually uses it: a matmul, timed on the device", + """ + import time, torch + a = torch.randn(8192, 8192, device="cuda", dtype=torch.float16) + b = torch.randn(8192, 8192, device="cuda", dtype=torch.float16) + torch.cuda.synchronize(); start = time.perf_counter() + [a @ b for _ in range(10)] and torch.cuda.synchronize() + seconds = (time.perf_counter() - start) / 10 + f"{2 * 8192 ** 3 / seconds / 1e12:.1f} TFLOP/s" + """, + ), + ( + "How much memory the card has, and how much this used", + """ + import torch + free, total = torch.cuda.mem_get_info() + f"{(total - free) / 1e9:.1f} GB used of {total / 1e9:.1f} GB" + """, + ), + ] + return [ + ( + "State is kept between lines", + """ + totals = [1, 2, 3] + totals.append(4) + sum(totals) + """, + ), + ( + "Where this is running", + """ + import platform, sys + platform.node(), platform.platform(), sys.version.split()[0] + """, + ), + ( + "The filesystem is the sandbox's own", + """ + from pathlib import Path + Path("/tmp/notes.txt").write_text("written inside the sandbox") + Path("/tmp/notes.txt").read_text() + """, + ), + ( + "Install a package into the sandbox", + """ + import subprocess, sys + subprocess.run([sys.executable, "-m", "pip", "install", "-q", "httpx"], check=True) + import httpx; httpx.__version__ + """, + ), + ] + + def main() -> None: args = _parse_args() if not os.environ.get("CWSANDBOX_API_KEY"): @@ -57,6 +142,7 @@ def main() -> None: timeout=60, gpu=args.gpu, container_image=args.image, + examples=_examples(args.gpu), ) as sandbox: print(f"Sandbox: {sandbox.sandbox_id}") info = sandbox.info diff --git a/examples/repl/datalayer_sandbox_example.py b/examples/repl/datalayer_sandbox_example.py index 07496bf..bd964ff 100644 --- a/examples/repl/datalayer_sandbox_example.py +++ b/examples/repl/datalayer_sandbox_example.py @@ -6,6 +6,42 @@ from code_sandboxes import Sandbox, run_repl +def _examples() -> list[tuple[str, str]]: + """Snippets worth pasting into this sandbox, for `:examples`.""" + return [ + ( + "State is kept between lines", + """ + totals = [1, 2, 3] + totals.append(4) + sum(totals) + """, + ), + ( + "Where this is running", + """ + import platform, sys + platform.node(), platform.platform(), sys.version.split()[0] + """, + ), + ( + "What the runtime was given", + """ + import os + {k: v for k, v in os.environ.items() if k.startswith("DATALAYER_")} + """, + ), + ( + "The filesystem is the sandbox's own", + """ + from pathlib import Path + Path("/tmp/notes.txt").write_text("written inside the sandbox") + Path("/tmp/notes.txt").read_text() + """, + ), + ] + + def main() -> None: try: environments = Sandbox.list_environments(variant="datalayer") @@ -19,6 +55,7 @@ def main() -> None: variant="datalayer", timeout=60, environment=first_env.name, + examples=_examples(), ) as sandbox: run_repl(sandbox) except Exception as exc: diff --git a/examples/repl/daytona_sandbox_example.py b/examples/repl/daytona_sandbox_example.py index cd43d7b..569c5a5 100644 --- a/examples/repl/daytona_sandbox_example.py +++ b/examples/repl/daytona_sandbox_example.py @@ -17,7 +17,7 @@ import argparse import os -from code_sandboxes import Sandbox, run_repl +from code_sandboxes import Sandbox, provider_ingress_execution, run_repl def _has_daytona_auth() -> bool: @@ -54,9 +54,99 @@ def _parse_args() -> argparse.Namespace: "stopped rather than deleted, so it can be started again." ), ) + parser.add_argument( + "--direct", + action="store_true", + help="Execute directly through the Daytona SDK adapter.", + ) return parser.parse_args() +def _examples(gpu: str | None) -> list[tuple[str, str]]: + """Snippets worth pasting into this sandbox, for `:examples`. + + A sandbox with a card in it is worth different lines from one without, so + the GPU set replaces the general one rather than being appended to it. + """ + if gpu: + return [ + ( + "What the GPU is, straight from the driver", + """ + import subprocess + print(subprocess.run(["nvidia-smi"], capture_output=True, text=True).stdout) + """, + ), + ( + "The same from Python, once torch is there", + """ + import torch + torch.cuda.is_available(), torch.cuda.device_count(), torch.cuda.get_device_name(0) + """, + ), + ( + "Install torch if the image has none (a minute or two)", + """ + import subprocess, sys + subprocess.run([sys.executable, "-m", "pip", "install", "-q", "torch"], check=True) + """, + ), + ( + "A workload that actually uses it: a matmul, timed on the device", + """ + import time, torch + a = torch.randn(8192, 8192, device="cuda", dtype=torch.float16) + b = torch.randn(8192, 8192, device="cuda", dtype=torch.float16) + torch.cuda.synchronize(); start = time.perf_counter() + [a @ b for _ in range(10)] and torch.cuda.synchronize() + seconds = (time.perf_counter() - start) / 10 + f"{2 * 8192 ** 3 / seconds / 1e12:.1f} TFLOP/s" + """, + ), + ( + "How much memory the card has, and how much this used", + """ + import torch + free, total = torch.cuda.mem_get_info() + f"{(total - free) / 1e9:.1f} GB used of {total / 1e9:.1f} GB" + """, + ), + ] + return [ + ( + "State is kept between lines", + """ + totals = [1, 2, 3] + totals.append(4) + sum(totals) + """, + ), + ( + "Where this is running", + """ + import platform, sys + platform.node(), platform.platform(), sys.version.split()[0] + """, + ), + ( + "The filesystem is the sandbox's own", + """ + from pathlib import Path + Path("/tmp/notes.txt").write_text("written inside the sandbox") + Path("/tmp/notes.txt").read_text() + """, + ), + ( + "Install a package into the sandbox", + """ + import subprocess, sys + subprocess.run([sys.executable, "-m", "pip", "install", "-q", "httpx"], check=True) + import httpx; httpx.__version__ + """, + ), + ] + + def main() -> None: args = _parse_args() if not _has_daytona_auth(): @@ -81,8 +171,11 @@ def main() -> None: gpu=args.gpu, spot=args.spot, delete_on_stop=not args.keep, + examples=_examples(args.gpu), + ) as provider, provider_ingress_execution( + provider, direct=args.direct ) as sandbox: - print(f"Sandbox: {sandbox.sandbox_id}") + print(f"Sandbox: {provider.sandbox_id}") run_repl(sandbox) if args.keep: print( diff --git a/examples/repl/docker_sandbox_example.py b/examples/repl/docker_sandbox_example.py index 6882cf7..4fa14c2 100644 --- a/examples/repl/docker_sandbox_example.py +++ b/examples/repl/docker_sandbox_example.py @@ -6,12 +6,57 @@ from code_sandboxes import Sandbox, run_repl +def _examples() -> list[tuple[str, str]]: + """Snippets worth pasting into this sandbox, for `:examples`.""" + return [ + ( + "Where this is running", + """ + import platform, sys + platform.node(), platform.platform(), sys.version.split()[0] + """, + ), + ( + "State is kept between lines", + """ + totals = [1, 2, 3] + totals.append(4) + sum(totals) + """, + ), + ( + "Which image this container came from", + """ + from pathlib import Path + print(Path("/etc/os-release").read_text()) + """, + ), + ( + "The filesystem is the sandbox's own", + """ + from pathlib import Path + Path("/tmp/notes.txt").write_text("written inside the sandbox") + Path("/tmp/notes.txt").read_text() + """, + ), + ( + "Install a package into the sandbox", + """ + import subprocess, sys + subprocess.run([sys.executable, "-m", "pip", "install", "-q", "httpx"], check=True) + import httpx; httpx.__version__ + """, + ), + ] + + def main() -> None: try: with Sandbox.create( variant="docker", timeout=30, image="code-sandboxes-jupyter:latest", + examples=_examples(), ) as sandbox: run_repl(sandbox) except ModuleNotFoundError as exc: diff --git a/examples/repl/e2b_sandbox_example.py b/examples/repl/e2b_sandbox_example.py index 94e02cd..cd0850b 100644 --- a/examples/repl/e2b_sandbox_example.py +++ b/examples/repl/e2b_sandbox_example.py @@ -19,7 +19,7 @@ import argparse import os -from code_sandboxes import Sandbox, run_repl +from code_sandboxes import Sandbox, provider_ingress_execution, run_repl def _parse_args() -> argparse.Namespace: @@ -44,9 +44,62 @@ def _parse_args() -> argparse.Namespace: "somebody is still typing at." ), ) + parser.add_argument( + "--direct", + action="store_true", + help="Execute directly through the E2B code-interpreter adapter.", + ) return parser.parse_args() +def _examples() -> list[tuple[str, str]]: + """Snippets worth pasting into this sandbox, for `:examples`.""" + return [ + ( + "State is kept between lines", + """ + totals = [1, 2, 3] + totals.append(4) + sum(totals) + """, + ), + ( + "The one backend that answers with rich outputs: this is an image", + """ + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + fig, ax = plt.subplots() + ax.plot([1, 4, 9, 16], marker="o") + ax.set_title("returned as a PNG, not as text") + fig + """, + ), + ( + "And an HTML repr comes back as HTML", + """ + import pandas as pd + pd.DataFrame({"variant": ["e2b", "daytona"], "state": [True, True]}) + """, + ), + ( + "Where this is running", + """ + import platform, sys + platform.node(), platform.platform(), sys.version.split()[0] + """, + ), + ( + "The filesystem is the sandbox's own", + """ + from pathlib import Path + Path("/tmp/notes.txt").write_text("written inside the sandbox") + Path("/tmp/notes.txt").read_text() + """, + ), + ] + + def main() -> None: args = _parse_args() if not os.environ.get("E2B_API_KEY"): @@ -57,11 +110,15 @@ def main() -> None: 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}") + with Sandbox.create( + variant="e2b", timeout=60, template=args.template, examples=_examples() + ) as provider, provider_ingress_execution( + provider, direct=args.direct + ) as sandbox: + print(f"Sandbox: {provider.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) + provider.set_timeout(args.minutes * 60) run_repl(sandbox) except Exception as exc: print("e2b REPL failed:", exc) diff --git a/examples/repl/eval_sandbox_example.py b/examples/repl/eval_sandbox_example.py index 6ea1aec..1a0ca97 100644 --- a/examples/repl/eval_sandbox_example.py +++ b/examples/repl/eval_sandbox_example.py @@ -6,8 +6,36 @@ from code_sandboxes import Sandbox, run_repl +def _examples() -> list[tuple[str, str]]: + """Snippets worth pasting into this sandbox, for `:examples`.""" + return [ + ( + "State is kept between lines", + """ + totals = [1, 2, 3] + totals.append(4) + sum(totals) + """, + ), + ( + "Where this is running", + """ + import platform, sys + platform.node(), platform.platform(), sys.version.split()[0] + """, + ), + ( + "It isolates NOTHING — this is your own process and your own disk", + """ + import os + os.getcwd(), len(os.listdir(".")) + """, + ), + ] + + def main() -> None: - with Sandbox.create(variant="eval", timeout=30) as sandbox: + with Sandbox.create(variant="eval", timeout=30, examples=_examples()) as sandbox: run_repl(sandbox) diff --git a/examples/repl/google_colab_sandbox_example.py b/examples/repl/google_colab_sandbox_example.py index 50e06fe..9a5e3e3 100644 --- a/examples/repl/google_colab_sandbox_example.py +++ b/examples/repl/google_colab_sandbox_example.py @@ -15,6 +15,42 @@ def _require(name: str) -> str: return value +def _examples() -> list[tuple[str, str]]: + """Snippets worth pasting into this sandbox, for `:examples`.""" + return [ + ( + "State is kept between lines", + """ + totals = [1, 2, 3] + totals.append(4) + sum(totals) + """, + ), + ( + "Where this is running", + """ + import platform, sys + platform.node(), platform.platform(), sys.version.split()[0] + """, + ), + ( + "What the Colab runtime was given, GPU included when there is one", + """ + import subprocess + print(subprocess.run(["nvidia-smi"], capture_output=True, text=True).stdout or "no GPU") + """, + ), + ( + "The filesystem is the sandbox's own", + """ + from pathlib import Path + Path("/tmp/notes.txt").write_text("written inside the sandbox") + Path("/tmp/notes.txt").read_text() + """, + ), + ] + + def main() -> None: try: runtime_url = _require("RUNTIME_URL") @@ -27,6 +63,7 @@ def main() -> None: server_url=runtime_url, kernel_id=runtime_id, proxy_token=runtime_proxy_token, + examples=_examples(), ) as sandbox: run_repl(sandbox) except Exception as exc: diff --git a/examples/repl/jupyter_server_sandbox_example.py b/examples/repl/jupyter_server_sandbox_example.py index f790add..4f5f744 100644 --- a/examples/repl/jupyter_server_sandbox_example.py +++ b/examples/repl/jupyter_server_sandbox_example.py @@ -6,9 +6,49 @@ from code_sandboxes import Sandbox, run_repl +def _examples() -> list[tuple[str, str]]: + """Snippets worth pasting into this sandbox, for `:examples`.""" + return [ + ( + "State is kept between lines", + """ + totals = [1, 2, 3] + totals.append(4) + sum(totals) + """, + ), + ( + "Where this is running", + """ + import platform, sys + platform.node(), platform.platform(), sys.version.split()[0] + """, + ), + ( + "A real kernel, so a figure comes back as a figure", + """ + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + fig, ax = plt.subplots() + ax.plot([1, 4, 9, 16]) + fig + """, + ), + ( + "The filesystem is the sandbox's own", + """ + from pathlib import Path + Path("/tmp/notes.txt").write_text("written inside the sandbox") + Path("/tmp/notes.txt").read_text() + """, + ), + ] + + def main() -> None: try: - with Sandbox.create(variant="jupyter-server", timeout=30) as sandbox: + with Sandbox.create(variant="jupyter-server", timeout=30, examples=_examples()) as sandbox: run_repl(sandbox) except ModuleNotFoundError as exc: print("jupyter sandbox is not available:", exc) diff --git a/examples/repl/kaggle_sandbox_example.py b/examples/repl/kaggle_sandbox_example.py index a131dd7..357068a 100644 --- a/examples/repl/kaggle_sandbox_example.py +++ b/examples/repl/kaggle_sandbox_example.py @@ -18,6 +18,43 @@ from code_sandboxes import Sandbox, run_repl +def _examples() -> list[tuple[str, str]]: + """Snippets worth pasting into this sandbox, for `:examples`.""" + return [ + ( + "State is kept between lines", + """ + totals = [1, 2, 3] + totals.append(4) + sum(totals) + """, + ), + ( + "Where this is running", + """ + import platform, sys + platform.node(), platform.platform(), sys.version.split()[0] + """, + ), + ( + "Which accelerator the session got, if KAGGLE_GPU asked for one", + """ + import subprocess + smi = subprocess.run(["nvidia-smi"], capture_output=True, text=True) + print(smi.stdout or "CPU session") + """, + ), + ( + "The datasets a Kaggle session mounts", + """ + from pathlib import Path + mounted = Path("/kaggle/input") + [p.name for p in mounted.iterdir()] if mounted.exists() else [] + """, + ), + ] + + def main() -> None: channels_url = os.environ.get("RUNTIME_CHANNELS_URL") runtime_url = os.environ.get("RUNTIME_URL") @@ -65,7 +102,7 @@ def main() -> None: print(f"accelerator: {kwargs['gpu']} — every batch job runs") print("with it, and queues longer than a CPU one.") - with Sandbox.create(variant="kaggle", **kwargs) as sandbox: + with Sandbox.create(variant="kaggle", **kwargs, examples=_examples()) as sandbox: run_repl(sandbox) except Exception as exc: print("kaggle REPL failed:", exc) diff --git a/examples/repl/modal_sandbox_example.py b/examples/repl/modal_sandbox_example.py index b9d27a0..a3400b1 100644 --- a/examples/repl/modal_sandbox_example.py +++ b/examples/repl/modal_sandbox_example.py @@ -7,7 +7,7 @@ import os from pathlib import Path -from code_sandboxes import Sandbox, run_repl +from code_sandboxes import Sandbox, provider_ingress_execution, run_repl def _has_modal_auth() -> bool: @@ -23,9 +23,99 @@ def _parse_args() -> argparse.Namespace: default=os.environ.get("MODAL_GPU"), help="Optional GPU flavor (for example: T4, A10G, A100, H100).", ) + parser.add_argument( + "--direct", + action="store_true", + help="Execute directly through the Modal process adapter.", + ) return parser.parse_args() +def _examples(gpu: str | None) -> list[tuple[str, str]]: + """Snippets worth pasting into this sandbox, for `:examples`. + + A sandbox with a card in it is worth different lines from one without, so + the GPU set replaces the general one rather than being appended to it. + """ + if gpu: + return [ + ( + "What the GPU is, straight from the driver", + """ + import subprocess + print(subprocess.run(["nvidia-smi"], capture_output=True, text=True).stdout) + """, + ), + ( + "The same from Python, once torch is there", + """ + import torch + torch.cuda.is_available(), torch.cuda.device_count(), torch.cuda.get_device_name(0) + """, + ), + ( + "Install torch if the image has none (a minute or two)", + """ + import subprocess, sys + subprocess.run([sys.executable, "-m", "pip", "install", "-q", "torch"], check=True) + """, + ), + ( + "A workload that actually uses it: a matmul, timed on the device", + """ + import time, torch + a = torch.randn(8192, 8192, device="cuda", dtype=torch.float16) + b = torch.randn(8192, 8192, device="cuda", dtype=torch.float16) + torch.cuda.synchronize(); start = time.perf_counter() + [a @ b for _ in range(10)] and torch.cuda.synchronize() + seconds = (time.perf_counter() - start) / 10 + f"{2 * 8192 ** 3 / seconds / 1e12:.1f} TFLOP/s" + """, + ), + ( + "How much memory the card has, and how much this used", + """ + import torch + free, total = torch.cuda.mem_get_info() + f"{(total - free) / 1e9:.1f} GB used of {total / 1e9:.1f} GB" + """, + ), + ] + return [ + ( + "State is kept between lines", + """ + totals = [1, 2, 3] + totals.append(4) + sum(totals) + """, + ), + ( + "Where this is running", + """ + import platform, sys + platform.node(), platform.platform(), sys.version.split()[0] + """, + ), + ( + "The filesystem is the sandbox's own", + """ + from pathlib import Path + Path("/tmp/notes.txt").write_text("written inside the sandbox") + Path("/tmp/notes.txt").read_text() + """, + ), + ( + "Install a package into the sandbox", + """ + import subprocess, sys + subprocess.run([sys.executable, "-m", "pip", "install", "-q", "httpx"], check=True) + import httpx; httpx.__version__ + """, + ), + ] + + def main() -> None: args = _parse_args() if not _has_modal_auth(): @@ -43,6 +133,9 @@ def main() -> None: variant="modal", timeout=60, gpu=args.gpu, + examples=_examples(args.gpu), + ) as provider, provider_ingress_execution( + provider, direct=args.direct ) as sandbox: run_repl(sandbox) except Exception as exc: diff --git a/examples/repl/monty_sandbox_example.py b/examples/repl/monty_sandbox_example.py index 2ec4703..879216c 100644 --- a/examples/repl/monty_sandbox_example.py +++ b/examples/repl/monty_sandbox_example.py @@ -6,9 +6,39 @@ from code_sandboxes import Sandbox, run_repl +def _examples() -> list[tuple[str, str]]: + """Snippets worth pasting into this sandbox, for `:examples`.""" + return [ + ( + "State is kept between lines", + """ + totals = [1, 2, 3] + totals.append(4) + sum(totals) + """, + ), + ( + "Pure computation is what this interpreter is for", + """ + fib = lambda n: n if n < 2 else fib(n - 1) + fib(n - 2) + [fib(n) for n in range(12)] + """, + ), + ( + "What it refuses: there is no filesystem and no network here", + """ + import os + os.listdir("/") + """, + ), + ] + + def main() -> None: try: - with Sandbox.create(variant="monty", timeout=30, name="monty1") as sandbox: + with Sandbox.create( + variant="monty", timeout=30, name="monty1", examples=_examples() + ) as sandbox: run_repl(sandbox) except ModuleNotFoundError as exc: print("monty sandbox is not available:", exc) diff --git a/pyproject.toml b/pyproject.toml index 8ea13af..c0d387d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ # Readable names for sandboxes nobody named — what a person # reads in the runtimes table instead of a bare identifier. "faker", - "jupyter-kernel-client", + "jupyter-kernel-client>=1.0.2", "jupyter-server", "jupyter-server-client", "pydantic>=2.0", @@ -40,23 +40,23 @@ code-sandboxes = "code_sandboxes.cli:main" cloudflare = ["httpx>=0.27"] coreweave = ["cwsandbox>=1.6"] datalayer = ["agent_runtimes>=1.0.16"] -daytona = ["daytona>=0.100"] +daytona = ["daytona>=0.205.1"] docker = ["docker>=6.0"] -e2b = ["e2b-code-interpreter>=2.0"] +e2b = ["e2b-code-interpreter>=2.9.1"] google-colab = [] kaggle = ["kaggle>=1.6"] monty = ["pydantic-monty"] -modal = ["modal>=0.64"] +modal = ["modal>=1.5.2"] all = [ "agent_runtimes", "cwsandbox>=1.6", - "daytona>=0.100", + "daytona>=0.205.1", "docker>=6.0", - "e2b-code-interpreter>=2.0", + "e2b-code-interpreter>=2.9.1", "httpx>=0.27", "kaggle>=1.6", "pydantic-monty", - "modal>=0.64", + "modal>=1.5.2", ] test = [ "ipykernel", diff --git a/tests/test_console.py b/tests/test_console.py index cd2be59..bb0e83f 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -13,6 +13,7 @@ from __future__ import annotations import builtins +from types import SimpleNamespace import pytest from rich.console import Console @@ -167,6 +168,25 @@ def test_show_and_run_prints_the_code_then_the_answer(): assert " 1 + 1" in rendered +def test_show_and_run_prints_callback_output_before_execution_returns(): + console, _ = _console() + + class StreamingSandbox(_FakeSandbox): + def run_code(self, code: str, **kwargs) -> ExecutionResult: + kwargs["on_stdout"](OutputMessage(line="first")) + # The first event is visible while execution is still in progress. + assert "first" in console.export_text(clear=False) + kwargs["on_stdout"](OutputMessage(line="second")) + return _result(stdout="first\nsecond") + + result = show_and_run(StreamingSandbox(), "stream()", console=console) + + assert result.stdout == "first\nsecond" + rendered = _rendered(console) + assert rendered.count(" first") == 1 + assert rendered.count(" second") == 1 + + # --- The prompt ----------------------------------------------------------- @@ -203,6 +223,126 @@ def test_every_exit_command_leaves(monkeypatch, command): assert sandbox.ran == [] +def test_examples_prints_the_snippets_and_runs_none_of_them(monkeypatch): + """`:examples` is for a person with a cursor: it shows, it does not run.""" + console, _ = _console() + sandbox = _FakeSandbox() + _typing(monkeypatch, ":examples", ":exit") + + run_repl( + sandbox, + console=console, + banner=False, + examples=[("Discover the GPU", "import torch\ntorch.cuda.get_device_name(0)")], + ) + + rendered = _rendered(console) + assert "# 1. Discover the GPU" in rendered + assert "torch.cuda.get_device_name(0)" in rendered + # Shown, never executed: the sandbox was not asked to run a thing. + assert sandbox.ran == [] + + +def test_examples_come_from_the_sandbox_they_were_created_with(monkeypatch): + """Declared once at creation; the prompt needs no arrangement of its own.""" + console, _ = _console() + sandbox = _FakeSandbox() + sandbox.config = SimpleNamespace(examples=[("From the config", "1 + 1")]) + _typing(monkeypatch, ":examples", ":exit") + + run_repl(sandbox, console=console, banner=False) + + assert "# 1. From the config" in _rendered(console) + + +def test_an_example_can_be_run_by_its_number(monkeypatch): + """`:examples:2` is for a reader who wants the answer, not the paste.""" + console, _ = _console() + sandbox = _FakeSandbox({"second()": _result(text="ok")}) + _typing(monkeypatch, ":examples:2", ":exit") + + run_repl( + sandbox, + console=console, + banner=False, + examples=[("First", "first()"), ("Second", "second()")], + ) + + # Run, and shown before it ran: an answer with no visible cause is worse. + assert sandbox.ran == ["second()"] + assert " second()" in _rendered(console) + + +def test_a_number_that_is_not_an_example_is_refused_without_running_anything(monkeypatch): + console, _ = _console() + sandbox = _FakeSandbox() + _typing(monkeypatch, ":examples:9", ":examples:x", ":exit") + + run_repl(sandbox, console=console, banner=False, examples=[("Only one", "1")]) + + assert sandbox.ran == [] + assert sum("no example" in line for line in _rendered(console)) == 2 + + +def test_brackets_in_a_snippet_survive_being_printed(monkeypatch): + """Rich reads `[...]` as a style tag, so a type hint would print mangled — + and be wrong exactly where someone was about to copy it.""" + console, _ = _console() + _typing(monkeypatch, ":examples", ":exit") + + run_repl( + _FakeSandbox(), + console=console, + banner=False, + examples=[("Types", "items: list[str] = []\nitems[0:1]")], + ) + + rendered = _rendered(console) + assert "items: list[str] = []" in rendered + assert "items[0:1]" in rendered + + +def test_a_prompt_with_no_examples_says_so_rather_than_printing_nothing(monkeypatch): + console, _ = _console() + _typing(monkeypatch, ":examples", ":exit") + + run_repl(_FakeSandbox(), console=console, banner=False) + + assert "This sandbox ships no examples." in _rendered(console) + + +def test_the_help_names_examples_only_when_there_are_some(monkeypatch): + """An empty command in the help is worse than no mention of it.""" + with_examples, _ = _console() + _typing(monkeypatch, ":help", ":exit") + run_repl(_FakeSandbox(), console=with_examples, banner=False, examples=[("A", "1")]) + assert any(":examples" in line for line in _rendered(with_examples)) + + without, _ = _console() + _typing(monkeypatch, ":help", ":exit") + run_repl(_FakeSandbox(), console=without, banner=False) + assert not any(":examples" in line for line in _rendered(without)) + + +def test_a_snippet_is_printed_dedented_so_it_can_be_pasted(monkeypatch): + """It is copied straight into the prompt, so leading indentation would + reach the interpreter as an IndentationError.""" + console, _ = _console() + _typing(monkeypatch, ":examples", ":exit") + + run_repl( + _FakeSandbox(), + console=console, + banner=False, + examples=[("Indented in the source", "\n x = 21\n x * 2\n")], + ) + + rendered = _rendered(console) + # Flush left, exactly as it must arrive at the prompt. + assert "x = 21" in rendered + assert not any(line.startswith(" ") and line.strip() == "x = 21" for line in rendered) + + def test_the_prompt_runs_what_is_typed_and_shows_the_answer(monkeypatch): console, _ = _console() sandbox = _FakeSandbox({"1 + 1": _result(text="2")}) diff --git a/tests/test_datalayer_sandbox.py b/tests/test_datalayer_sandbox.py new file mode 100644 index 0000000..9dc5baa --- /dev/null +++ b/tests/test_datalayer_sandbox.py @@ -0,0 +1,75 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Reaching a Datalayer deployment, and saying so when it cannot be reached. + +Both things pinned here failed in production the same way: something moved in +a package this one talks to, and what the user was told pointed somewhere +else entirely. +""" + +from __future__ import annotations + +import inspect + +import pytest + +from code_sandboxes.datalayer_sandbox import DatalayerSandbox, _urls_for_run +from code_sandboxes.exceptions import SandboxConfigurationError +from code_sandboxes.models import SandboxConfig + +#: Importing the SDK warns — about its coming move to platformdirs, about +#: pydantic's class-based config. Neither is what these tests are about, and +#: the suite turns warnings into errors. +pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning") + + +def test_every_service_the_sdk_knows_about_points_at_the_one_origin(): + """A run serves all of its services from a single host. + + Read off the SDK rather than from a list written here: the list went + stale when `mcp_server_url` was renamed, and every execution died on the + unexpected keyword — a failure with no visible connection to the rename. + """ + from datalayer_core.utils.urls import DatalayerURLs + + urls = _urls_for_run("https://prod1.datalayer.run/") + + services = [ + name + for name in inspect.signature(DatalayerURLs.from_environment).parameters + if name.endswith("_url") + ] + assert services, "the SDK declares no service URLs; this test is testing nothing" + for name in services: + assert getattr(urls, name) == "https://prod1.datalayer.run" + + +def test_a_backend_that_cannot_be_imported_says_what_actually_failed(monkeypatch): + """The reason, not the usual reason. + + The message named the missing package and the command that installs it + whatever the import error said. With the package installed and one name + moved inside it, that sent the reader to reinstall a dependency that was + already there. + """ + import builtins + + real_import = builtins.__import__ + + def refuse(name, *args, **kwargs): + if name.startswith("agent_runtimes"): + raise ImportError("cannot import name 'DEFAULT_TIME_RESERVATION'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", refuse) + + sandbox = DatalayerSandbox(SandboxConfig()) + with pytest.raises(SandboxConfigurationError) as raised: + sandbox.start() + + message = str(raised.value) + assert "DEFAULT_TIME_RESERVATION" in message + # The old advice is still there for the case where it IS the answer. + assert "code-sandboxes[datalayer]" in message diff --git a/tests/test_daytona.py b/tests/test_daytona.py index d262169..eee1b6b 100644 --- a/tests/test_daytona.py +++ b/tests/test_daytona.py @@ -594,6 +594,28 @@ def test_asking_for_resources_creates_from_an_image(): assert from_image.resources.cpu == 2 +def test_a_gpu_sandbox_is_asked_for_as_ephemeral_even_without_spot(): + """Daytona refuses a GPU sandbox that outlives its stop. + + "GPU sandboxes must be ephemeral; set autoDeleteInterval to 0" — of every + GPU sandbox, not only the preemptible ones, which is where this used to be + set. An on-demand `gpu=` was therefore refused by the API on creation. + """ + daytona = pytest.importorskip("daytona") + + on_demand = _started(SandboxConfig(gpu="H100"))._create_params(daytona) + assert on_demand.auto_delete_interval == 0 + assert not getattr(on_demand, "spot", None) + + preemptible = _started(SandboxConfig(gpu="H100"), spot=True)._create_params(daytona) + assert preemptible.auto_delete_interval == 0 + assert preemptible.spot is True + + # A sandbox with no GPU is left alone: it may outlive its stop. + plain = _started(SandboxConfig())._create_params(daytona) + assert getattr(plain, "auto_delete_interval", None) != 0 + + def test_only_the_client_settings_that_were_given_are_passed_on(): """What is left out is what the SDK reads from the environment.""" daytona = pytest.importorskip("daytona") diff --git a/tests/test_jupyter_ingress.py b/tests/test_jupyter_ingress.py new file mode 100644 index 0000000..da88962 --- /dev/null +++ b/tests/test_jupyter_ingress.py @@ -0,0 +1,97 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Provider-independent Jupyter ingress preparation and credentials.""" + +# ruff: noqa: S106 + +from types import SimpleNamespace + +from code_sandboxes import JupyterServerOptions +from code_sandboxes.daytona_sandbox import DaytonaSandbox +from code_sandboxes.e2b_sandbox import E2BSandbox +from code_sandboxes.jupyter_ingress import preparation_command +from code_sandboxes.modal_sandbox import ModalSandbox + + +class _Result: + exit_code = 0 + result = "" + stderr = "" + + +class _Process: + returncode = 0 + stderr = SimpleNamespace(read=lambda: "") + + def wait(self): + return 0 + + +def test_preparation_checks_before_installing_and_waits_for_readiness(): + command = preparation_command(JupyterServerOptions(port=9999, token="secret")) + + assert "import jupyter_server, ipykernel" in command + assert "|| python -m pip install" in command + assert "--ServerApp.port=9999" in command + assert "socket.create_connection(('127.0.0.1', 9999)" in command + + +def test_daytona_uses_preview_ingress_and_caches_preparation(): + calls = [] + remote = SimpleNamespace( + process=SimpleNamespace(exec=lambda command, timeout: calls.append(command) or _Result()), + get_preview_link=lambda port: SimpleNamespace( + url=f"https://daytona.example/{port}", token="preview-secret" + ), + ) + sandbox = DaytonaSandbox() + sandbox._sandbox = remote + sandbox._started = True + + endpoint = sandbox.prepare_jupyter_server(JupyterServerOptions(token="jupyter-secret")) + again = sandbox.prepare_jupyter_server() + + assert endpoint is again + assert len(calls) == 1 + assert endpoint.websocket_url == "wss://daytona.example/8888" + assert endpoint.headers == {"X-Daytona-Preview-Token": "preview-secret"} + assert endpoint.query == {"token": "jupyter-secret"} + + +def test_e2b_uses_traffic_access_token(): + calls = [] + remote = SimpleNamespace( + commands=SimpleNamespace(run=lambda command, timeout: calls.append(command) or _Result()), + get_host=lambda port: f"{port}-sandbox.e2b.app", + traffic_access_token="traffic-secret", + ) + sandbox = E2BSandbox() + sandbox._sandbox = remote + sandbox._started = True + + endpoint = sandbox.prepare_jupyter_server(JupyterServerOptions(token="jupyter-secret")) + + assert len(calls) == 1 + assert endpoint.http_url == "https://8888-sandbox.e2b.app" + assert endpoint.headers == {"E2B-Traffic-Access-Token": "traffic-secret"} + + +def test_modal_keeps_connect_and_jupyter_credentials_separate(): + calls = [] + remote = SimpleNamespace( + exec=lambda *args, **kwargs: calls.append((args, kwargs)) or _Process(), + create_connect_token=lambda port: SimpleNamespace( + url=f"https://modal.example/{port}/", token="connect-secret" + ), + ) + sandbox = ModalSandbox() + sandbox._sandbox = remote + sandbox._started = True + + endpoint = sandbox.prepare_jupyter_server(JupyterServerOptions(token="jupyter-secret")) + + assert len(calls) == 1 + assert endpoint.headers == {"Authorization": "Bearer connect-secret"} + assert endpoint.query == {"token": "jupyter-secret"} diff --git a/tests/test_jupyter_server.py b/tests/test_jupyter_server.py index 6c45161..8b14416 100644 --- a/tests/test_jupyter_server.py +++ b/tests/test_jupyter_server.py @@ -6,12 +6,15 @@ import os import sys +import threading +import time import types import uuid from pathlib import Path import pytest +from code_sandboxes.exceptions import SandboxConfigurationError from code_sandboxes.jupyter_server_sandbox import JupyterServerSandbox from code_sandboxes.models import SandboxConfig @@ -174,6 +177,43 @@ def test_local_jupyter_persistence(self, tmp_path: Path): finally: sandbox.stop() + def test_iopub_outputs_are_forwarded_while_execution_is_running(self): + events: list[str] = [] + + class StreamingClient: + def execute_interactive(self, code, timeout, output_hook): + assert code == "print('first'); print('second')" + output_hook( + { + "header": {"msg_type": "stream"}, + "content": {"name": "stdout", "text": "first\n"}, + } + ) + assert events == ["first"] + output_hook( + { + "header": {"msg_type": "stream"}, + "content": {"name": "stdout", "text": "second\n"}, + } + ) + return {"content": {"status": "ok", "execution_count": 7}} + + sandbox = JupyterServerSandbox.__new__(JupyterServerSandbox) + sandbox._started = True + sandbox._client = StreamingClient() + sandbox._interrupt_requested = threading.Event() + sandbox._executing_event = threading.Event() + sandbox.config = SandboxConfig(timeout=60) + + result = sandbox.run_code( + "print('first'); print('second')", + on_stdout=lambda message: events.append(message.line), + ) + + assert events == ["first", "second"] + assert result.stdout == "first\nsecond" + assert result.execution_count == 7 + def _kernel_client_stub(captured: dict): """Build a JupyterKernelClient stub that records the kwargs it was built with.""" @@ -270,3 +310,46 @@ def test_owned_server_still_generates_a_token(): sandbox = JupyterServerSandbox() assert sandbox._token + + +class TestAServerThatWillNotStart: + """What the user is told when the Jupyter Server never comes up. + + Both streams went to `DEVNULL`, so a server that died on the way up — + a module not installed, a port already taken — explained itself into + nothing, and the caller waited out the whole timeout to be told + "Timed out waiting for Jupyter Server". The reason was there all along. + """ + + def _sandbox(self, returncode, said): + from collections import deque + + sandbox = JupyterServerSandbox.__new__(JupyterServerSandbox) + sandbox._server_url = "http://127.0.0.1:1" + sandbox._token = "not-a-secret" # noqa: S105 - a stand-in, not a credential + sandbox._headers = {} + sandbox._server_output = deque(said) + sandbox._server_process = type( + "P", (), {"poll": lambda self: returncode, "returncode": returncode} + )() + return sandbox + + def test_a_dead_server_is_reported_at_once_with_what_it_said(self): + sandbox = self._sandbox(1, ["ModuleNotFoundError: No module named 'jupyter_server'"]) + + started = time.time() + with pytest.raises(SandboxConfigurationError) as raised: + sandbox._wait_for_server(timeout=30) + + assert time.time() - started < 5, "it waited out the timeout" + assert "exited with code 1" in str(raised.value) + assert "No module named 'jupyter_server'" in str(raised.value) + + def test_a_server_still_running_is_waited_for_and_then_quoted(self): + sandbox = self._sandbox(None, ["[W] something looked wrong"]) + + with pytest.raises(SandboxConfigurationError) as raised: + sandbox._wait_for_server(timeout=1) + + assert "Timed out" in str(raised.value) + assert "something looked wrong" in str(raised.value) diff --git a/tests/test_provider_ingress.py b/tests/test_provider_ingress.py new file mode 100644 index 0000000..304c091 --- /dev/null +++ b/tests/test_provider_ingress.py @@ -0,0 +1,64 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Selection of real Jupyter ingress versus direct provider execution.""" + +from types import SimpleNamespace + +from code_sandboxes import JupyterServerEndpoint, SandboxConfig +from code_sandboxes.provider_ingress import provider_ingress_execution + + +class _Provider: + config = SandboxConfig() + + def __init__(self): + self.prepared = 0 + + def prepare_jupyter_server(self, options): + self.prepared += 1 + return JupyterServerEndpoint( + port=8888, + http_url="https://provider.example", + websocket_url="wss://provider.example", + headers={"X-Provider-Token": "secret"}, + query={"token": "jupyter-secret"}, + ) + + +def test_direct_mode_keeps_the_provider_adapter(): + provider = _Provider() + + with provider_ingress_execution(provider, direct=True) as execution: + assert execution is provider + + assert provider.prepared == 0 + + +def test_default_connects_a_jupyter_sandbox(monkeypatch): + calls = [] + + class _Jupyter: + def __init__(self, **kwargs): + calls.append(SimpleNamespace(kind="init", kwargs=kwargs)) + + def start(self): + calls.append(SimpleNamespace(kind="start")) + + def stop(self): + calls.append(SimpleNamespace(kind="stop")) + + monkeypatch.setattr( + "code_sandboxes.provider_ingress.JupyterServerSandbox", _Jupyter + ) + provider = _Provider() + + with provider_ingress_execution(provider) as execution: + assert isinstance(execution, _Jupyter) + + assert provider.prepared == 1 + assert [call.kind for call in calls] == ["init", "start", "stop"] + assert calls[0].kwargs["server_url"] == "https://provider.example" + assert calls[0].kwargs["headers"] == {"X-Provider-Token": "secret"} + assert calls[0].kwargs["token"] == "jupyter-secret" diff --git a/tests/test_providers.py b/tests/test_providers.py index da95537..3091f65 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -129,3 +129,21 @@ def test_availability_is_read_from_the_secrets_given_not_from_the_process(): # available anywhere, and the credentialed ones are not. assert "eval" in names assert "kaggle" not in names + + +def test_a_provider_carries_the_mark_it_is_drawn_with(): + """The icon travels with the provider, so every surface draws one thing. + + The operator copies it onto the environments it serves and the web looks + the component up by it; naming it here is what keeps a Daytona sandbox + looking like Daytona in the CLI, the listing and the table alike. + """ + catalog = {entry["name"]: entry for entry in provider_catalog({})} + + assert catalog["daytona"]["icon"] == "daytona" + assert catalog["e2b"]["icon"] == "e2b" + assert catalog["kaggle"]["icon"] == "kaggle" + assert catalog["modal"]["icon"] == "modal" + # No mark for it in the set yet, which is said as nothing rather than as + # a slug that resolves to whatever the reader keeps for the unknown. + assert catalog["cloudflare"]["icon"] is None