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/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index 775923d..7068a7b 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 @@ -216,6 +218,7 @@ "TunnelInfo", "VariableNotFoundError", "available_providers", + "example_code", "execution_result_to_reply", "get_manager", "get_provider", @@ -227,5 +230,6 @@ "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..4f542f4 100644 --- a/code_sandboxes/__version__.py +++ b/code_sandboxes/__version__.py @@ -3,4 +3,4 @@ """Code Sandboxes.""" -__version__ = "1.1.2" +__version__ = "1.1.4" diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index db0a876..e401ebd 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -220,6 +220,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 +284,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..a9be7f7 100644 --- a/code_sandboxes/console.py +++ b/code_sandboxes/console.py @@ -19,6 +19,8 @@ from __future__ import annotations +from collections.abc import Sequence +from textwrap import dedent from typing import TYPE_CHECKING, Any from rich.console import Console @@ -29,10 +31,12 @@ __all__ = [ "EXIT_COMMANDS", + "example_code", "repl_prompt", "run_repl", "show_and_run", "show_code", + "show_examples", "show_result", ] @@ -144,7 +148,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 +158,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,8 +255,26 @@ 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) 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..e1b38d1 100644 --- a/code_sandboxes/daytona_sandbox.py +++ b/code_sandboxes/daytona_sandbox.py @@ -50,6 +50,7 @@ SandboxEnvironment, SandboxInfo, SandboxStatus, + gpu_memory, ) logger = logging.getLogger(__name__) @@ -216,6 +217,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 @@ -322,6 +332,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 +344,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 +411,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 +435,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 +454,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. diff --git a/code_sandboxes/jupyter_server_sandbox.py b/code_sandboxes/jupyter_server_sandbox.py index 59a8f38..081e5c3 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. 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..a3076cd 100644 --- a/code_sandboxes/modal_sandbox.py +++ b/code_sandboxes/modal_sandbox.py @@ -39,6 +39,7 @@ SandboxEnvironment, SandboxInfo, SandboxStatus, + gpu_memory, ) logger = logging.getLogger(__name__) @@ -193,6 +194,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"}, ), ] diff --git a/code_sandboxes/models.py b/code_sandboxes/models.py index fd2324c..02bb93f 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. 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..0aa730b 100644 --- a/docs/docs/examples/index.mdx +++ b/docs/docs/examples/index.mdx @@ -44,6 +44,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/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/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..4e82788 100644 --- a/examples/repl/daytona_sandbox_example.py +++ b/examples/repl/daytona_sandbox_example.py @@ -57,6 +57,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 _has_daytona_auth(): @@ -81,6 +166,7 @@ def main() -> None: gpu=args.gpu, spot=args.spot, delete_on_stop=not args.keep, + examples=_examples(args.gpu), ) as sandbox: print(f"Sandbox: {sandbox.sandbox_id}") run_repl(sandbox) 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..971a6b0 100644 --- a/examples/repl/e2b_sandbox_example.py +++ b/examples/repl/e2b_sandbox_example.py @@ -47,6 +47,54 @@ def _parse_args() -> argparse.Namespace: 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,7 +105,9 @@ 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: + with Sandbox.create( + variant="e2b", timeout=60, template=args.template, examples=_examples() + ) as sandbox: print(f"Sandbox: {sandbox.sandbox_id}") # A REPL is read at human speed, and the default life of a sandbox # is shorter than a session usually is. 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..45a204b 100644 --- a/examples/repl/modal_sandbox_example.py +++ b/examples/repl/modal_sandbox_example.py @@ -26,6 +26,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 _has_modal_auth(): @@ -43,6 +128,7 @@ def main() -> None: variant="modal", timeout=60, gpu=args.gpu, + examples=_examples(args.gpu), ) 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/tests/test_console.py b/tests/test_console.py index cd2be59..c0fc45d 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 @@ -203,6 +204,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_server.py b/tests/test_jupyter_server.py index 6c45161..8d5c3a6 100644 --- a/tests/test_jupyter_server.py +++ b/tests/test_jupyter_server.py @@ -6,12 +6,14 @@ import os import sys +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 @@ -270,3 +272,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_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