Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions code_sandboxes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -216,6 +218,7 @@
"TunnelInfo",
"VariableNotFoundError",
"available_providers",
"example_code",
"execution_result_to_reply",
"get_manager",
"get_provider",
Expand All @@ -227,5 +230,6 @@
"run_repl",
"show_and_run",
"show_code",
"show_examples",
"show_result",
]
2 changes: 1 addition & 1 deletion code_sandboxes/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@

"""Code Sandboxes."""

__version__ = "1.1.2"
__version__ = "1.1.4"
7 changes: 7 additions & 0 deletions code_sandboxes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
90 changes: 86 additions & 4 deletions code_sandboxes/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,10 +31,12 @@

__all__ = [
"EXIT_COMMANDS",
"example_code",
"repl_prompt",
"run_repl",
"show_and_run",
"show_code",
"show_examples",
"show_result",
]

Expand Down Expand Up @@ -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.",
Expand All @@ -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:
Expand All @@ -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)
Expand Down
49 changes: 26 additions & 23 deletions code_sandboxes/datalayer_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
35 changes: 30 additions & 5 deletions code_sandboxes/daytona_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
SandboxEnvironment,
SandboxInfo,
SandboxStatus,
gpu_memory,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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},
),
]
Expand Down Expand Up @@ -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:
Expand All @@ -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 "
Expand All @@ -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.
Expand Down
Loading
Loading