From 81ff8bda4e2cf908abd9bba14de775b0993e9f90 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 20 Aug 2026 19:01:53 +0200 Subject: [PATCH 1/7] feat: daytona --- CHANGELOG.md | 11 + README.md | 1 + code_sandboxes/__init__.py | 3 + code_sandboxes/base.py | 12 +- code_sandboxes/cli.py | 6 +- code_sandboxes/daytona_sandbox.py | 630 +++++++++++++++++++++++ code_sandboxes/manage.py | 127 ++++- code_sandboxes/models.py | 1 + code_sandboxes/providers.py | 26 + docs/docs/cli/index.mdx | 3 + docs/docs/cli/management.mdx | 4 +- docs/docs/examples/index.mdx | 8 + docs/docs/index.mdx | 1 + docs/docs/installation/index.mdx | 8 + docs/docs/sandboxes/datalayer.mdx | 2 +- docs/docs/sandboxes/daytona.mdx | 156 ++++++ docs/docs/sandboxes/index.mdx | 4 +- examples/README.md | 6 + examples/exec/Makefile | 16 +- examples/exec/daytona_sandbox_example.py | 127 +++++ examples/repl/Makefile | 14 +- examples/repl/daytona_sandbox_example.py | 84 +++ pyproject.toml | 2 + tests/test_daytona.py | 502 ++++++++++++++++++ tests/test_manage.py | 1 + 25 files changed, 1742 insertions(+), 13 deletions(-) create mode 100644 code_sandboxes/daytona_sandbox.py create mode 100644 docs/docs/sandboxes/daytona.mdx create mode 100644 examples/exec/daytona_sandbox_example.py create mode 100644 examples/repl/daytona_sandbox_example.py create mode 100644 tests/test_daytona.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4e3ae..3e5af29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ ## Unreleased +- Added the `daytona` sandbox variant (`DaytonaSandbox`), running code in a + [Daytona](https://www.daytona.io/docs/) cloud sandbox. It drives the + sandbox's code interpreter rather than `process.code_run`, so state persists + between calls and `create_context()` gives a namespace Daytona keeps apart. + The value of a trailing expression is captured and returned as + `ExecutionResult.text`, which the interpreter itself does not report. GPUs, + cpu/memory and the network policy map onto Daytona's own settings; binary + files go through its filesystem API. Authenticate with `DAYTONA_API_KEY` (or + `DAYTONA_JWT_TOKEN` with `DAYTONA_ORGANIZATION_ID`) and install with + `pip install code-sandboxes[daytona]`. `get_manager("daytona")` answers the + CRUD verbs over an organization's sandboxes. - Added the `kaggle` sandbox variant (`KaggleSandbox`) to connect to a Kaggle interactive notebook runtime via `jupyter-kernel-client`'s `KaggleKernelClient`. Authenticate with a Kaggle API token (`token` argument or diff --git a/README.md b/README.md index 7063f62..4e315ab 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Canonical variant names: - `google_colab` - `jupyter` - `kaggle` +- `daytona` - `modal` - `monty` diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index bfdaefe..b584014 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -20,6 +20,7 @@ Cloud container sandboxes: - ModalSandbox: Modal cloud containers, per-snippet process execution + - DaytonaSandbox: Daytona cloud sandboxes, stateful Python interpreter Features: - Code execution with streaming support @@ -61,6 +62,7 @@ from .client import CodeExecutionOutcome, CodeSandboxClient, execution_result_to_reply from .commands import CommandResult, ProcessHandle, SandboxCommands from .datalayer_sandbox import DatalayerSandbox +from .daytona_sandbox import DaytonaSandbox from .docker_sandbox import DockerSandbox from .eval_sandbox import EvalSandbox from .exceptions import ( @@ -146,6 +148,7 @@ "Context", "ContextNotFoundError", "DatalayerSandbox", + "DaytonaSandbox", "DockerSandbox", # Sandbox implementations "EvalSandbox", diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index 4a38cf2..76af498 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -324,6 +324,10 @@ def create( # noqa: C901 from .modal_sandbox import ModalSandbox sandbox = ModalSandbox(config=config, **kwargs) + elif variant_value == "daytona": + from .daytona_sandbox import DaytonaSandbox + + sandbox = DaytonaSandbox(config=config, **kwargs) else: raise ValueError( f"Unknown sandbox variant: {variant}. " @@ -396,6 +400,10 @@ def list_environments( from .modal_sandbox import ModalSandbox return ModalSandbox.list_environments() + if variant_value == "daytona": + from .daytona_sandbox import DaytonaSandbox + + return DaytonaSandbox.list_environments() if variant_value == "kaggle": from .kaggle_sandbox import KaggleSandbox @@ -410,8 +418,8 @@ def list_environments( return DatalayerSandbox.list_environments(**kwargs) raise ValueError( f"Unknown sandbox variant: {variant}. " - "Supported variants: eval, docker, jupyter-server, monty, modal, " - "kaggle, google_colab, datalayer" + "Supported variants: " + + ", ".join(sorted(v.value for v in SandboxVariant)) ) @classmethod diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py index bec35c1..08d344a 100644 --- a/code_sandboxes/cli.py +++ b/code_sandboxes/cli.py @@ -30,6 +30,7 @@ "google-colab", "kaggle", "modal", + "daytona", "datalayer", } @@ -134,7 +135,7 @@ def _resolve_variant_kwargs( if run_url: kwargs["run_url"] = run_url - if variant in {"modal", "datalayer", "kaggle"} and gpu: + if variant in {"modal", "daytona", "datalayer", "kaggle"} and gpu: kwargs["gpu"] = gpu return kwargs @@ -218,7 +219,7 @@ def repl( "-v", help=( "Sandbox variant (jupyter, docker, eval, monty, " - "google_colab/google-colab, kaggle, modal, datalayer)." + "google_colab/google-colab, kaggle, modal, daytona, datalayer)." ), ), timeout: float = typer.Option(60.0, help="Default code execution timeout (seconds)."), @@ -237,6 +238,7 @@ def repl( help=( "GPU flavor / accelerator for supported variants " "(modal/datalayer examples: T4, A10G, A100, H100; " + "daytona examples: H100, H200, RTX-4090; " "kaggle examples: NvidiaTeslaT4, NvidiaTeslaP100, or aliases T4/P100)." ), ), diff --git a/code_sandboxes/daytona_sandbox.py b/code_sandboxes/daytona_sandbox.py new file mode 100644 index 0000000..9476316 --- /dev/null +++ b/code_sandboxes/daytona_sandbox.py @@ -0,0 +1,630 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Daytona sandbox implementation. + +`Daytona `_ runs code in cloud sandboxes that +start in well under a second. This variant drives one through its CODE +INTERPRETER — ``sandbox.code_interpreter`` — and not through +``sandbox.process.code_run``: the interpreter holds a namespace per context, +so ``x = 1`` in one call and ``print(x)`` in the next behave the way they do +in every other variant of this package, while ``code_run`` is a fresh process +per snippet and would not. + +What the interpreter answers with is stdout, stderr, and the error when the +code raised — there is no execute_result on the wire. The value of a trailing +expression is therefore captured here rather than lost (see +:func:`_capture_trailing_value`). Rich display data — a figure, an HTML repr — +has no channel at all, and is not reported. +""" + +from __future__ import annotations + +import ast +import json +import logging +import math +import textwrap +import time +from typing import Any + +from .base import Sandbox +from .exceptions import ( + SandboxConfigurationError, + SandboxExecutionError, + SandboxNotStartedError, + VariableNotFoundError, +) +from .models import ( + CodeError, + Context, + ExecutionResult, + Logs, + OutputHandler, + OutputMessage, + ResourceConfig, + Result, + SandboxConfig, + SandboxEnvironment, + SandboxInfo, + SandboxStatus, +) + +logger = logging.getLogger(__name__) + +#: What every sandbox this package creates is labelled with, so the ones it +#: made can be told from the rest of an organization's. +CREATED_BY_LABEL = "code-sandboxes" + +#: The line the capture below writes, and the only line `run_code` takes back +#: out of the stream. Long and specific on purpose: a line of the code's own +#: output that began with it would be read as a value and disappear. +_VALUE_MARKER = "__code_sandboxes_daytona_value__:" + +#: Names the capture binds inside the sandbox. Prefixed rather than short: +#: they share a namespace with everything the caller defines. +_VALUE_VAR = "_code_sandboxes_value" +_JSON_MOD = "_code_sandboxes_json" +_SYS_MOD = "_code_sandboxes_sys" + + +def _emit_text(expression: str) -> str: + """Code writing the string `expression` evaluates to, on one line. + + JSON-encoded, so a value whose text runs over several lines still arrives + as ONE line of stdout — which is what makes it separable from what the + code itself printed. + + Written through ``sys.stdout`` rather than ``print``, and through modules + imported under prefixed names: the code that just ran is free to have + rebound ``print``, ``sys`` or ``json`` to anything it likes. The names are + dropped again afterwards — the namespace they were bound in is the one the + caller goes on working in, and `dir()` should not answer with ours. + """ + return ( + f"import json as {_JSON_MOD}, sys as {_SYS_MOD}\n" + f"{_SYS_MOD}.stdout.write({_VALUE_MARKER!r} + {_JSON_MOD}.dumps({expression}) + '\\n')\n" + f"{_SYS_MOD}.stdout.flush()\n" + f"del {_JSON_MOD}, {_SYS_MOD}\n" + ) + + +def _split_marker(line: str) -> tuple[str, str | None]: + """The line as the caller should see it, and the value it carried. + + The marker is looked for ANYWHERE in the line, not only at its start. It + is written last and nothing puts a newline in front of it, so code that + left the stream mid-line — ``sys.stdout.write("x")``, a `print` with + ``end=""`` — has the marker land right after its own text. Reading the + line whole would have lost the value and shown the marker to the caller. + """ + at = line.find(_VALUE_MARKER) + if at < 0: + return line, None + try: + value = json.loads(line[at + len(_VALUE_MARKER) :]) + except ValueError: + return line, None + if not isinstance(value, str): + return line, None + return line[:at], value + + +def _capture_trailing_value(code: str) -> str: + """Make the value of a trailing expression readable, or leave the code alone. + + The interpreter of Daytona reports what the code PRINTED; the value of a + last expression — the ``2`` of a cell holding ``1 + 1`` — is evaluated and + dropped. Every other variant of this package answers with it, and the REPL + and the tables that read `ExecutionResult.text` expect it, so it is asked + for: the expression is bound to a name and its repr written to stdout + behind a marker, which :meth:`DaytonaSandbox.run_code` takes back out. + + What comes BEFORE the expression is passed through untouched, byte for + byte, and the expression stays on the line it was written on. That is the + point of slicing the source rather than rewriting the tree: a traceback + naming line 7 has to still mean line 7 of what the caller submitted. + """ + try: + tree = ast.parse(code) + except SyntaxError: + # Not ours to report: the sandbox raises it, with its own message and + # its own position. + return code + if not tree.body or not isinstance(tree.body[-1], ast.Expr): + return code + last = tree.body[-1] + if any( + isinstance(node, (ast.Await, ast.Yield, ast.YieldFrom, ast.Starred)) + for node in ast.walk(last.value) + ): + # Binding these to a name means something else, or does not parse at + # all. The code runs as written, and answers with its output alone. + return code + + lines = code.splitlines(keepends=True) + line = lines[last.lineno - 1] + # `col_offset` counts the BYTES of the utf-8 line, not its characters. + column = len(line.encode()[: last.col_offset].decode(errors="ignore")) + start = sum(len(each) for each in lines[: last.lineno - 1]) + column + head, expression = code[:start], code[start:] + return ( + f"{head}{_VALUE_VAR} = ({expression})\n" + f"if {_VALUE_VAR} is not None:\n" + f"{textwrap.indent(_emit_text(f'repr({_VALUE_VAR})'), ' ')}" + f"del {_VALUE_VAR}\n" + ) + + +class _Lines: + """Whole lines out of a stream that arrives in chunks. + + The interpreter sends stdout as it is written, which cuts wherever the + write did — mid-line as readily as at its end. A marker can only be + recognised on a complete line, and an `OutputMessage` is a line, so the + remainder of a chunk is held until the rest of it turns up. + """ + + def __init__(self) -> None: + self._pending = "" + + def feed(self, chunk: str) -> list[str]: + self._pending += chunk + *complete, self._pending = self._pending.split("\n") + return complete + + def flush(self) -> list[str]: + """Whatever never got its newline, once the execution is over.""" + rest, self._pending = self._pending, "" + return [rest] if rest else [] + + +def _gpu_type(flavor: str, daytona: Any) -> Any: + """The Daytona GPU of that name, or a refusal naming the ones there are. + + The flavours differ from one provider to the next — a `T4` is Modal's + vocabulary, not Daytona's — so a name that means nothing here is said so + at once, rather than reaching the API as an invalid enum. + """ + wanted = flavor.strip().upper().replace("_", "-") + offered = [ + candidate + for candidate in daytona.GpuType + if not candidate.value.lower().startswith("unknown") + ] + for candidate in offered: + if candidate.value.upper() == wanted: + return candidate + raise SandboxConfigurationError( + f"Daytona has no GPU called {flavor!r}. It offers: " + + ", ".join(candidate.value for candidate in offered) + + "." + ) + + +def _import_daytona() -> Any: + try: + import daytona + except ImportError as exc: + raise SandboxConfigurationError( + "daytona is required for DaytonaSandbox. Install it with: " + "pip install code-sandboxes[daytona]" + ) from exc + return daytona + + +class DaytonaSandbox(Sandbox): + """Sandbox backed by a Daytona cloud sandbox. + + Args: + config: Optional sandbox configuration. + api_key: Daytona API key. Read from ``DAYTONA_API_KEY`` when omitted. + api_url: Daytona API URL. Read from ``DAYTONA_API_URL`` when omitted, + which itself defaults to ``https://app.daytona.io/api``. + target: Region the sandbox runs in. ``DAYTONA_TARGET`` when omitted. + jwt_token: The other way of authenticating, with ``organization_id``. + organization_id: Organization the JWT belongs to. + snapshot: Name of the Daytona snapshot to create from. The default + snapshot of the organization when omitted. + image: A ``daytona.Image`` to create from instead of a snapshot. + Resources — cpu, memory, a GPU — can only be asked for of an + image, so asking for any of them builds one when none is given. + python_version: Python of that image. Daytona's own default when + omitted. + delete_on_stop: Whether :meth:`stop` DELETES the sandbox, which is the + default and what a ``with`` block should do, or merely stops it — + leaving it in the organization, to be started again. + """ + + def __init__( + self, + config: SandboxConfig | None = None, + api_key: str | None = None, + api_url: str | None = None, + target: str | None = None, + jwt_token: str | None = None, + organization_id: str | None = None, + snapshot: str | None = None, + image: Any | None = None, + python_version: str | None = None, + delete_on_stop: bool = True, + **kwargs, + ): + super().__init__(config) + self._api_key = api_key + self._api_url = api_url + self._target = target + self._jwt_token = jwt_token + self._organization_id = organization_id + self._snapshot = snapshot + self._image = image + self._python_version = python_version + self._delete_on_stop = delete_on_stop + self._daytona: Any | None = None + self._sandbox: Any | None = None + #: The Daytona interpreter context standing for each of ours, made on + #: first use — creating one is a round trip, and most callers use the + #: default namespace and never need a second. + self._contexts: dict[str, Any] = {} + self._execution_count = 0 + self._extra_kwargs = kwargs + + @classmethod + def list_environments(cls) -> list[SandboxEnvironment]: + """The environments this provider ships. + + Daytona takes a machine specification per sandbox rather than a + catalogue of named ones, so what is offered here are the two shapes + worth naming — a plain sandbox, and one with a GPU attached — and + choosing an environment stays what it is everywhere else: choosing + between named things. + """ + return [ + SandboxEnvironment( + name="daytona-default", + title="Daytona", + language="python", + owner="daytona", + visibility="cloud", + burning_rate=0.0, + metadata={"variant": "daytona", "gpu": None}, + ), + SandboxEnvironment( + name="daytona-gpu", + title="Daytona GPU", + language="python", + owner="daytona", + visibility="cloud", + burning_rate=0.0, + metadata={"variant": "daytona", "gpu": "H100"}, + ), + ] + + def start(self) -> None: + if self._started: + return + + daytona = _import_daytona() + self._daytona = daytona.Daytona(self._client_config(daytona)) + self._sandbox = self._daytona.create(self._create_params(daytona)) + + self._default_context = self.create_context("default") + self._info = SandboxInfo( + id=self._sandbox.id, + variant="daytona", + status=SandboxStatus.RUNNING, + created_at=time.time(), + name=self.config.name, + metadata={ + "daytona_sandbox_id": self._sandbox.id, + "snapshot": getattr(self._sandbox, "snapshot", None), + "target": getattr(self._sandbox, "target", None), + }, + resources=ResourceConfig( + cpu=getattr(self._sandbox, "cpu", None), + memory=getattr(self._sandbox, "memory", None), + gpu=getattr(self._sandbox, "gpu_type", None), + ), + config=self.config, + ) + self._started = True + + def _client_config(self, daytona: Any) -> Any | None: + """The client settings that were given, and nothing more. + + A field left out is a field the SDK reads from the environment — + ``DAYTONA_API_KEY`` and the rest — so passing ``None`` for everything + would not be the same as passing nothing. + """ + settings = { + "api_key": self._api_key, + "api_url": self._api_url, + "target": self._target, + "jwt_token": self._jwt_token, + "organization_id": self._organization_id, + } + given = {key: value for key, value in settings.items() if value} + return daytona.DaytonaConfig(**given) if given else None + + def _create_params(self, daytona: Any) -> Any: + """What to ask Daytona for, from the configuration of this sandbox.""" + common: dict[str, Any] = {"labels": self._labels()} + if self.config.env_vars: + common["env_vars"] = dict(self.config.env_vars) + if self.config.idle_timeout: + common["auto_stop_interval"] = max(1, round(self.config.idle_timeout / 60)) + if self.config.max_lifetime: + common["ttl_minutes"] = max(1, round(self.config.max_lifetime / 60)) + common.update(self._network_params()) + + resources = self._resources(daytona) + if self._image is not None or resources is not None: + image = self._image + if image is None: + image = ( + daytona.Image.debian_slim(self._python_version) + if self._python_version + else daytona.Image.debian_slim() + ) + return daytona.CreateSandboxFromImageParams( + image=image, resources=resources, **common + ) + return daytona.CreateSandboxFromSnapshotParams(snapshot=self._snapshot, **common) + + def _labels(self) -> dict[str, str]: + """The metadata the sandbox carries in Daytona. + + The NAME of the sandbox goes here rather than into Daytona's own + ``name``: that one is unique within an organization and is how a + sandbox is addressed, so a second sandbox asking for a name already + taken is a conflict rather than a second sandbox. Ours are generated, + and a caller is free to repeat one. + """ + labels = {"created-by": CREATED_BY_LABEL} + if self.config.name: + labels["name"] = self.config.name + labels.update(self._tags) + return labels + + def _network_params(self) -> dict[str, Any]: + """What the network policy of the configuration means to Daytona.""" + policy = self.config.network_policy + if policy == "none": + return {"network_block_all": True} + if policy == "allowlist": + if not self.config.allowed_hosts: + raise SandboxConfigurationError( + "network_policy='allowlist' needs allowed_hosts: a sandbox " + "allowed nothing is a sandbox with no network at all, which " + "is network_policy='none'." + ) + return {"domain_allow_list": ",".join(self.config.allowed_hosts)} + return {} + + def _resources(self, daytona: Any) -> Any | None: + """The machine asked for, or nothing when the defaults will do.""" + cpu = int(self.config.cpu_limit) if self.config.cpu_limit else None + memory = None + if self.config.memory_limit: + # Bytes here, whole GiB there — and never zero, which would be a + # sandbox with no memory rather than one with the default. + memory = max(1, round(self.config.memory_limit / 1024**3)) + gpu_type = _gpu_type(self.config.gpu, daytona) if self.config.gpu else None + if cpu is None and memory is None and gpu_type is None: + return None + return daytona.Resources( + cpu=cpu, + memory=memory, + gpu=1 if gpu_type is not None else None, + gpu_type=gpu_type, + ) + + def stop(self) -> None: + if not self._started: + return + if self._sandbox is not None: + try: + if self._delete_on_stop: + self._sandbox.delete() + else: + self._sandbox.stop() + except Exception: + logger.debug( + "Ignoring error while stopping the Daytona sandbox", exc_info=True + ) + self._sandbox = None + self._daytona = None + self._contexts.clear() + self._started = False + if self._info: + self._info.status = SandboxStatus.STOPPED + + def _interpreter_context(self, context: Context | None) -> Any | None: + """The Daytona context one of ours stands for, made on first use. + + ``None`` — and the default context, which is the same namespace — + is the shared one of the sandbox. Anything else is a context Daytona + keeps apart, so :meth:`create_context` really does isolate. + """ + if context is None or context.id == "default": + return None + existing = self._contexts.get(context.id) + if existing is None: + existing = self._sandbox.code_interpreter.create_context(cwd=context.cwd) + self._contexts[context.id] = existing + return existing + + def run_code( # noqa: C901 + self, + code: str, + language: str = "python", + context: Context | None = None, + on_stdout: OutputHandler[OutputMessage] | None = None, + on_stderr: OutputHandler[OutputMessage] | None = None, + on_result: OutputHandler[Result] | None = None, + on_error: OutputHandler[CodeError] | None = None, + envs: dict[str, str] | None = None, + timeout: float | None = None, + ) -> ExecutionResult: + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + if language != "python": + raise ValueError(f"DaytonaSandbox only supports Python, got: {language}") + + started_at = time.time() + self._execution_count += 1 + + values: list[str] = [] + stdout_messages: list[OutputMessage] = [] + stderr_messages: list[OutputMessage] = [] + stdout_lines, stderr_lines = _Lines(), _Lines() + + def take_stdout(line: str) -> None: + # The marker is OURS: the caller never sees it, neither streamed + # nor in the logs it reads afterwards. What shared its line does + # reach them — that part is the code's own output. + text, value = _split_marker(line) + if value is not None: + values.append(value) + if not text: + return + message = OutputMessage(line=text, timestamp=time.time(), error=False) + stdout_messages.append(message) + if on_stdout: + on_stdout(message) + + def take_stderr(line: str) -> None: + message = OutputMessage(line=line, timestamp=time.time(), error=True) + stderr_messages.append(message) + if on_stderr: + on_stderr(message) + + def feed_stdout(chunk: Any) -> None: + for line in stdout_lines.feed(chunk.output): + take_stdout(line) + + def feed_stderr(chunk: Any) -> None: + for line in stderr_lines.feed(chunk.output): + take_stderr(line) + + seconds = timeout if timeout is not None else self.config.timeout + try: + reply = self._sandbox.code_interpreter.run_code( + _capture_trailing_value(code), + context=self._interpreter_context(context), + on_stdout=feed_stdout, + on_stderr=feed_stderr, + envs=envs, + # Daytona counts in whole seconds, and reads 0 as "no limit". + timeout=max(0, math.ceil(seconds)), + ) + except Exception as error: + return ExecutionResult( + execution_ok=False, + execution_error=f"Failed to execute code on Daytona: {error}", + started_at=started_at, + completed_at=time.time(), + context_id=context.id if context else "default", + ) + + for line in stdout_lines.flush(): + take_stdout(line) + for line in stderr_lines.flush(): + take_stderr(line) + + results: list[Result] = [] + for text in values: + value = Result(data={"text/plain": text}, is_main_result=True) + results.append(value) + if on_result: + on_result(value) + + code_error: CodeError | None = None + if reply.error is not None: + code_error = CodeError( + name=reply.error.name or "Error", + value=reply.error.value or "", + traceback=reply.error.traceback or "", + ) + if on_error: + on_error(code_error) + + return ExecutionResult( + results=results, + logs=Logs(stdout=stdout_messages, stderr=stderr_messages), + execution_ok=True, + code_error=code_error, + execution_count=self._execution_count, + context_id=context.id if context else "default", + started_at=started_at, + completed_at=time.time(), + ) + + def _do_interrupt(self) -> bool: + """Daytona's interpreter takes no interrupt; a timeout is the only stop.""" + return False + + def _get_internal_variable(self, name: str, context: Context | None = None) -> Any: + """The value of a variable, carried back as JSON. + + A Daytona sandbox is a machine of its own: what comes back is what can + be encoded, and anything that cannot arrives as its repr rather than + raising — a partial answer being more use than none for the reading + this serves, which is `commands.run` and the filesystem. + """ + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + execution = self.run_code( + _emit_text(f"{_JSON_MOD}.dumps({name}, default=repr)"), context=context + ) + if not execution.execution_ok: + raise SandboxExecutionError( + execution.execution_error or "Sandbox execution failed" + ) + if execution.code_error is not None or execution.text is None: + raise VariableNotFoundError(name) + return json.loads(execution.text) + + def _set_internal_variable( + self, name: str, value: Any, context: Context | None = None + ) -> None: + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + try: + payload = json.dumps(value) + except TypeError as error: + raise SandboxConfigurationError( + f"A Daytona sandbox runs elsewhere, so {name!r} has to cross as " + "JSON and this value cannot be encoded. Build it inside the " + "sandbox with run_code instead." + ) from error + execution = self.run_code( + f"import json as {_JSON_MOD}\n" + f"{name} = {_JSON_MOD}.loads({payload!r})\n" + f"del {_JSON_MOD}\n", + context=context, + ) + if not execution.execution_ok: + raise SandboxExecutionError( + execution.execution_error or "Sandbox execution failed" + ) + if execution.code_error is not None: + raise SandboxExecutionError(str(execution.code_error)) + + def _write_file(self, path: str, content: bytes) -> None: + """Straight to the filesystem of the sandbox, not through the code. + + The base class writes a file by running a snippet that base64-decodes + it, which is the only way when all a variant has is an interpreter. + Daytona has a filesystem API, so a large file does not have to become + a large program. + """ + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + self._sandbox.fs.upload_file(content, path) + + def _read_file(self, path: str) -> bytes: + if not self._started or self._sandbox is None: + raise SandboxNotStartedError() + return self._sandbox.fs.download_file(path) or b"" diff --git a/code_sandboxes/manage.py b/code_sandboxes/manage.py index f4d0ed9..d92aebe 100644 --- a/code_sandboxes/manage.py +++ b/code_sandboxes/manage.py @@ -632,6 +632,127 @@ def create(self, **kwargs: Any) -> SandboxInfo: return info +#: What Daytona calls the life of a sandbox, in the words used here. A state +#: this does not name is one added after this was written: reported as +#: pending rather than guessed at. +_DAYTONA_STATES = { + "started": SandboxStatus.RUNNING, + "creating": SandboxStatus.STARTING, + "starting": SandboxStatus.STARTING, + "restoring": SandboxStatus.STARTING, + "pulling_snapshot": SandboxStatus.STARTING, + "building_snapshot": SandboxStatus.STARTING, + "pending_build": SandboxStatus.STARTING, + "stopping": SandboxStatus.STOPPING, + "pausing": SandboxStatus.STOPPING, + "archiving": SandboxStatus.STOPPING, + "destroying": SandboxStatus.STOPPING, + "stopped": SandboxStatus.STOPPED, + "archived": SandboxStatus.STOPPED, + "destroyed": SandboxStatus.TERMINATED, + "error": SandboxStatus.ERROR, + "build_failed": SandboxStatus.ERROR, +} + + +class DaytonaSandboxManager(SandboxManager): + """Sandboxes of a Daytona organization.""" + + variant = "daytona" + capabilities = frozenset({"create", "list", "get", "update", "delete"}) + + def __init__( + self, + api_key: str | None = None, + api_url: str | None = None, + target: str | None = None, + **_: Any, + ) -> None: + self._settings = {"api_key": api_key, "api_url": api_url, "target": target} + + def _client(self) -> Any: + try: + import daytona + except ImportError as exc: + raise SandboxManagementError( + "daytona package is required: pip install code-sandboxes[daytona]" + ) from exc + given = {key: value for key, value in self._settings.items() if value} + return daytona.Daytona(daytona.DaytonaConfig(**given) if given else None) + + def _info(self, sandbox: Any) -> SandboxInfo: + labels = dict(getattr(sandbox, "labels", None) or {}) + state = getattr(sandbox, "state", None) + state_value = getattr(state, "value", state) + return SandboxInfo( + id=sandbox.id, + variant=self.variant, + status=_DAYTONA_STATES.get(str(state_value), SandboxStatus.PENDING), + # The name a person gave it, which Daytona carries as a label: + # its own `name` is an address and has to stay unique. + name=labels.get("name") or getattr(sandbox, "name", None), + metadata={ + "state": state_value, + "labels": labels, + "snapshot": getattr(sandbox, "snapshot", None), + "target": getattr(sandbox, "target", None), + }, + ) + + def list(self) -> list[SandboxInfo]: + return [self._info(sandbox) for sandbox in self._client().list()] + + def get(self, sandbox_id: str) -> SandboxInfo | None: + try: + return self._info(self._client().get(sandbox_id)) + except Exception: + return None + + def delete(self, sandbox_id: str) -> bool: + client = self._client() + try: + sandbox = client.get(sandbox_id) + except Exception: + return False + client.delete(sandbox) + return True + + def update( + self, sandbox_id: str, tags: dict[str, str] | None = None, **_: Any + ) -> SandboxInfo: + """Set labels on the sandbox — what Daytona changes on a running one.""" + if not tags: + raise self._unsupported("update without tags=...", "only labels change") + try: + sandbox = self._client().get(sandbox_id) + except Exception as exc: + raise SandboxManagementError( + f"No daytona sandbox found: {sandbox_id}" + ) from exc + # Daytona REPLACES the label set, so what is there is kept and the + # tags given are written over it — an update of one tag is not a + # deletion of the others. + sandbox.set_labels({**(dict(sandbox.labels or {})), **tags}) + info = self.get(sandbox_id) + if info is None: + raise SandboxManagementError(f"No daytona sandbox found: {sandbox_id}") + return info + + def create(self, **kwargs: Any) -> SandboxInfo: + from .daytona_sandbox import DaytonaSandbox + + given = {key: value for key, value in self._settings.items() if value} + # Detached, so it outlives this call: stopping it is `delete`. + sandbox = DaytonaSandbox(delete_on_stop=False, **given, **kwargs) + sandbox.start() + info = sandbox.info + if info is None: + raise SandboxManagementError("The sandbox started without an identity.") + sandbox._sandbox = None + sandbox._started = False + return info + + class DatalayerSandboxManager(SandboxManager): """Runtimes of the Datalayer platform, through ``agent_runtimes``.""" @@ -738,6 +859,7 @@ def create(self, **kwargs: Any) -> SandboxInfo: "google_colab": GoogleColabSandboxManager, "kaggle": KaggleSandboxManager, "modal": ModalSandboxManager, + "daytona": DaytonaSandboxManager, "datalayer": DatalayerSandboxManager, } @@ -755,8 +877,9 @@ def get_manager(variant: str, **kwargs: Any) -> SandboxManager: accepted for ``google_colab``). **kwargs: Variant-specific connection settings — ``server_url`` / ``token`` (jupyter), ``proxy_token`` (google_colab), ``app_name`` - (modal), ``username`` (kaggle), ``token`` / ``run_url`` - (datalayer), ``docker_client`` (docker). + (modal), ``api_key`` / ``api_url`` / ``target`` (daytona), + ``username`` (kaggle), ``token`` / ``run_url`` (datalayer), + ``docker_client`` (docker). Returns: A :class:`SandboxManager` for the variant. diff --git a/code_sandboxes/models.py b/code_sandboxes/models.py index 1b7fa08..ff772da 100644 --- a/code_sandboxes/models.py +++ b/code_sandboxes/models.py @@ -69,6 +69,7 @@ class SandboxVariant(str, Enum): DOCKER = "docker" JUPYTER = "jupyter-server" DATALAYER = "datalayer" + DAYTONA = "daytona" GOOGLE_COLAB = "google_colab" KAGGLE = "kaggle" MONTY = "monty" diff --git a/code_sandboxes/providers.py b/code_sandboxes/providers.py index 330559d..ebd8988 100644 --- a/code_sandboxes/providers.py +++ b/code_sandboxes/providers.py @@ -210,6 +210,32 @@ def read() -> list[SandboxEnvironment]: ), list_environments=_environments_of(SandboxVariant.MODAL), ), + SandboxProvider( + variant=SandboxVariant.DAYTONA, + title="Daytona", + description=( + "Sandboxes on Daytona, with a stateful Python interpreter and an " + "optional GPU." + ), + extra="daytona", + requirements=( + ProviderRequirement( + env_vars=("DAYTONA_API_KEY",), + hint=( + "Create an API key at app.daytona.io and set " + "DAYTONA_API_KEY." + ), + ), + ProviderRequirement( + env_vars=("DAYTONA_JWT_TOKEN", "DAYTONA_ORGANIZATION_ID"), + hint=( + "Set DAYTONA_JWT_TOKEN with the DAYTONA_ORGANIZATION_ID it " + "belongs to." + ), + ), + ), + list_environments=_environments_of(SandboxVariant.DAYTONA), + ), SandboxProvider( variant=SandboxVariant.DOCKER, title="Docker", diff --git a/docs/docs/cli/index.mdx b/docs/docs/cli/index.mdx index 4db6367..ebb6a2b 100644 --- a/docs/docs/cli/index.mdx +++ b/docs/docs/cli/index.mdx @@ -32,6 +32,7 @@ Supported variants: - `kaggle` - `google-colab` - `modal` +- `daytona` - `datalayer` ## Variant-specific Behavior @@ -40,6 +41,8 @@ Supported variants: - `kaggle`: supports either interactive runtime settings or credential-based batch execution. - `monty`: starts a Monty REPL-backed sandbox. - `modal`: starts a Modal sandbox container. +- `daytona`: starts a Daytona sandbox; `--gpu` takes Daytona's own flavors + (`H100`, `H200`, `RTX-4090`, ...). - `google-colab`: prompts for runtime URL, kernel ID, and proxy token. ## Usage diff --git a/docs/docs/cli/management.mdx b/docs/docs/cli/management.mdx index 940191f..8f6a5d2 100644 --- a/docs/docs/cli/management.mdx +++ b/docs/docs/cli/management.mdx @@ -59,7 +59,7 @@ below the table instead of hiding the ones that answered. from code_sandboxes import get_manager, manageable_variants print(manageable_variants()) -# ['datalayer', 'docker', 'eval', 'google_colab', 'jupyter', 'kaggle', 'modal', 'monty'] +# ['datalayer', 'daytona', 'docker', 'eval', 'google_colab', 'jupyter', 'kaggle', 'modal', 'monty'] manager = get_manager("modal") for info in manager.list(): @@ -75,6 +75,7 @@ Connection settings go to `get_manager` and stay out of the verbs: get_manager("jupyter-server", server_url="http://localhost:8888", token="...") get_manager("google_colab", server_url="https://...", proxy_token="...") get_manager("modal", app_name="code-sandboxes") +get_manager("daytona", api_key="dtn_...", target="eu") get_manager("kaggle", username="...") get_manager("datalayer", token="...", run_url="https://...") ``` @@ -85,6 +86,7 @@ get_manager("datalayer", token="...", run_url="https://...") | --- | --- | --- | --- | | `datalayer` | a runtime of the Datalayer platform | the capabilities | the runtime | | `modal` | a Modal sandbox of the `code-sandboxes` app | the tags | the Modal sandbox | +| `daytona` | a sandbox of the Daytona organization | the labels | the Daytona sandbox | | `docker` | a container labelled `code-sandboxes` | the name | the container (forced) | | `jupyter-server` | a kernel of the Jupyter Server | — nothing in place | the kernel | | `google_colab` | a kernel of the Colab runtime | — nothing in place | the kernel | diff --git a/docs/docs/examples/index.mdx b/docs/docs/examples/index.mdx index d3499ad..59c1e9e 100644 --- a/docs/docs/examples/index.mdx +++ b/docs/docs/examples/index.mdx @@ -67,6 +67,14 @@ make google-colab make modal ``` +## Daytona + +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/daytona_sandbox_example.py + +```bash +make daytona +``` + ## Datalayer - Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/datalayer_sandbox_example.py diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index 717a37a..4aa3159 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -70,6 +70,7 @@ Code Sandboxes supports these execution variants: | `kaggle` | Managed notebook runtime | Interactive and batch runs | | `google-colab` | Managed notebook runtime | Interactive Colab-connected runs | | `modal` | Managed container runtime | Ephemeral compute tasks | +| `daytona` | Managed cloud sandbox | Stateful agent sessions | | `datalayer` | Managed VM/runtime | Production and GPU workloads | ## Quick Start diff --git a/docs/docs/installation/index.mdx b/docs/docs/installation/index.mdx index 5e06ace..c5ca731 100644 --- a/docs/docs/installation/index.mdx +++ b/docs/docs/installation/index.mdx @@ -33,6 +33,9 @@ pip install code-sandboxes[monty] # With Modal variant support pip install code-sandboxes[modal] +# With Daytona variant support +pip install code-sandboxes[daytona] + # All features pip install code-sandboxes[all] ``` @@ -48,6 +51,8 @@ pip install code-sandboxes[all] - For Monty variant: `code-sandboxes[monty]` (no credentials required) - For Modal variant: `code-sandboxes[modal]` and Modal credentials (`modal token new` or `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`) +- For Daytona variant: `code-sandboxes[daytona]` and a Daytona API key + (`DAYTONA_API_KEY`, from app.daytona.io) ## Configuration @@ -59,6 +64,9 @@ pip install code-sandboxes[all] | `DATALAYER_RUN_URL` | Custom Datalayer service URL (optional) | | `MODAL_TOKEN_ID` | Modal token id (Modal variant) | | `MODAL_TOKEN_SECRET` | Modal token secret (Modal variant) | +| `DAYTONA_API_KEY` | Daytona API key (Daytona variant) | +| `DAYTONA_API_URL` | Daytona API URL (optional, defaults to app.daytona.io) | +| `DAYTONA_TARGET` | Daytona region (optional) | ### Programmatic Configuration diff --git a/docs/docs/sandboxes/datalayer.mdx b/docs/docs/sandboxes/datalayer.mdx index 089c0d9..068a290 100644 --- a/docs/docs/sandboxes/datalayer.mdx +++ b/docs/docs/sandboxes/datalayer.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 9 +sidebar_position: 10 title: Datalayer --- diff --git a/docs/docs/sandboxes/daytona.mdx b/docs/docs/sandboxes/daytona.mdx new file mode 100644 index 0000000..e9c50dd --- /dev/null +++ b/docs/docs/sandboxes/daytona.mdx @@ -0,0 +1,156 @@ +--- +sidebar_position: 9 +title: Daytona +--- + +# Daytona + +Runs code in a [Daytona](https://www.daytona.io/docs/) sandbox — an isolated +cloud sandbox that starts in well under a second. + +State **persists** between calls. The variant drives the sandbox's code +interpreter (`sandbox.code_interpreter`), which holds one Python namespace per +context, rather than `process.code_run`, which is a fresh process per snippet: + +```python +sandbox.run_code("x = 40") +sandbox.run_code("x + 2").text # "42" +``` + +- **Requirements:** `code-sandboxes[daytona]` (installs `daytona`). +- **Parameters:** `api_key`, `api_url`, `target`, `jwt_token`, + `organization_id`, `snapshot`, `image`, `python_version`, `delete_on_stop`. + +## How To Obtain Daytona Credentials + +1. Sign in at [app.daytona.io](https://app.daytona.io). +2. Create an API key and export it: + ```bash + export DAYTONA_API_KEY="dtn_..." + ``` +3. Optionally point at another deployment or region: + ```bash + export DAYTONA_API_URL="https://app.daytona.io/api" # the default + export DAYTONA_TARGET="eu" # your default region otherwise + ``` + +JWT authentication works too, and then the organization has to be named +alongside it: `DAYTONA_JWT_TOKEN` with `DAYTONA_ORGANIZATION_ID`. + +Every one of these can be passed to the sandbox instead of exported. What is +left out is read from the environment by the Daytona SDK, so passing nothing is +not the same as passing `None`: + +```python +Sandbox.create(variant="daytona", api_key="dtn_...", target="eu") +``` + +## Usage + +```python +from code_sandboxes import Sandbox + +with Sandbox.create(variant="daytona") as sandbox: + result = sandbox.run_code("import numpy as np; np.arange(5).sum()") + print(result.text) # "10" +``` + +### Contexts + +The default namespace is shared by every call. `create_context()` asks Daytona +for one it keeps apart, so two pieces of work can run in the same sandbox +without seeing each other's variables: + +```python +with Sandbox.create(variant="daytona") as sandbox: + other = sandbox.create_context() + + sandbox.run_code("secret = 1") + sandbox.run_code("secret", context=other).code_error.name # "NameError" +``` + +### GPUs + +Daytona's own flavours — `H100`, `H200`, `RTX-PRO-6000`, `RTX-4090`, +`RTX-5090`. A name Daytona does not have is refused before any sandbox is +created, naming the ones it does: + +```python +Sandbox.create(variant="daytona", gpu="H100") +``` + +A GPU — like any request for `cpu` or `memory` — is a machine specification, +and Daytona accepts one only when the sandbox is built from an **image**. So +asking for resources builds from `Image.debian_slim()` instead of starting from +the default snapshot, and takes longer to come up. Pass `image=` to choose that +image yourself, or `snapshot=` to start from a snapshot of your organization +when you need no specification. + +### Network policy + +The policy of the configuration becomes Daytona's own network settings: + +```python +# No outbound network at all. +Sandbox.create(variant="daytona", network_policy="none") + +# Only these domains. +Sandbox.create( + variant="daytona", + network_policy="allowlist", + allowed_hosts=["pypi.org", "files.pythonhosted.org"], +) +``` + +### Lifecycle + +Leaving the `with` block **deletes** the sandbox. Daytona sandboxes otherwise +outlive the program that made them, and one nobody deletes goes on costing +storage. Pass `delete_on_stop=False` to stop it instead, leaving it in the +organization to be started again: + +```python +with Sandbox.create(variant="daytona", delete_on_stop=False) as sandbox: + sandbox.run_code("open('/tmp/work', 'w').write('kept')") +``` + +`code-sandboxes list -v daytona` shows what is there; see the +[management guide](/cli/management). + +### Names and tags + +A Daytona name is an address — `daytona.get(name)` — and has to be unique +within an organization, while the names this package generates are meant to be +readable and may repeat. So the name and any tags travel as **labels**, next to +`created-by=code-sandboxes`, which is what tells the sandboxes this package +made from the rest of the organization's: + +```python +Sandbox.create(variant="daytona", name="nightly-report", tags={"team": "ai"}) +``` + +## What Is Not Reported + +The interpreter answers with stdout, stderr, and the error when the code +raised. There is no execute_result on the wire, so: + +- The value of a trailing expression is captured for you — it is evaluated, + bound, and its `repr` carried back on a marked line of stdout that never + reaches your logs. That is what makes `result.text` work above. +- Rich display data — a figure, an HTML repr, a PNG — has no channel at all and + is **not** returned. `result.results` only ever holds the text value. +- There is no interrupt: `sandbox.interrupt()` answers `False`, and a runaway + execution is stopped by its timeout. + +Variables cross as JSON (`get_variable`, `set_variable`, and everything built +on them such as `sandbox.commands.run`), so a value that cannot be encoded +comes back as its `repr` rather than as the object itself, and one that cannot +be *sent* is refused with a reason. + +Binary files go straight to Daytona's filesystem API rather than through a +program that decodes them: + +```python +sandbox.files.write_bytes("/tmp/data.parquet", payload) +sandbox.files.read_bytes("/tmp/data.parquet") +``` diff --git a/docs/docs/sandboxes/index.mdx b/docs/docs/sandboxes/index.mdx index 779efec..6ebc69c 100644 --- a/docs/docs/sandboxes/index.mdx +++ b/docs/docs/sandboxes/index.mdx @@ -12,7 +12,8 @@ A sandbox is an isolated environment where code can be executed safely. Code San Use `Sandbox.create()` to create a new sandbox: Canonical variant names are `jupyter-server`, `docker`, `eval`, `monty`, `kaggle`, -`google_colab`, `modal`, and `datalayer`. Older `local-*` names are no longer supported. +`google_colab`, `modal`, `daytona`, and `datalayer`. Older `local-*` names are no +longer supported. The CLI also accepts `google-colab` as an alias for `google_colab`. @@ -69,6 +70,7 @@ Each page below explains how to configure each variant. | [`kaggle`](./kaggle) | Kaggle runtime (interactive or batch) | | [`google_colab`](./google-colab) | Google Colab runtime via runtime proxy | | [`modal`](./modal) | Modal container execution | +| [`daytona`](./daytona) | Daytona cloud sandbox with a stateful interpreter | | [`datalayer`](./datalayer) | Datalayer managed runtime with optional GPU | ## Environments diff --git a/examples/README.md b/examples/README.md index 25be16f..b148468 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,6 +24,7 @@ Supported sandbox variants: - `google-colab` - `kaggle` - `modal` +- `daytona` - `datalayer` Run one-shot examples from `examples/exec/`: @@ -37,6 +38,7 @@ python monty_sandbox_example.py python google_colab_sandbox_example.py python kaggle_sandbox_example.py python modal_sandbox_example.py +python daytona_sandbox_example.py python datalayer_sandbox_example.py ``` @@ -51,6 +53,7 @@ make monty make google-colab make kaggle make modal +make daytona make datalayer ``` @@ -65,6 +68,7 @@ make monty make google-colab make kaggle make modal +make daytona make datalayer ``` @@ -75,4 +79,6 @@ Notes by variant: - `google-colab`: requires `RUNTIME_URL`, `RUNTIME_ID`, and `RUNTIME_PROXY_TOKEN`. - `kaggle`: requires `RUNTIME_CHANNELS_URL`, or `RUNTIME_URL` and `RUNTIME_ID`. - `modal`: requires `MODAL_TOKEN_ID`/`MODAL_TOKEN_SECRET` or `~/.modal.toml`. +- `daytona`: requires `code-sandboxes[daytona]` and `DAYTONA_API_KEY` (or + `DAYTONA_JWT_TOKEN` with `DAYTONA_ORGANIZATION_ID`). - `datalayer`: requires Datalayer runtime credentials/config. diff --git a/examples/exec/Makefile b/examples/exec/Makefile index ea21cc7..166c63f 100644 --- a/examples/exec/Makefile +++ b/examples/exec/Makefile @@ -2,9 +2,9 @@ PYTHON ?= python -.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu modal modal-gpu datalayer +.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu modal modal-gpu daytona daytona-gpu datalayer -all: eval docker jupyter-server monty google-colab modal datalayer +all: eval docker jupyter-server monty google-colab modal daytona datalayer eval: $(PYTHON) eval_sandbox_example.py @@ -43,5 +43,17 @@ modal-gpu: ## Modal example on a GPU (MODAL_GPU flavor, default 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}" +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 + +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}" + datalayer: $(PYTHON) datalayer_sandbox_example.py diff --git a/examples/exec/daytona_sandbox_example.py b/examples/exec/daytona_sandbox_example.py new file mode 100644 index 0000000..b627b09 --- /dev/null +++ b/examples/exec/daytona_sandbox_example.py @@ -0,0 +1,127 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""Example: daytona sandbox (cloud sandbox with a stateful interpreter). + +Run with: + python examples/exec/daytona_sandbox_example.py + +Auth: +- create an API key at https://app.daytona.io and export DAYTONA_API_KEY, or +- export DAYTONA_JWT_TOKEN together with DAYTONA_ORGANIZATION_ID. + +GPU options: +- pass `--gpu H100` (or H200/RTX-4090/RTX-5090/RTX-PRO-6000), or +- set DAYTONA_GPU in the environment. + +Asking for a GPU asks for a machine specification, and Daytona takes one only +when the sandbox is built from an IMAGE — so a GPU run starts from a Debian +image rather than from the default snapshot, and takes longer to come up. +""" + +import argparse +import os + +from exec_common import show_and_run + +from code_sandboxes import Sandbox + + +def _has_daytona_auth() -> bool: + if os.environ.get("DAYTONA_API_KEY"): + return True + return bool( + os.environ.get("DAYTONA_JWT_TOKEN") and os.environ.get("DAYTONA_ORGANIZATION_ID") + ) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the daytona sandbox example.") + parser.add_argument( + "--gpu", + default=os.environ.get("DAYTONA_GPU"), + help="Optional GPU (for example: H100, H200, RTX-4090).", + ) + return parser.parse_args() + + +def _gpu_probe_code() -> str: + """Code that PROVES a GPU, not merely looks for one. + + Prints one `GPU-PROBE:` line per fact so the caller can assert on them: + the driver must be there, and must list at least one device. + """ + return """ +import shutil +import subprocess + +smi = shutil.which("nvidia-smi") +print("GPU-PROBE: nvidia-smi", "present" if smi else "MISSING") +if smi: + listing = subprocess.run( + ["nvidia-smi", "-L"], check=False, capture_output=True, text=True + ).stdout.strip() + print("GPU-PROBE: devices", listing or "NONE") +""" + + +def main() -> None: + args = _parse_args() + if not _has_daytona_auth(): + print("Daytona auth not found.") + print("Set DAYTONA_API_KEY, or DAYTONA_JWT_TOKEN with DAYTONA_ORGANIZATION_ID.") + print("Create a key at https://app.daytona.io.") + raise SystemExit(1) + + if args.gpu: + print(f"Launching daytona sandbox with GPU: {args.gpu}") + else: + print("Launching daytona sandbox without GPU.") + + try: + with Sandbox.create(variant="daytona", timeout=60, gpu=args.gpu) as sandbox: + print(f"Sandbox: {sandbox.sandbox_id}") + + # What tells this variant apart from a per-snippet runner: the + # interpreter holds one namespace, so the second snippet sees what + # the first defined. + show_and_run(sandbox, "x = 40") + show_and_run(sandbox, "import sys; print(sys.version.split()[0])") + state = show_and_run(sandbox, "x + 2") + if state.text != "42": + raise RuntimeError( + f"State did not survive between snippets: x + 2 gave {state.text!r}." + ) + print("state verified: the namespace is shared between snippets.") + + # 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")) + + if args.gpu: + gpu_result = show_and_run(sandbox, _gpu_probe_code()) + probe = gpu_result.stdout.strip() + # A GPU run must PROVE the GPU: the driver present, and at + # least one device listed. + if "nvidia-smi present" not in probe: + raise RuntimeError("GPU requested but nvidia-smi is missing in the sandbox.") + if "devices NONE" in probe or "GPU-PROBE: devices" not in probe: + raise RuntimeError("GPU requested but no device is listed by nvidia-smi.") + print(f"GPU verified: {args.gpu} is present.") + + # The error path, demonstrated ON PURPOSE: the run must not die, + # the failure must come back as a `code_error` on the result. Said + # before it happens, or the example's last lines read as a crash. + print("-- error handling: the next snippet raises deliberately --") + error_result = show_and_run(sandbox, "raise RuntimeError('daytona failure example')") + if error_result.code_error is None: + raise RuntimeError("The deliberate failure did not surface as a code_error.") + print("error captured as expected — daytona example completed.") + except Exception as exc: + print("daytona example failed:", exc) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() diff --git a/examples/repl/Makefile b/examples/repl/Makefile index c42663f..7820189 100644 --- a/examples/repl/Makefile +++ b/examples/repl/Makefile @@ -2,9 +2,9 @@ PYTHON ?= python -.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu kaggle-live kaggle-gpu-live modal modal-gpu datalayer +.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu kaggle-live kaggle-gpu-live modal modal-gpu daytona daytona-gpu datalayer -all: eval docker jupyter-server monty google-colab modal datalayer +all: eval docker jupyter-server monty google-colab modal daytona datalayer eval: $(PYTHON) eval_sandbox_example.py @@ -49,5 +49,15 @@ modal-gpu: ## Modal REPL on a GPU (MODAL_GPU flavor, default 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}" +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 + +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}" + datalayer: $(PYTHON) datalayer_sandbox_example.py diff --git a/examples/repl/daytona_sandbox_example.py b/examples/repl/daytona_sandbox_example.py new file mode 100644 index 0000000..abec6a8 --- /dev/null +++ b/examples/repl/daytona_sandbox_example.py @@ -0,0 +1,84 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# BSD 3-Clause License + +"""REPL example: daytona sandbox (cloud sandbox with a stateful interpreter). + +Run with: + python examples/repl/daytona_sandbox_example.py + +Auth: +- create an API key at https://app.daytona.io and export DAYTONA_API_KEY, or +- export DAYTONA_JWT_TOKEN together with DAYTONA_ORGANIZATION_ID. + +The prompt behaves as a REPL should: definitions persist between lines, and a +line that is an expression answers with its value. +""" + +import argparse +import os + +from repl_common import run_repl + +from code_sandboxes import Sandbox + + +def _has_daytona_auth() -> bool: + if os.environ.get("DAYTONA_API_KEY"): + return True + return bool( + os.environ.get("DAYTONA_JWT_TOKEN") and os.environ.get("DAYTONA_ORGANIZATION_ID") + ) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the daytona sandbox REPL example.") + parser.add_argument( + "--gpu", + default=os.environ.get("DAYTONA_GPU"), + help="Optional GPU (for example: H100, H200, RTX-4090).", + ) + parser.add_argument( + "--keep", + action="store_true", + help=( + "Leave the sandbox in the organization when the REPL closes, " + "stopped rather than deleted, so it can be started again." + ), + ) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + if not _has_daytona_auth(): + print("Daytona auth not found.") + print("Set DAYTONA_API_KEY, or DAYTONA_JWT_TOKEN with DAYTONA_ORGANIZATION_ID.") + print("Create a key at https://app.daytona.io.") + raise SystemExit(1) + + if args.gpu: + print(f"Launching daytona sandbox REPL with GPU: {args.gpu}") + else: + print("Launching daytona sandbox REPL without GPU.") + + try: + with Sandbox.create( + variant="daytona", + timeout=60, + gpu=args.gpu, + delete_on_stop=not args.keep, + ) as sandbox: + print(f"Sandbox: {sandbox.sandbox_id}") + run_repl(sandbox) + if args.keep: + print( + "Kept: the sandbox is stopped, not deleted — " + "`code-sandboxes list -v daytona` shows it." + ) + except Exception as exc: + print("daytona REPL failed:", exc) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index d984c9f..aaa98a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ code-sandboxes = "code_sandboxes.cli:main" [project.optional-dependencies] datalayer = ["agent_runtimes>=1.0.16"] +daytona = ["daytona>=0.100"] docker = ["docker>=6.0"] google-colab = [] kaggle = ["kaggle>=1.6"] @@ -45,6 +46,7 @@ monty = ["pydantic-monty"] modal = ["modal>=0.64"] all = [ "agent_runtimes", + "daytona>=0.100", "docker>=6.0", "kaggle>=1.6", "pydantic-monty", diff --git a/tests/test_daytona.py b/tests/test_daytona.py new file mode 100644 index 0000000..3a87ff9 --- /dev/null +++ b/tests/test_daytona.py @@ -0,0 +1,502 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""The Daytona sandbox, against an interpreter that really runs the code. + +The fake below is not a mock answering with canned strings. What this variant +does that is worth testing happens on either side of the wire — the code is +rewritten before it goes out, and the stream that comes back is cut into lines +and read for a marker — and only running the code it actually sends exercises +both. +""" + +from __future__ import annotations + +import contextlib +import io +import traceback +from types import SimpleNamespace + +import pytest + +from code_sandboxes.base import Sandbox +from code_sandboxes.daytona_sandbox import ( + _VALUE_MARKER, + DaytonaSandbox, + _capture_trailing_value, + _Lines, + _split_marker, +) +from code_sandboxes.exceptions import SandboxConfigurationError +from code_sandboxes.manage import get_manager, manageable_variants +from code_sandboxes.models import SandboxConfig, SandboxVariant +from code_sandboxes.providers import get_provider + + +def _chunks(text: str, size: int = 7) -> list[str]: + """The text as the websocket would deliver it: cut anywhere at all.""" + return [text[at : at + size] for at in range(0, len(text), size)] if text else [] + + +class _FakeInterpreter: + """Daytona's code interpreter, executed here instead of over there.""" + + def __init__(self) -> None: + self.namespaces: dict[str | None, dict] = {None: {"__name__": "__main__"}} + self.contexts: list[SimpleNamespace] = [] + self.calls: list[SimpleNamespace] = [] + + def create_context(self, cwd=None): + context = SimpleNamespace(id=f"ctx-{len(self.contexts)}", cwd=cwd) + self.contexts.append(context) + self.namespaces[context.id] = {"__name__": "__main__"} + return context + + def run_code( + self, + code, + *, + context=None, + on_stdout=None, + on_stderr=None, + on_error=None, + envs=None, + timeout=None, + ): + self.calls.append( + SimpleNamespace(code=code, context=context, envs=envs, timeout=timeout) + ) + namespace = self.namespaces[context.id if context else None] + out, err, error = io.StringIO(), io.StringIO(), None + try: + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + exec(compile(code, "", "exec"), namespace) # noqa: S102 + except BaseException as exc: # the sandbox reports a failure, it never raises + error = SimpleNamespace( + name=type(exc).__name__, value=str(exc), traceback=traceback.format_exc() + ) + for chunk in _chunks(out.getvalue()): + if on_stdout: + on_stdout(SimpleNamespace(output=chunk)) + for chunk in _chunks(err.getvalue()): + if on_stderr: + on_stderr(SimpleNamespace(output=chunk)) + if error is not None and on_error: + on_error(error) + return SimpleNamespace(stdout=out.getvalue(), stderr=err.getvalue(), error=error) + + +class _FakeFilesystem: + def __init__(self) -> None: + self.files: dict[str, bytes] = {} + + def upload_file(self, src, dst, timeout=1800): + self.files[dst] = src if isinstance(src, bytes) else open(src, "rb").read() + + def download_file(self, *args): + return self.files.get(args[0]) + + +class _FakeDaytonaSandbox: + def __init__(self) -> None: + self.id = "sbx-test" + self.name = None + self.labels: dict[str, str] = {} + self.code_interpreter = _FakeInterpreter() + self.fs = _FakeFilesystem() + self.deleted = False + self.stopped = False + + def delete(self): + self.deleted = True + + def stop(self): + self.stopped = True + + +def _started(config: SandboxConfig | None = None, **kwargs) -> DaytonaSandbox: + """A sandbox wired to the fake, as `start()` would have left it.""" + sandbox = DaytonaSandbox(config=config or SandboxConfig(timeout=10.0), **kwargs) + sandbox._started = True + sandbox._sandbox = _FakeDaytonaSandbox() + sandbox._default_context = sandbox.create_context("default") + return sandbox + + +# --- What the caller gets back ------------------------------------------ + + +def test_state_survives_between_calls(): + """One namespace per sandbox: that is why the interpreter is used at all.""" + sandbox = _started() + + sandbox.run_code("x = 40") + result = sandbox.run_code("x + 2") + + assert result.success + assert result.text == "42" + + +def test_trailing_expression_answers_like_a_repl(): + sandbox = _started() + + assert sandbox.run_code("1 + 1").text == "2" + # A statement has no value, and none is invented for it. + assert sandbox.run_code("y = 3").text is None + # Neither has a call that returns None. + assert sandbox.run_code("print('hi')").text is None + + +def test_the_marker_never_reaches_the_caller(): + """The line carrying the value is ours, and is taken back out.""" + sandbox = _started() + streamed: list[str] = [] + + result = sandbox.run_code("print('hello')\n1 + 1", on_stdout=lambda m: streamed.append(m.line)) + + assert result.stdout == "hello" + assert streamed == ["hello"] + assert _VALUE_MARKER not in result.stdout + + +def test_output_cut_across_chunks_is_put_back_together(): + """A websocket cuts where it likes; an OutputMessage is still a line.""" + sandbox = _started() + long_line = "x" * 40 + + result = sandbox.run_code(f"print({long_line!r})\nprint('second')") + + assert [message.line for message in result.logs.stdout] == [long_line, "second"] + + +def test_a_line_never_terminated_keeps_the_marker_out_of_it(): + """`write` leaves the stream mid-line, so the marker lands on that line.""" + sandbox = _started() + + result = sandbox.run_code("import sys; sys.stdout.write('no newline')") + + assert result.stdout == "no newline" + # And the value written by that call — the number of characters — is read. + assert result.text == "10" + + +def test_stderr_is_kept_apart(): + sandbox = _started() + + result = sandbox.run_code("import sys; sys.stderr.write('warned\\n')") + + assert result.stderr == "warned" + assert result.stdout == "" + + +def test_raising_code_comes_back_as_a_code_error(): + """The sandbox worked; the code did not. Those are different failures.""" + sandbox = _started() + + result = sandbox.run_code("raise ValueError('boom')") + + assert result.execution_ok + assert result.code_error is not None + assert result.code_error.name == "ValueError" + assert result.code_error.value == "boom" + assert not result.success + + +def test_the_interpreter_refusing_is_an_execution_failure(): + sandbox = _started() + + def explode(*_args, **_kwargs): + raise RuntimeError("websocket closed") + + sandbox._sandbox.code_interpreter.run_code = explode + result = sandbox.run_code("1 + 1") + + assert not result.execution_ok + assert "websocket closed" in (result.execution_error or "") + + +def test_a_traceback_names_the_line_the_caller_wrote(): + """The capture rewrites the tail; everything before it keeps its place.""" + sandbox = _started() + + result = sandbox.run_code("x = 1\n\n\nundefined_name\n") + + assert result.code_error is not None + assert result.code_error.name == "NameError" + assert "line 4" in result.code_error.traceback + + +def test_the_capture_leaves_nothing_behind_in_the_namespace(): + sandbox = _started() + + sandbox.run_code("1 + 1") + names = sandbox._sandbox.code_interpreter.namespaces[None] + + assert [name for name in names if name.startswith("_code_sandboxes")] == [] + + +# --- What is sent out ---------------------------------------------------- + + +def test_a_sub_second_timeout_is_rounded_up_to_a_whole_one(): + """Daytona counts in whole seconds, and reads 0 as no limit at all.""" + sandbox = _started() + + sandbox.run_code("1", timeout=0.5) + + assert sandbox._sandbox.code_interpreter.calls[-1].timeout == 1 + + +def test_environment_variables_are_passed_through(): + sandbox = _started() + + sandbox.run_code("1", envs={"TOKEN": "secret"}) + + assert sandbox._sandbox.code_interpreter.calls[-1].envs == {"TOKEN": "secret"} + + +def test_the_default_context_is_daytonas_own(): + sandbox = _started() + + sandbox.run_code("1", context=sandbox._default_context) + + assert sandbox._sandbox.code_interpreter.calls[-1].context is None + assert sandbox._sandbox.code_interpreter.contexts == [] + + +def test_a_created_context_really_is_isolated(): + sandbox = _started() + other = sandbox.create_context() + + sandbox.run_code("x = 1") + result = sandbox.run_code("x", context=other) + + assert result.code_error is not None + assert result.code_error.name == "NameError" + # And made once, however often it is used. + sandbox.run_code("2", context=other) + assert len(sandbox._sandbox.code_interpreter.contexts) == 1 + + +def test_a_non_python_language_is_refused(): + sandbox = _started() + + with pytest.raises(ValueError, match="only supports Python"): + sandbox.run_code("SELECT 1", language="sql") + + +# --- The code that is not rewritten -------------------------------------- + + +@pytest.mark.parametrize( + "code", + [ + "x = 1", # no trailing expression + "x = (", # will not parse: the sandbox reports the syntax error + "async def f():\n pass", + "value = await thing()", + ], +) +def test_code_without_a_capturable_value_is_sent_verbatim(code): + assert _capture_trailing_value(code) == code + + +def test_an_awaited_trailing_expression_is_left_alone(): + """Binding an await to a name outside a coroutine does not parse.""" + code = "await thing()" + + assert _capture_trailing_value(code) == code + + +# --- Lines --------------------------------------------------------------- + + +def test_lines_holds_a_partial_line_until_the_rest_arrives(): + lines = _Lines() + + assert lines.feed("ab") == [] + assert lines.feed("c\nde") == ["abc"] + assert lines.feed("f\ng\n") == ["def", "g"] + assert lines.flush() == [] + + +def test_a_marker_sharing_a_line_with_real_output_is_split_off(): + text, value = _split_marker(f'partial{_VALUE_MARKER}"7"') + + assert (text, value) == ("partial", "7") + + +def test_a_line_that_merely_looks_like_a_marker_is_left_whole(): + line = f"{_VALUE_MARKER}not json" + + assert _split_marker(line) == (line, None) + + +def test_lines_gives_up_what_never_got_a_newline(): + lines = _Lines() + + lines.feed("tail") + + assert lines.flush() == ["tail"] + assert lines.flush() == [] + + +# --- Variables and files ------------------------------------------------- + + +def test_variables_cross_as_json(): + sandbox = _started() + + sandbox.set_variable("payload", {"a": [1, 2], "b": "three"}) + + assert sandbox.get_variable("payload") == {"a": [1, 2], "b": "three"} + + +def test_a_value_that_cannot_be_encoded_is_refused_with_a_reason(): + sandbox = _started() + + with pytest.raises(SandboxConfigurationError, match="cannot be encoded"): + sandbox.set_variable("fn", lambda: None) + + +def test_bytes_go_through_the_filesystem_api_not_through_the_code(): + """A large file should not have to become a large program.""" + sandbox = _started() + + sandbox.files.write_bytes("/work/hello.bin", b"content", make_dirs=False) + + assert sandbox._sandbox.fs.files["/work/hello.bin"] == b"content" + assert sandbox.files.read_bytes("/work/hello.bin") == b"content" + # Nothing was executed to move those bytes. + assert sandbox._sandbox.code_interpreter.calls == [] + + +# --- Lifecycle ----------------------------------------------------------- + + +def test_stopping_deletes_the_sandbox_by_default(): + sandbox = _started() + fake = sandbox._sandbox + + sandbox.stop() + + assert fake.deleted + assert not sandbox.is_started + + +def test_a_sandbox_can_be_left_standing_for_later(): + sandbox = _started(delete_on_stop=False) + fake = sandbox._sandbox + + sandbox.stop() + + assert fake.stopped + assert not fake.deleted + + +# --- Configuration ------------------------------------------------------- + + +def test_the_network_policy_becomes_daytonas_own_settings(): + blocked = _started(SandboxConfig(network_policy="none")) + assert blocked._network_params() == {"network_block_all": True} + + allowed = _started( + SandboxConfig( + network_policy="allowlist", + allowed_hosts=["pypi.org", "files.pythonhosted.org"], + ) + ) + assert allowed._network_params() == { + "domain_allow_list": "pypi.org,files.pythonhosted.org" + } + + assert _started(SandboxConfig(network_policy="inherit"))._network_params() == {} + + +def test_an_allowlist_of_nothing_is_refused(): + sandbox = _started(SandboxConfig(network_policy="allowlist")) + + with pytest.raises(SandboxConfigurationError, match="allowed_hosts"): + sandbox._network_params() + + +def test_the_name_travels_as_a_label_not_as_daytonas_name(): + """Daytona names address a sandbox and must be unique; ours are generated.""" + sandbox = _started(SandboxConfig(name="tan-law-5384")) + sandbox.set_tags({"team": "ai"}) + + assert sandbox._labels() == { + "created-by": "code-sandboxes", + "name": "tan-law-5384", + "team": "ai", + } + + +def test_a_gpu_daytona_does_not_have_is_refused_by_name(): + daytona = pytest.importorskip("daytona") + from code_sandboxes.daytona_sandbox import _gpu_type + + with pytest.raises(SandboxConfigurationError, match="no GPU called 'T4'"): + _gpu_type("T4", daytona) + + assert _gpu_type("h100", daytona) == daytona.GpuType.H100 + + +def test_resources_are_only_asked_for_when_the_configuration_says_so(): + daytona = pytest.importorskip("daytona") + + assert _started(SandboxConfig())._resources(daytona) is None + resources = _started( + SandboxConfig(cpu_limit=2.0, memory_limit=4 * 1024**3, gpu="H100") + )._resources(daytona) + assert (resources.cpu, resources.memory, resources.gpu) == (2, 4, 1) + assert resources.gpu_type == daytona.GpuType.H100 + + +def test_asking_for_resources_creates_from_an_image(): + """Daytona takes a machine specification with an image, not a snapshot.""" + daytona = pytest.importorskip("daytona") + + from_snapshot = _started(SandboxConfig())._create_params(daytona) + assert isinstance(from_snapshot, daytona.CreateSandboxFromSnapshotParams) + + from_image = _started(SandboxConfig(cpu_limit=2.0))._create_params(daytona) + assert isinstance(from_image, daytona.CreateSandboxFromImageParams) + assert from_image.resources.cpu == 2 + + +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") + + assert _started()._client_config(daytona) is None + config = _started(api_key="dtn_key")._client_config(daytona) + assert config.api_key == "dtn_key" + + +# --- Registration -------------------------------------------------------- + + +def test_the_variant_is_registered_everywhere_a_variant_is_named(): + assert SandboxVariant.DAYTONA.value == "daytona" + assert isinstance(Sandbox.create(variant="daytona"), DaytonaSandbox) + assert [env.name for env in Sandbox.list_environments(variant="daytona")] == [ + "daytona-default", + "daytona-gpu", + ] + assert get_provider("daytona") is not None + assert "daytona" in manageable_variants() + assert get_manager("daytona").variant == "daytona" + + +def test_the_provider_says_what_it_needs(): + provider = get_provider("daytona") + + assert provider.extra == "daytona" + assert not provider.is_available({}) + assert provider.is_available({"DAYTONA_API_KEY": "dtn_key"}) + assert provider.is_available( + {"DAYTONA_JWT_TOKEN": "jwt", "DAYTONA_ORGANIZATION_ID": "org"} + ) diff --git a/tests/test_manage.py b/tests/test_manage.py index b48df96..3d55cf1 100644 --- a/tests/test_manage.py +++ b/tests/test_manage.py @@ -23,6 +23,7 @@ def test_every_variant_has_a_manager(): assert manageable_variants() == [ "datalayer", + "daytona", "docker", "eval", "google_colab", From 9b00046b9de65f0f52d01b306cf57446071ccf44 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 20 Aug 2026 20:02:11 +0200 Subject: [PATCH 2/7] lint --- README.md | 10 +- code_sandboxes/__init__.py | 20 +-- code_sandboxes/base.py | 30 ++-- code_sandboxes/cli.py | 32 ++--- code_sandboxes/client.py | 2 +- code_sandboxes/datalayer_sandbox.py | 32 ++++- code_sandboxes/daytona_sandbox.py | 20 +-- code_sandboxes/jupyter_server_sandbox.py | 32 ++--- code_sandboxes/kaggle_live.py | 87 +++++++----- code_sandboxes/kaggle_sandbox.py | 19 +-- code_sandboxes/manage.py | 58 ++------ code_sandboxes/modal_sandbox.py | 12 +- code_sandboxes/providers.py | 84 ++++++----- docs/docs/cli/index.mdx | 18 +-- docs/docs/cli/management.mdx | 8 +- docs/docs/examples/index.mdx | 52 +++---- docs/docs/index.mdx | 10 +- docs/docs/installation/index.mdx | 20 +-- docs/docs/sandboxes/datalayer.mdx | 2 +- docs/docs/sandboxes/daytona.mdx | 2 +- docs/docs/sandboxes/docker.mdx | 2 +- docs/docs/sandboxes/eval.mdx | 2 +- docs/docs/sandboxes/google-colab.mdx | 2 +- docs/docs/sandboxes/index.mdx | 18 +-- docs/docs/sandboxes/jupyter-server.mdx | 2 +- docs/docs/sandboxes/kaggle.mdx | 2 +- docs/docs/sandboxes/modal.mdx | 2 +- docs/docs/sandboxes/monty.mdx | 2 +- examples/README.md | 46 +++--- examples/exec/datalayer_sandbox_example.py | 2 +- examples/exec/daytona_sandbox_example.py | 4 +- examples/exec/docker_sandbox_example.py | 2 +- examples/exec/google_colab_sandbox_example.py | 4 +- .../exec/jupyter_server_sandbox_example.py | 4 +- examples/exec/kaggle_sandbox_example.py | 22 ++- examples/exec/modal_sandbox_example.py | 16 ++- examples/exec/monty_sandbox_example.py | 4 +- examples/repl/daytona_sandbox_example.py | 4 +- examples/repl/kaggle_sandbox_example.py | 10 +- examples/repl/modal_sandbox_example.py | 2 +- tests/test_cli_repl.py | 4 +- tests/test_daytona.py | 12 +- tests/test_eval.py | 6 +- tests/test_factory.py | 26 ++++ tests/test_kaggle_execute.py | 5 +- tests/test_kaggle_live.py | 7 +- tests/test_kaggle_session.py | 72 ++++++++++ tests/test_kernel_client_compatibility.py | 20 +-- tests/test_manage.py | 4 +- tests/test_modal_session.py | 24 ++-- tests/test_providers.py | 131 ++++++++++++++++++ 51 files changed, 606 insertions(+), 407 deletions(-) create mode 100644 tests/test_providers.py diff --git a/README.md b/README.md index 4e315ab..32bf8b3 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,12 @@ Code Sandboxes (`code_sandboxes`) is a Python package for running code in isolat Canonical variant names: - `datalayer` +- `daytona` - `docker` - `eval` - `google_colab` -- `jupyter` +- `jupyter-server` - `kaggle` -- `daytona` - `modal` - `monty` @@ -49,18 +49,18 @@ pip install code-sandboxes For backend-specific extras and credentials, see [https://code-sandboxes.datalayer.tech/installation](https://code-sandboxes.datalayer.tech/installation) and [https://code-sandboxes.datalayer.tech/sandboxes](https://code-sandboxes.datalayer.tech/sandboxes). -### Jupyter Sandbox +### Jupyter Server Sandbox ```python from code_sandboxes import Sandbox # Option 1: manage a local Jupyter server automatically -with Sandbox.create(variant="jupyter") as sandbox: +with Sandbox.create(variant="jupyter-server") as sandbox: print(sandbox.run_code("1 + 1").text) # 2 # Option 2: connect to an existing Jupyter server with Sandbox.create( - variant="jupyter", + variant="jupyter-server", server_url="http://localhost:8888", token="MY_TOKEN", ) as sandbox: diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index b584014..7542fd0 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -123,6 +123,7 @@ SnapshotInfo, TunnelInfo, ) +from .monty_sandbox import MontySandbox from .providers import ( PROVIDERS, ProviderRequirement, @@ -130,17 +131,12 @@ available_providers, get_provider, ) -from .monty_sandbox import MontySandbox +#: Everything this package exports, in one sorted list — the groups it +#: used to be split into stopped matching what they sat above. __all__ = [ "KAGGLE_API_TOKEN_ENV", - # Providers "PROVIDERS", - "ProviderRequirement", - "SandboxProvider", - "available_providers", - "get_provider", - # Models "CodeError", "CodeExecutionOutcome", "CodeSandboxClient", @@ -150,7 +146,6 @@ "DatalayerSandbox", "DaytonaSandbox", "DockerSandbox", - # Sandbox implementations "EvalSandbox", "ExecutionResult", "FileInfo", @@ -173,28 +168,25 @@ "OutputHandler", "OutputMessage", "ProcessHandle", + "ProviderRequirement", "ResourceConfig", "Result", - # Main sandbox class "Sandbox", "SandboxAuthenticationError", - # Commands "SandboxCommands", "SandboxConfig", "SandboxConfigurationError", "SandboxConnectionError", "SandboxEnvironment", - # Exceptions "SandboxError", "SandboxExecutionError", "SandboxFileHandle", - # Filesystem "SandboxFilesystem", "SandboxInfo", - # Management (CRUD) "SandboxManagementError", "SandboxManager", "SandboxNotStartedError", + "SandboxProvider", "SandboxQuotaExceededError", "SandboxResourceError", "SandboxSnapshotError", @@ -204,8 +196,10 @@ "SnapshotInfo", "TunnelInfo", "VariableNotFoundError", + "available_providers", "execution_result_to_reply", "get_manager", + "get_provider", "manageable_variants", "parse_google_colab_channels_url", "parse_kaggle_channels_url", diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index 76af498..d190534 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -54,6 +54,20 @@ def generate_sandbox_name() -> str: return f"{colour}-{fake.word()}-{suffix}" +def normalize_variant(variant: SandboxVariant | str) -> str: + """One spelling of a variant, as every dispatcher of this package reads it. + + The value of a variant may carry a dash — `jupyter-server` — and a caller + types it with a dash, with an underscore, in capitals, or with the + whitespace a configuration file left around it. `get_manager` and + `get_provider` have always answered to all of those; the factory here + accepted one spelling alone, which made the front door of the package the + strictest thing in it. + """ + value = variant.value if isinstance(variant, SandboxVariant) else str(variant) + return value.strip().lower().replace("-", "_") + + #: The Datalayer environment used when a caller names none. Every cluster #: provides it; the previous default, `python-cpu-env`, does not exist on #: current deployments. @@ -286,12 +300,7 @@ def create( # noqa: C901 from .eval_sandbox import EvalSandbox - # In one normal form, as every dispatcher here reads it: the value of - # a variant may carry a dash — `jupyter-server` — and callers type - # either spelling. - variant_value = ( - variant.value if isinstance(variant, SandboxVariant) else variant - ).replace("-", "_") + variant_value = normalize_variant(variant) if variant_value == "eval": sandbox = EvalSandbox(config=config, **kwargs) @@ -331,8 +340,7 @@ def create( # noqa: C901 else: raise ValueError( f"Unknown sandbox variant: {variant}. " - "Supported variants: " - + ", ".join(sorted(v.value for v in SandboxVariant)) + "Supported variants: " + ", ".join(sorted(v.value for v in SandboxVariant)) ) # Set tags if provided @@ -377,8 +385,7 @@ def list_environments( Returns: List of SandboxEnvironment entries. """ - variant_value = variant.value if isinstance(variant, SandboxVariant) else variant - variant_value = variant_value.replace("-", "_") + variant_value = normalize_variant(variant) if variant_value == "eval": from .eval_sandbox import EvalSandbox @@ -418,8 +425,7 @@ def list_environments( return DatalayerSandbox.list_environments(**kwargs) raise ValueError( f"Unknown sandbox variant: {variant}. " - "Supported variants: " - + ", ".join(sorted(v.value for v in SandboxVariant)) + "Supported variants: " + ", ".join(sorted(v.value for v in SandboxVariant)) ) @classmethod diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py index 08d344a..44804c0 100644 --- a/code_sandboxes/cli.py +++ b/code_sandboxes/cli.py @@ -314,9 +314,7 @@ def _add_sandbox_row(table: Table, info: Any) -> None: _APP_NAME_OPTION = typer.Option(None, help="Modal app name (default code-sandboxes).") _USERNAME_OPTION = typer.Option(None, help="Kaggle username override.") _TAG_OPTION = typer.Option(None, "--tag", help="Tag as key=value, repeatable (modal).") -_CAPABILITY_OPTION = typer.Option( - None, "--capability", help="Capability, repeatable (datalayer)." -) +_CAPABILITY_OPTION = typer.Option(None, "--capability", help="Capability, repeatable (datalayer).") @app.command("list") @@ -330,9 +328,7 @@ def list_sandboxes( username: str | None = _USERNAME_OPTION, ) -> None: """List sandboxes — of one variant, or of every variant that answers.""" - kwargs = _manager_kwargs( - server_url, token, proxy_token, run_url, app_name, username - ) + kwargs = _manager_kwargs(server_url, token, proxy_token, run_url, app_name, username) variants = [variant] if variant else manageable_variants() table = _sandbox_table("Code Sandboxes") skipped: list[tuple[str, str]] = [] @@ -369,9 +365,7 @@ def get( username: str | None = _USERNAME_OPTION, ) -> None: """Show one sandbox.""" - kwargs = _manager_kwargs( - server_url, token, proxy_token, run_url, app_name, username - ) + kwargs = _manager_kwargs(server_url, token, proxy_token, run_url, app_name, username) try: info = get_manager(variant, **kwargs).get(sandbox_id) except Exception as exc: @@ -398,9 +392,7 @@ def delete( username: str | None = _USERNAME_OPTION, ) -> None: """Delete a sandbox.""" - kwargs = _manager_kwargs( - server_url, token, proxy_token, run_url, app_name, username - ) + kwargs = _manager_kwargs(server_url, token, proxy_token, run_url, app_name, username) if not yes and not typer.confirm(f"Delete {variant} sandbox {sandbox_id}?"): raise typer.Exit(code=0) try: @@ -422,9 +414,7 @@ def update( name: str | None = typer.Option(None, help="New name (docker)."), tag: list[str] | None = _TAG_OPTION, capability: list[str] | None = _CAPABILITY_OPTION, - code: str | None = typer.Option( - None, help="New code, pushed as a new version (kaggle)." - ), + code: str | None = typer.Option(None, help="New code, pushed as a new version (kaggle)."), server_url: str | None = _SERVER_URL_OPTION, token: str | None = _TOKEN_OPTION, proxy_token: str | None = _PROXY_TOKEN_OPTION, @@ -433,9 +423,7 @@ def update( username: str | None = _USERNAME_OPTION, ) -> None: """Update a sandbox: what changes depends on the variant.""" - manager_kwargs = _manager_kwargs( - server_url, token, proxy_token, run_url, app_name, username - ) + manager_kwargs = _manager_kwargs(server_url, token, proxy_token, run_url, app_name, username) changes: dict[str, Any] = {} if name: changes["name"] = name @@ -470,9 +458,7 @@ def create( None, help="Environment (datalayer) or kernel name (jupyter)." ), gpu: str | None = typer.Option(None, help="GPU flavor for supported variants."), - code: str | None = typer.Option( - None, help="Code for the batch kernel (kaggle only)." - ), + code: str | None = typer.Option(None, help="Code for the batch kernel (kaggle only)."), server_url: str | None = _SERVER_URL_OPTION, token: str | None = _TOKEN_OPTION, proxy_token: str | None = _PROXY_TOKEN_OPTION, @@ -481,9 +467,7 @@ def create( username: str | None = _USERNAME_OPTION, ) -> None: """Create a sandbox and leave it running, detached from this process.""" - manager_kwargs = _manager_kwargs( - server_url, token, proxy_token, run_url, app_name, username - ) + manager_kwargs = _manager_kwargs(server_url, token, proxy_token, run_url, app_name, username) create_kwargs: dict[str, Any] = {} if gpu: create_kwargs["gpu"] = gpu diff --git a/code_sandboxes/client.py b/code_sandboxes/client.py index 0afbb1b..084a84d 100644 --- a/code_sandboxes/client.py +++ b/code_sandboxes/client.py @@ -15,7 +15,7 @@ from code_sandboxes import CodeSandboxClient # Create + own the sandbox lifecycle. - with CodeSandboxClient.create(variant="jupyter", jupyter_url=url) as client: + with CodeSandboxClient.create(variant="jupyter-server", jupyter_url=url) as client: outcome = client.execute_code("x = 1") outcome = client.execute_code("print(x)") print(outcome.stdout) # "1" diff --git a/code_sandboxes/datalayer_sandbox.py b/code_sandboxes/datalayer_sandbox.py index ad0e8a3..64f40a6 100644 --- a/code_sandboxes/datalayer_sandbox.py +++ b/code_sandboxes/datalayer_sandbox.py @@ -40,7 +40,6 @@ ) - def _urls_for_run(run_url: str): """Datalayer service URLs for a deployment served from one origin. @@ -52,7 +51,27 @@ def _urls_for_run(run_url: str): from datalayer_core.utils.urls import DatalayerURLs base = (run_url or "").rstrip("/") - return DatalayerURLs.from_environment(**{name: base for name in ['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']}) + 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, + ) + ) class DatalayerSandbox(Sandbox): @@ -168,8 +187,8 @@ def list_all( DatalayerSandbox instances. """ try: - from agent_runtimes.client import AgentClient import datalayer_core.utils.urls # noqa: F401 - availability check + from agent_runtimes.client import AgentClient except ImportError: return @@ -210,8 +229,8 @@ def list_environments( run_url: Optional[str] = None, ) -> list[SandboxEnvironment]: try: - from agent_runtimes.client import AgentClient import datalayer_core.utils.urls # noqa: F401 - availability check + from agent_runtimes.client import AgentClient except ImportError: return [] @@ -250,9 +269,9 @@ def start(self) -> None: try: # Import here to avoid hard dependency + import datalayer_core.utils.urls # noqa: F401 - availability check from agent_runtimes.client import AgentClient from agent_runtimes.client.agent_client import DEFAULT_TIME_RESERVATION - import datalayer_core.utils.urls # noqa: F401 - availability check except ImportError as e: raise SandboxConfigurationError( "agent-runtimes package is required for DatalayerSandbox. " @@ -562,8 +581,7 @@ def run_code( execution_ok=True, code_error=code_error, exit_code=exit_code, - execution_count=getattr(response, "execution_count", None) - or self._execution_count, + execution_count=getattr(response, "execution_count", None) or self._execution_count, context_id=context.id if context else "default", started_at=started_at, completed_at=time.time(), diff --git a/code_sandboxes/daytona_sandbox.py b/code_sandboxes/daytona_sandbox.py index 9476316..e0aff4d 100644 --- a/code_sandboxes/daytona_sandbox.py +++ b/code_sandboxes/daytona_sandbox.py @@ -367,9 +367,7 @@ def _create_params(self, daytona: Any) -> Any: if self._python_version else daytona.Image.debian_slim() ) - return daytona.CreateSandboxFromImageParams( - image=image, resources=resources, **common - ) + return daytona.CreateSandboxFromImageParams(image=image, resources=resources, **common) return daytona.CreateSandboxFromSnapshotParams(snapshot=self._snapshot, **common) def _labels(self) -> dict[str, str]: @@ -430,9 +428,7 @@ def stop(self) -> None: else: self._sandbox.stop() except Exception: - logger.debug( - "Ignoring error while stopping the Daytona sandbox", exc_info=True - ) + logger.debug("Ignoring error while stopping the Daytona sandbox", exc_info=True) self._sandbox = None self._daytona = None self._contexts.clear() @@ -579,16 +575,12 @@ def _get_internal_variable(self, name: str, context: Context | None = None) -> A _emit_text(f"{_JSON_MOD}.dumps({name}, default=repr)"), context=context ) if not execution.execution_ok: - raise SandboxExecutionError( - execution.execution_error or "Sandbox execution failed" - ) + raise SandboxExecutionError(execution.execution_error or "Sandbox execution failed") if execution.code_error is not None or execution.text is None: raise VariableNotFoundError(name) return json.loads(execution.text) - def _set_internal_variable( - self, name: str, value: Any, context: Context | None = None - ) -> None: + def _set_internal_variable(self, name: str, value: Any, context: Context | None = None) -> None: if not self._started or self._sandbox is None: raise SandboxNotStartedError() try: @@ -606,9 +598,7 @@ def _set_internal_variable( context=context, ) if not execution.execution_ok: - raise SandboxExecutionError( - execution.execution_error or "Sandbox execution failed" - ) + raise SandboxExecutionError(execution.execution_error or "Sandbox execution failed") if execution.code_error is not None: raise SandboxExecutionError(str(execution.code_error)) diff --git a/code_sandboxes/jupyter_server_sandbox.py b/code_sandboxes/jupyter_server_sandbox.py index 443f673..59a8f38 100644 --- a/code_sandboxes/jupyter_server_sandbox.py +++ b/code_sandboxes/jupyter_server_sandbox.py @@ -10,6 +10,7 @@ from __future__ import annotations +import contextlib import logging import os import signal @@ -240,7 +241,7 @@ def _start_local_server_subprocess(self, workdir: str, port: int) -> None: workdir, ) - self._server_process = subprocess.Popen( + self._server_process = subprocess.Popen( # noqa: S603 — argv built above, no shell cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -431,15 +432,13 @@ def _setup_tool_caller(self) -> None: """Keep tool calling on the client side for Jupyter sandboxes.""" return - def stop(self) -> None: + def stop(self) -> None: # noqa: C901 if not self._started: return if self._client is not None: - try: + with contextlib.suppress(Exception): self._client.stop() - except Exception: - pass self._client = None # Terminate subprocess-based server @@ -452,37 +451,30 @@ def stop(self) -> None: self._server_process.terminate() self._server_process.wait(timeout=5) except Exception: - try: + # It would not go quietly; there is nothing after `kill`. + with contextlib.suppress(Exception): self._server_process.kill() - except Exception: - pass self._server_process = None # Terminate in-process server (legacy mode) if self._server_app is not None and self._owns_server: - try: + with contextlib.suppress(Exception): if getattr(self._server_app, "io_loop", None): self._server_app.io_loop.add_callback(self._server_app.stop) else: self._server_app.stop() - except Exception: - pass self._server_app = None if self._server_thread is not None and self._owns_server: - try: + with contextlib.suppress(Exception): self._server_thread.join(timeout=5) - except Exception: - pass self._server_thread = None if self._workdir_tmp and os.path.isdir(self._workdir_tmp): - try: - import shutil + import shutil - shutil.rmtree(self._workdir_tmp, ignore_errors=True) - except Exception: - pass + # `ignore_errors` already swallows what the tree throws. + shutil.rmtree(self._workdir_tmp, ignore_errors=True) self._workdir_tmp = None self._started = False @@ -509,7 +501,7 @@ def _do_interrupt(self) -> bool: logger.warning(f"Failed to interrupt Jupyter kernel: {e}") return False - def run_code( + def run_code( # noqa: C901 self, code: str, language: str = "python", diff --git a/code_sandboxes/kaggle_live.py b/code_sandboxes/kaggle_live.py index 1956540..d83500b 100644 --- a/code_sandboxes/kaggle_live.py +++ b/code_sandboxes/kaggle_live.py @@ -34,7 +34,7 @@ import time import uuid from pathlib import Path -from typing import Callable, Any, Optional +from typing import Any, Callable __all__ = ["KaggleLiveSession", "build_agent_code", "resolve_kaggle_credentials"] @@ -150,7 +150,7 @@ def build_agent_code( "idle": idle_timeout, } ) - template = ''' + template = """ import json, os, shutil, tempfile, time from pathlib import Path @@ -194,12 +194,31 @@ def sink(msg): kind = msg["msg_type"] content = msg["content"] if kind == "stream": - outputs.append({"output_type": "stream", "name": content["name"], "text": content["text"]}) + outputs.append( + { + "output_type": "stream", + "name": content["name"], + "text": content["text"], + } + ) elif kind in ("execute_result", "display_data"): - outputs.append({"output_type": kind, "data": content.get("data", {}), "metadata": content.get("metadata", {})}) + outputs.append( + { + "output_type": kind, + "data": content.get("data", {}), + "metadata": content.get("metadata", {}), + } + ) elif kind == "error": state["status"] = "error" - outputs.append({"output_type": "error", "ename": content["ename"], "evalue": content["evalue"], "traceback": content.get("traceback", [])}) + outputs.append( + { + "output_type": "error", + "ename": content["ename"], + "evalue": content["evalue"], + "traceback": content.get("traceback", []), + } + ) reply = client.execute_interactive(code, output_hook=sink, timeout=None) if reply["content"]["status"] == "error": state["status"] = "error" @@ -226,7 +245,7 @@ def sink(msg): client.stop_channels() manager.shutdown_kernel(now=True) print("agent: session closed") -''' +""" return template.replace("%SETTINGS%", repr(settings)) @@ -243,7 +262,7 @@ def __init__( executor: Any, *, api: Any = None, - session_id: Optional[str] = None, + session_id: str | None = None, poll_seconds: float = POLL_SECONDS, ) -> None: self._executor = executor @@ -254,11 +273,11 @@ def __init__( self._api = api self._poll = poll_seconds self._session_id = session_id or uuid.uuid4().hex[:8] - self._username: Optional[str] = None - self._c2k: Optional[str] = None - self._k2c: Optional[str] = None + self._username: str | None = None + self._c2k: str | None = None + self._k2c: str | None = None self._seq = 0 - self._slug: Optional[str] = None + self._slug: str | None = None self.id = f"kaggle-live-{self._session_id}" # -- the bus --------------------------------------------------------- @@ -292,13 +311,11 @@ def _write_bus(self, ref: str, payload: dict, *, create: bool) -> None: shutil.rmtree(folder, ignore_errors=True) - def _read_bus(self, ref: str) -> Optional[dict]: + def _read_bus(self, ref: str) -> dict | None: folder = Path(tempfile.mkdtemp(prefix="bus-")) try: with contextlib.redirect_stdout(io.StringIO()): - self._api.dataset_download_files( - ref, path=str(folder), force=True, unzip=True - ) + self._api.dataset_download_files(ref, path=str(folder), force=True, unzip=True) return json.loads((folder / "message.json").read_text(encoding="utf-8")) except Exception: # Not there yet, or a version still processing: the caller polls. @@ -316,15 +333,16 @@ def _agent_log_tail(self, lines: int = 25) -> str: return "" folder = Path(tempfile.mkdtemp(prefix="bus-log-")) try: - with contextlib.redirect_stdout(io.StringIO()): - files = self._executor.output(self._slug, str(folder)) - for name in files: - path = Path(name) - if path.suffix == ".log" or path.name == "output.log": - text = path.read_text(encoding="utf-8", errors="replace") - return "\n".join(text.splitlines()[-lines:]) - except Exception: # noqa: BLE001 — the log is a courtesy, not a right - pass + # A post-mortem is a nicety: a job with no log yet, or one whose + # artifacts cannot be fetched, leaves the caller its own error. + with contextlib.suppress(Exception): + with contextlib.redirect_stdout(io.StringIO()): + files = self._executor.output(self._slug, str(folder)) + for name in files: + path = Path(name) + if path.suffix == ".log" or path.name == "output.log": + text = path.read_text(encoding="utf-8", errors="replace") + return "\n".join(text.splitlines()[-lines:]) finally: import shutil @@ -334,9 +352,9 @@ def _agent_log_tail(self, lines: int = 25) -> str: def start( self, *, - accelerator: Optional[str] = None, + accelerator: str | None = None, ready_timeout: float = READY_TIMEOUT_SECONDS, - on_progress: Optional[Callable[[str], None]] = print, + on_progress: Callable[[str], None] | None = print, ) -> None: """Create the bus, submit the agent, wait until the kernel answers. @@ -378,7 +396,7 @@ def start( started = time.monotonic() deadline = started + ready_timeout - last_status: Optional[str] = None + last_status: str | None = None last_note = started while time.monotonic() < deadline: message = self._read_bus(self._k2c) @@ -387,11 +405,10 @@ def start( return # The job's own status: a dead agent must fail NOW, with its log, # not at the end of a fifteen-minute timeout. - status: Optional[str] = None - try: + status: str | None = None + # Unreachable status is not a dead job: the wait goes on. + with contextlib.suppress(Exception): status = self._executor.status(self._slug) if self._slug else None - except Exception: # noqa: BLE001 — status is telemetry, not truth - pass if status in ("ERROR", "CANCEL_ACKNOWLEDGED", "COMPLETE"): tail = self._agent_log_tail() hint = "" @@ -424,7 +441,7 @@ def start( "longest — or the account may not allow internet-enabled kernels." ) - def execute(self, code: str, timeout: Optional[float] = None) -> dict: + def execute(self, code: str, timeout: float | None = None) -> dict: """Run one snippet on the live kernel and return its reply.""" if self._c2k is None or self._k2c is None: raise RuntimeError("The live session is not started.") @@ -447,11 +464,9 @@ def stop(self, shutdown_kernel: bool = True) -> None: """Tell the agent to go; the datasets remain, small and private.""" if shutdown_kernel and self._c2k: self._seq += 1 - try: + # The agent's idle timeout is the backstop. + with contextlib.suppress(Exception): self._write_bus(self._c2k, {"seq": self._seq, "op": "shutdown"}, create=False) - except Exception: - # The agent's idle timeout is the backstop. - pass def get_variable(self, name: str) -> Any: raise NotImplementedError( diff --git a/code_sandboxes/kaggle_sandbox.py b/code_sandboxes/kaggle_sandbox.py index b12aeb9..16092e8 100644 --- a/code_sandboxes/kaggle_sandbox.py +++ b/code_sandboxes/kaggle_sandbox.py @@ -135,11 +135,7 @@ def start(self) -> None: # Live mode: a batch job runs an agent holding a real ipykernel, and # the code travels over the account's own datasets — one persistent # kernel, no replay, no external service. See `kaggle_live`. - if ( - self._extra_kwargs.get("live") - and not self._server_url - and not self._channels_url - ): + if self._extra_kwargs.get("live") and not self._server_url and not self._channels_url: from .kaggle_live import KaggleLiveSession executor = KaggleKernelExecutor( @@ -525,6 +521,7 @@ def run_code_streaming( # noqa: C901 reply = self._cut_replayed_outputs(reply, session_marker) submitted.kernel_reply = reply + raised = False if isinstance(reply, dict): for output in reply.get("outputs", []): output_type = output.get("output_type") @@ -544,21 +541,27 @@ def run_code_streaming( # noqa: C901 extra=output.get("metadata", {}), ) elif output_type == "error": + raised = True yield CodeError( name=output.get("ename", "Error"), value=output.get("evalue", ""), traceback="\n".join(output.get("traceback", [])), ) + was_interrupted = self._interrupt_requested.is_set() if status != "COMPLETE": yield CodeError( name="KaggleExecutionError", value=failure_message or f"Kaggle execution failed with status: {status}", traceback=getattr(submitted, "log", "") or "", ) - else: - # Only a snippet that completed joins the session — see the batch - # path, which records under the same condition. + elif not raised and not was_interrupted: + # Only a snippet that ran WITHOUT ERROR joins the session, as in + # the batch path: the replay re-runs it ahead of every turn after + # this one, so recording a failing snippet fails every one of + # them. The status is not the test — a job whose cell raised still + # completes, because the JOB ran; what the outputs say is whether + # the CODE did. self._record_session(code) self._executing_event.clear() diff --git a/code_sandboxes/manage.py b/code_sandboxes/manage.py index d92aebe..5e7c581 100644 --- a/code_sandboxes/manage.py +++ b/code_sandboxes/manage.py @@ -88,9 +88,7 @@ def create(self, **kwargs: Any) -> SandboxInfo: def update(self, sandbox_id: str, **changes: Any) -> SandboxInfo: """Change what the backend can change; the sandbox as it now is.""" - raise self._unsupported( - "update", "this backend has nothing that can be changed in place" - ) + raise self._unsupported("update", "this backend has nothing that can be changed in place") def _unsupported(self, verb: str, reason: str) -> SandboxManagementError: return SandboxManagementError( @@ -116,9 +114,7 @@ def delete(self, sandbox_id: str) -> bool: raise self._unsupported("delete", self._reason) def create(self, **kwargs: Any) -> SandboxInfo: - raise self._unsupported( - "detached create", self._reason + "; use Sandbox.create() instead" - ) + raise self._unsupported("detached create", self._reason + "; use Sandbox.create() instead") def update(self, sandbox_id: str, **changes: Any) -> SandboxInfo: raise self._unsupported("update", self._reason) @@ -162,9 +158,7 @@ def _client(self) -> Any: def _containers(self) -> list[Any]: client = self._client() - labelled = client.containers.list( - all=True, filters={"label": self.LABEL} - ) + labelled = client.containers.list(all=True, filters={"label": self.LABEL}) seen = {c.id for c in labelled} # Containers from before the label existed: found by their image. for container in client.containers.list(all=True): @@ -243,7 +237,7 @@ def create(self, **kwargs: Any) -> SandboxInfo: class JupyterServerSandboxManager(SandboxManager): """Kernels of a Jupyter Server, spoken to over its REST API. - A ``jupyter`` sandbox started with ``server_url`` lives on that server as + A ``jupyter-server`` sandbox started with ``server_url`` lives on that server as a kernel; this manager enumerates and deletes those kernels. The server defaults to ``JUPYTER_SERVER_URL``/``JUPYTER_TOKEN`` from the environment, then to ``http://localhost:8888``. @@ -259,9 +253,7 @@ def __init__( **_: Any, ) -> None: self._server_url = ( - server_url - or os.environ.get("JUPYTER_SERVER_URL") - or "http://localhost:8888" + server_url or os.environ.get("JUPYTER_SERVER_URL") or "http://localhost:8888" ).rstrip("/") self._token = token if token is not None else os.environ.get("JUPYTER_TOKEN") @@ -359,8 +351,7 @@ def _request(self, method: str, path: str, **kwargs: Any) -> Any: # to answer what it cannot do. if not self._colab_url: raise SandboxManagementError( - "A Colab runtime URL is required: pass server_url=... or set " - "RUNTIME_URL." + "A Colab runtime URL is required: pass server_url=... or set RUNTIME_URL." ) from .google_colab import ( COLAB_CLIENT_AGENT_HEADER, @@ -483,9 +474,7 @@ def update(self, sandbox_id: str, code: str | None = None, **_: Any) -> SandboxI try: executor.api.kernels_pull(ref, str(folder), metadata=True) except Exception as exc: - raise SandboxManagementError( - f"No kaggle sandbox found: {sandbox_id} ({exc})" - ) from exc + raise SandboxManagementError(f"No kaggle sandbox found: {sandbox_id} ({exc})") from exc metadata = json.loads((folder / "kernel-metadata.json").read_text()) code_file = folder / (metadata.get("code_file") or "kernel.py") if code_file.suffix == ".ipynb": @@ -515,9 +504,7 @@ def update(self, sandbox_id: str, code: str | None = None, **_: Any) -> SandboxI raise SandboxManagementError(f"Kaggle refused the update: {error}") info = self.get(ref) if info is None: - raise SandboxManagementError( - f"The kernel disappeared while updating: {ref}" - ) + raise SandboxManagementError(f"The kernel disappeared while updating: {ref}") info.metadata["version"] = getattr(response, "version_number", "") return info @@ -592,9 +579,7 @@ def delete(self, sandbox_id: str) -> bool: sandbox.terminate() return True - def update( - self, sandbox_id: str, tags: dict[str, str] | None = None, **_: Any - ) -> SandboxInfo: + def update(self, sandbox_id: str, tags: dict[str, str] | None = None, **_: Any) -> SandboxInfo: """Set tags on the sandbox — what Modal changes on a running one.""" if not tags: raise self._unsupported("update without tags=...", "only tags change") @@ -602,9 +587,7 @@ def update( try: sandbox = modal.Sandbox.from_id(sandbox_id) except Exception as exc: - raise SandboxManagementError( - f"No modal sandbox found: {sandbox_id}" - ) from exc + raise SandboxManagementError(f"No modal sandbox found: {sandbox_id}") from exc sandbox.set_tags(tags) info = self.get(sandbox_id) if info is None: @@ -717,18 +700,14 @@ def delete(self, sandbox_id: str) -> bool: client.delete(sandbox) return True - def update( - self, sandbox_id: str, tags: dict[str, str] | None = None, **_: Any - ) -> SandboxInfo: + def update(self, sandbox_id: str, tags: dict[str, str] | None = None, **_: Any) -> SandboxInfo: """Set labels on the sandbox — what Daytona changes on a running one.""" if not tags: raise self._unsupported("update without tags=...", "only labels change") try: sandbox = self._client().get(sandbox_id) except Exception as exc: - raise SandboxManagementError( - f"No daytona sandbox found: {sandbox_id}" - ) from exc + raise SandboxManagementError(f"No daytona sandbox found: {sandbox_id}") from exc # Daytona REPLACES the label set, so what is there is kept and the # tags given are written over it — an update of one tag is not a # deletion of the others. @@ -775,15 +754,12 @@ def _get_client(self) -> Any: from agent_runtimes.client import AgentClient except ImportError as exc: raise SandboxManagementError( - "agent_runtimes package is required: " - "pip install code-sandboxes[datalayer]" + "agent_runtimes package is required: pip install code-sandboxes[datalayer]" ) from exc if self._run_url: from .datalayer_sandbox import _urls_for_run - self._client = AgentClient( - urls=_urls_for_run(self._run_url), api_key=self._token - ) + self._client = AgentClient(urls=_urls_for_run(self._run_url), api_key=self._token) else: self._client = AgentClient(api_key=self._token) return self._client @@ -891,11 +867,7 @@ def get_manager(variant: str, **kwargs: Any) -> SandboxManager: # Keys carry the variant's own spelling — `jupyter-server` with its dash — # and lookups arrive in either form: compare in one normal form. manager_class = next( - ( - cls - for key, cls in _MANAGERS.items() - if key.replace("-", "_") == normalized - ), + (cls for key, cls in _MANAGERS.items() if key.replace("-", "_") == normalized), None, ) if manager_class is None: diff --git a/code_sandboxes/modal_sandbox.py b/code_sandboxes/modal_sandbox.py index 9f83b23..b1751d6 100644 --- a/code_sandboxes/modal_sandbox.py +++ b/code_sandboxes/modal_sandbox.py @@ -16,6 +16,7 @@ from __future__ import annotations +import contextlib import logging import math import time @@ -127,7 +128,7 @@ def _resolve_modal_gpu(gpu_flavor: str, modal_module: Any) -> Any: def _modal_exec_timeout_seconds(timeout: float | None, default: float) -> int: """Return a Modal-compatible timeout in integer seconds.""" value = timeout if timeout is not None else default - return max(1, int(math.ceil(value))) + return max(1, math.ceil(value)) class ModalSandbox(Sandbox): @@ -259,19 +260,18 @@ def _start_driver(self) -> None: driver = self._sandbox.exec(self._python_executable, "-u", "-c", _DRIVER_SOURCE) except Exception: logger.warning( - "The Modal session driver could not be started; snippets will " - "not share state.", + "The Modal session driver could not be started; snippets will not share state.", exc_info=True, ) return replies: queue.Queue = queue.Queue() def pump() -> None: - try: + # The reader dies with the driver, and says so with the sentinel + # below rather than with an exception nobody is there to catch. + with contextlib.suppress(Exception): for line in driver.stdout: replies.put(line) - except Exception: # noqa: BLE001 — the reader dies with the driver - pass replies.put(None) # A thread reads the replies: the stream blocks, and a request that diff --git a/code_sandboxes/providers.py b/code_sandboxes/providers.py index ebd8988..d6f24ea 100644 --- a/code_sandboxes/providers.py +++ b/code_sandboxes/providers.py @@ -22,19 +22,20 @@ from __future__ import annotations import os +from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path -from typing import Callable, Mapping, Optional +from typing import Callable from .models import SandboxEnvironment, SandboxVariant __all__ = [ + "PROVIDERS", "ProviderRequirement", "SandboxProvider", - "PROVIDERS", "available_providers", - "provider_catalog", "get_provider", + "provider_catalog", ] @@ -50,11 +51,11 @@ class ProviderRequirement: #: Environment variables that must all be set for this way to be satisfied. env_vars: tuple[str, ...] = () #: A file that satisfies it instead, e.g. what a provider's CLI writes. - file: Optional[str] = None + file: str | None = None #: What to tell someone who has none of it. hint: str = "" - def is_met(self, secrets: Optional[Mapping[str, str]] = None) -> bool: + def is_met(self, secrets: Mapping[str, str] | None = None) -> bool: """Whether this way of providing the credentials is satisfied. Args: @@ -83,11 +84,19 @@ class SandboxProvider: #: Any one of these satisfies the provider; empty means nothing is needed. requirements: tuple[ProviderRequirement, ...] = () #: Extra packages needed, as the extra of this distribution. - extra: Optional[str] = None + extra: str | None = None #: Whether the provider can be used with no credentials at all. needs_credentials: bool = True + #: Credentials the environment listing takes as ARGUMENTS, as pairs of + #: (keyword, the variable holding its value). + #: + #: Most providers ship a fixed list and need none. Datalayer asks its + #: platform, so it needs the account to ask on behalf of — and reading it + #: from the process environment is exactly wrong for a service, which + #: holds the credentials of whoever is asking and not of itself. + environment_secrets: tuple[tuple[str, str], ...] = () #: Read the environments this provider ships, when it can be asked. - list_environments: Optional[Callable[[], list[SandboxEnvironment]]] = field( + list_environments: Callable[..., list[SandboxEnvironment]] | None = field( default=None, repr=False ) @@ -96,7 +105,7 @@ def name(self) -> str: """The identifier of the provider, which is that of its variant.""" return self.variant.value - def is_available(self, secrets: Optional[Mapping[str, str]] = None) -> bool: + def is_available(self, secrets: Mapping[str, str] | None = None) -> bool: """Whether the provider's requirements are met, here or in `secrets`.""" if not self.needs_credentials or not self.requirements: return True @@ -106,29 +115,43 @@ def missing(self) -> tuple[ProviderRequirement, ...]: """The ways of satisfying it, when none of them is satisfied.""" return () if self.is_available() else self.requirements - def environments(self) -> list[SandboxEnvironment]: - """The environments the provider ships, or none when it cannot say.""" + def environments(self, secrets: Mapping[str, str] | None = None) -> list[SandboxEnvironment]: + """The environments the provider ships, or none when it cannot say. + + Args: + secrets: Where to read the credentials the listing takes as + arguments. The process environment by default; a service + passes the secrets of an ACCOUNT, and then asking the platform + what THAT account may launch is the whole point — asked + without them, the provider reads as enabled and ships nothing. + """ if self.list_environments is None: return [] + store: Mapping[str, str] = os.environ if secrets is None else secrets + arguments = { + keyword: store[variable] + for keyword, variable in self.environment_secrets + if store.get(variable) + } try: - return self.list_environments() - except Exception: # noqa: BLE001 + return self.list_environments(**arguments) + except Exception: # A provider that cannot be reached ships nothing rather than # taking down whoever asked what is available. return [] -def _environments_of(variant: SandboxVariant) -> Callable[[], list[SandboxEnvironment]]: +def _environments_of(variant: SandboxVariant) -> Callable[..., list[SandboxEnvironment]]: """Read a variant's own environments, lazily. Imported on the call rather than here: a provider whose package is not installed must cost nothing until someone asks about it. """ - def read() -> list[SandboxEnvironment]: + def read(**kwargs) -> list[SandboxEnvironment]: from .base import Sandbox - return Sandbox.list_environments(variant=variant) + return Sandbox.list_environments(variant=variant, **kwargs) return read @@ -148,6 +171,10 @@ def read() -> list[SandboxEnvironment]: hint="Sign in with `datalayer login`, or set DATALAYER_TOKEN.", ), ), + environment_secrets=( + ("token", "DATALAYER_TOKEN"), + ("run_url", "DATALAYER_RUN_URL"), + ), list_environments=_environments_of(SandboxVariant.DATALAYER), ), SandboxProvider( @@ -169,8 +196,7 @@ def read() -> list[SandboxEnvironment]: variant=SandboxVariant.KAGGLE, title="Kaggle", description=( - "Kaggle notebook sessions, interactively against a running kernel " - "or as a batch job." + "Kaggle notebook sessions, interactively against a running kernel or as a batch job." ), extra="kaggle", requirements=( @@ -214,24 +240,17 @@ def read() -> list[SandboxEnvironment]: variant=SandboxVariant.DAYTONA, title="Daytona", description=( - "Sandboxes on Daytona, with a stateful Python interpreter and an " - "optional GPU." + "Sandboxes on Daytona, with a stateful Python interpreter and an optional GPU." ), extra="daytona", requirements=( ProviderRequirement( env_vars=("DAYTONA_API_KEY",), - hint=( - "Create an API key at app.daytona.io and set " - "DAYTONA_API_KEY." - ), + hint=("Create an API key at app.daytona.io and set DAYTONA_API_KEY."), ), ProviderRequirement( env_vars=("DAYTONA_JWT_TOKEN", "DAYTONA_ORGANIZATION_ID"), - hint=( - "Set DAYTONA_JWT_TOKEN with the DAYTONA_ORGANIZATION_ID it " - "belongs to." - ), + hint=("Set DAYTONA_JWT_TOKEN with the DAYTONA_ORGANIZATION_ID it belongs to."), ), ), list_environments=_environments_of(SandboxVariant.DAYTONA), @@ -247,8 +266,7 @@ def read() -> list[SandboxEnvironment]: variant=SandboxVariant.EVAL, title="Eval", description=( - "Code evaluated in this very process. For tests and examples; it " - "isolates nothing." + "Code evaluated in this very process. For tests and examples; it isolates nothing." ), needs_credentials=False, list_environments=_environments_of(SandboxVariant.EVAL), @@ -256,7 +274,7 @@ def read() -> list[SandboxEnvironment]: ) -def get_provider(name: str) -> Optional[SandboxProvider]: +def get_provider(name: str) -> SandboxProvider | None: """The provider of that name, if there is one. Args: @@ -272,14 +290,14 @@ def get_provider(name: str) -> Optional[SandboxProvider]: def available_providers( - secrets: Optional[Mapping[str, str]] = None, + secrets: Mapping[str, str] | None = None, ) -> tuple[SandboxProvider, ...]: """The providers whose credentials are on hand, here or in `secrets`.""" return tuple(p for p in PROVIDERS if p.is_available(secrets)) def provider_catalog( - secrets: Optional[Mapping[str, str]] = None, + secrets: Mapping[str, str] | None = None, ) -> list[dict]: """Every provider as plain data, for a service to serve. @@ -313,7 +331,7 @@ def provider_catalog( "title": environment.title, "language": environment.language, } - for environment in (provider.environments() if enabled else []) + for environment in (provider.environments(secrets) if enabled else []) ], } ) diff --git a/docs/docs/cli/index.mdx b/docs/docs/cli/index.mdx index ebb6a2b..3e2a98a 100644 --- a/docs/docs/cli/index.mdx +++ b/docs/docs/cli/index.mdx @@ -25,25 +25,25 @@ You can either pass `--variant` directly or omit it and choose interactively. Supported variants: -- `jupyter-server` +- `datalayer` +- `daytona` - `docker` - `eval` -- `monty` -- `kaggle` - `google-colab` +- `jupyter-server` +- `kaggle` - `modal` -- `daytona` -- `datalayer` +- `monty` ## Variant-specific Behavior -- `jupyter-server`: starts a managed local Jupyter server on a random port. -- `kaggle`: supports either interactive runtime settings or credential-based batch execution. -- `monty`: starts a Monty REPL-backed sandbox. -- `modal`: starts a Modal sandbox container. - `daytona`: starts a Daytona sandbox; `--gpu` takes Daytona's own flavors (`H100`, `H200`, `RTX-4090`, ...). - `google-colab`: prompts for runtime URL, kernel ID, and proxy token. +- `jupyter-server`: starts a managed local Jupyter server on a random port. +- `kaggle`: supports either interactive runtime settings or credential-based batch execution. +- `modal`: starts a Modal sandbox container. +- `monty`: starts a Monty REPL-backed sandbox. ## Usage diff --git a/docs/docs/cli/management.mdx b/docs/docs/cli/management.mdx index 8f6a5d2..7e0ffce 100644 --- a/docs/docs/cli/management.mdx +++ b/docs/docs/cli/management.mdx @@ -59,7 +59,7 @@ below the table instead of hiding the ones that answered. from code_sandboxes import get_manager, manageable_variants print(manageable_variants()) -# ['datalayer', 'daytona', 'docker', 'eval', 'google_colab', 'jupyter', 'kaggle', 'modal', 'monty'] +# ['datalayer', 'daytona', 'docker', 'eval', 'google_colab', 'jupyter-server', 'kaggle', 'modal', 'monty'] manager = get_manager("modal") for info in manager.list(): @@ -85,13 +85,13 @@ get_manager("datalayer", token="...", run_url="https://...") | Variant | A sandbox is | Update changes | Delete removes | | --- | --- | --- | --- | | `datalayer` | a runtime of the Datalayer platform | the capabilities | the runtime | -| `modal` | a Modal sandbox of the `code-sandboxes` app | the tags | the Modal sandbox | | `daytona` | a sandbox of the Daytona organization | the labels | the Daytona sandbox | | `docker` | a container labelled `code-sandboxes` | the name | the container (forced) | -| `jupyter-server` | a kernel of the Jupyter Server | — nothing in place | the kernel | +| `eval`, `monty` | an object inside the creating process | — not supported | — not supported | | `google_colab` | a kernel of the Colab runtime | — nothing in place | the kernel | +| `jupyter-server` | a kernel of the Jupyter Server | — nothing in place | the kernel | | `kaggle` | a kernel on kaggle.com (batch mode creates one per run) | the code (a new version) | the kernel | -| `eval`, `monty` | an object inside the creating process | — not supported | — not supported | +| `modal` | a Modal sandbox of the `code-sandboxes` app | the tags | the Modal sandbox | Not every backend can honour every verb. A manager states what it supports through `manager.capabilities` and raises `SandboxManagementError` with the diff --git a/docs/docs/examples/index.mdx b/docs/docs/examples/index.mdx index 59c1e9e..08057ec 100644 --- a/docs/docs/examples/index.mdx +++ b/docs/docs/examples/index.mdx @@ -11,12 +11,20 @@ For canonical setup details per variant (requirements, credentials, and parameters), see [Sandboxes](/sandboxes). For installation extras, see [Installation](/installation). -## Eval +## Datalayer -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/eval_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/datalayer_sandbox_example.py ```bash -make eval +make datalayer +``` + +## Daytona + +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/daytona_sandbox_example.py + +```bash +make daytona ``` ## Docker @@ -27,36 +35,36 @@ make eval make docker ``` -## Jupyter +## Eval -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/jupyter_server_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/eval_sandbox_example.py ```bash -make jupyter +make eval ``` -## Monty +## Google Colab -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/monty_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/google_colab_sandbox_example.py ```bash -make monty +make google-colab ``` -## Kaggle +## Jupyter -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/kaggle_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/jupyter_server_sandbox_example.py ```bash -make kaggle +make jupyter ``` -## Colab +## Kaggle -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/google_colab_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/kaggle_sandbox_example.py ```bash -make google-colab +make kaggle ``` ## Modal @@ -67,20 +75,12 @@ make google-colab make modal ``` -## Daytona - -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/exec/daytona_sandbox_example.py - -```bash -make daytona -``` - -## Datalayer +## Monty -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/datalayer_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/monty_sandbox_example.py ```bash -make datalayer +make monty ``` See the Makefile for all targets: https://github.com/datalayer/code-sandboxes/blob/main/examples/Makefile diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index 4aa3159..7b17593 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -63,15 +63,15 @@ Code Sandboxes supports these execution variants: | Variant | Isolation Level | Best For | |---------|-----------------|----------| -| `eval` | None (Python exec) | Development, testing | -| `monty` | In-process secure interpreter | Safe, fast LLM snippets | +| `datalayer` | Managed VM/runtime | Production and GPU workloads | +| `daytona` | Managed cloud sandbox | Stateful agent sessions | | `docker` | Container | Isolated execution | +| `eval` | None (Python exec) | Development, testing | +| `google-colab` | Managed notebook runtime | Interactive Colab-connected runs | | `jupyter-server` | Process (Jupyter kernel) | Persistent notebook-style state | | `kaggle` | Managed notebook runtime | Interactive and batch runs | -| `google-colab` | Managed notebook runtime | Interactive Colab-connected runs | | `modal` | Managed container runtime | Ephemeral compute tasks | -| `daytona` | Managed cloud sandbox | Stateful agent sessions | -| `datalayer` | Managed VM/runtime | Production and GPU workloads | +| `monty` | In-process secure interpreter | Safe, fast LLM snippets | ## Quick Start diff --git a/docs/docs/installation/index.mdx b/docs/docs/installation/index.mdx index c5ca731..b387e2d 100644 --- a/docs/docs/installation/index.mdx +++ b/docs/docs/installation/index.mdx @@ -21,20 +21,20 @@ Code Sandboxes supports different execution backends via extras: # With Datalayer variant support pip install code-sandboxes[datalayer] +# With Daytona variant support +pip install code-sandboxes[daytona] + # With Docker variant support pip install code-sandboxes[docker] # With Kaggle support pip install code-sandboxes[kaggle] -# With Monty (secure in-process interpreter) support -pip install code-sandboxes[monty] - # With Modal variant support pip install code-sandboxes[modal] -# With Daytona variant support -pip install code-sandboxes[daytona] +# With Monty (secure in-process interpreter) support +pip install code-sandboxes[monty] # All features pip install code-sandboxes[all] @@ -43,16 +43,16 @@ pip install code-sandboxes[all] ## Requirements - Python 3.10 or higher -- For Docker variant: Docker installed and running - For Datalayer variant: valid `DATALAYER_API_KEY` -- For Kaggle variant: `code-sandboxes[kaggle]` and Kaggle credentials (for batch mode) or runtime connection values (for interactive mode) +- For Daytona variant: `code-sandboxes[daytona]` and a Daytona API key + (`DAYTONA_API_KEY`, from app.daytona.io) +- For Docker variant: Docker installed and running - For Google Colab variant: a Colab runtime assignment (server URL, kernel id, proxy token) -- For Monty variant: `code-sandboxes[monty]` (no credentials required) +- For Kaggle variant: `code-sandboxes[kaggle]` and Kaggle credentials (for batch mode) or runtime connection values (for interactive mode) - For Modal variant: `code-sandboxes[modal]` and Modal credentials (`modal token new` or `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET`) -- For Daytona variant: `code-sandboxes[daytona]` and a Daytona API key - (`DAYTONA_API_KEY`, from app.daytona.io) +- For Monty variant: `code-sandboxes[monty]` (no credentials required) ## Configuration diff --git a/docs/docs/sandboxes/datalayer.mdx b/docs/docs/sandboxes/datalayer.mdx index 068a290..c11478a 100644 --- a/docs/docs/sandboxes/datalayer.mdx +++ b/docs/docs/sandboxes/datalayer.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 10 +sidebar_position: 2 title: Datalayer --- diff --git a/docs/docs/sandboxes/daytona.mdx b/docs/docs/sandboxes/daytona.mdx index e9c50dd..ed7c211 100644 --- a/docs/docs/sandboxes/daytona.mdx +++ b/docs/docs/sandboxes/daytona.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 9 +sidebar_position: 3 title: Daytona --- diff --git a/docs/docs/sandboxes/docker.mdx b/docs/docs/sandboxes/docker.mdx index e5a53c2..e99e037 100644 --- a/docs/docs/sandboxes/docker.mdx +++ b/docs/docs/sandboxes/docker.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 5 +sidebar_position: 4 title: Docker --- diff --git a/docs/docs/sandboxes/eval.mdx b/docs/docs/sandboxes/eval.mdx index 5d2189e..eb12ac7 100644 --- a/docs/docs/sandboxes/eval.mdx +++ b/docs/docs/sandboxes/eval.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 3 +sidebar_position: 5 title: Eval --- diff --git a/docs/docs/sandboxes/google-colab.mdx b/docs/docs/sandboxes/google-colab.mdx index d859465..b2702ec 100644 --- a/docs/docs/sandboxes/google-colab.mdx +++ b/docs/docs/sandboxes/google-colab.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 6 title: Google Colab --- diff --git a/docs/docs/sandboxes/index.mdx b/docs/docs/sandboxes/index.mdx index 6ebc69c..003ba53 100644 --- a/docs/docs/sandboxes/index.mdx +++ b/docs/docs/sandboxes/index.mdx @@ -11,9 +11,9 @@ A sandbox is an isolated environment where code can be executed safely. Code San Use `Sandbox.create()` to create a new sandbox: -Canonical variant names are `jupyter-server`, `docker`, `eval`, `monty`, `kaggle`, -`google_colab`, `modal`, `daytona`, and `datalayer`. Older `local-*` names are no -longer supported. +Canonical variant names are `datalayer`, `daytona`, `docker`, `eval`, +`google_colab`, `jupyter-server`, `kaggle`, `modal`, and `monty`. Older `local-*` +names are no longer supported. The CLI also accepts `google-colab` as an alias for `google_colab`. @@ -63,15 +63,15 @@ Each page below explains how to configure each variant. | Variant | Summary | |---------|---------| -| [`jupyter-server`](./jupyter-server) | Jupyter kernel-backed execution with persistent state | -| [`eval`](./eval) | In-process `exec()` for fast development-only runs | -| [`monty`](./monty) | Secure in-process Python subset via Monty | +| [`datalayer`](./datalayer) | Datalayer managed runtime with optional GPU | +| [`daytona`](./daytona) | Daytona cloud sandbox with a stateful interpreter | | [`docker`](./docker) | Jupyter execution in a Docker container | -| [`kaggle`](./kaggle) | Kaggle runtime (interactive or batch) | +| [`eval`](./eval) | In-process `exec()` for fast development-only runs | | [`google_colab`](./google-colab) | Google Colab runtime via runtime proxy | +| [`jupyter-server`](./jupyter-server) | Jupyter kernel-backed execution with persistent state | +| [`kaggle`](./kaggle) | Kaggle runtime (interactive or batch) | | [`modal`](./modal) | Modal container execution | -| [`daytona`](./daytona) | Daytona cloud sandbox with a stateful interpreter | -| [`datalayer`](./datalayer) | Datalayer managed runtime with optional GPU | +| [`monty`](./monty) | Secure in-process Python subset via Monty | ## Environments diff --git a/docs/docs/sandboxes/jupyter-server.mdx b/docs/docs/sandboxes/jupyter-server.mdx index dbe3cbe..ee9d948 100644 --- a/docs/docs/sandboxes/jupyter-server.mdx +++ b/docs/docs/sandboxes/jupyter-server.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 2 +sidebar_position: 7 title: Jupyter --- diff --git a/docs/docs/sandboxes/kaggle.mdx b/docs/docs/sandboxes/kaggle.mdx index e306ad4..360f3b8 100644 --- a/docs/docs/sandboxes/kaggle.mdx +++ b/docs/docs/sandboxes/kaggle.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 8 title: Kaggle --- diff --git a/docs/docs/sandboxes/modal.mdx b/docs/docs/sandboxes/modal.mdx index 0f860fd..4133ab4 100644 --- a/docs/docs/sandboxes/modal.mdx +++ b/docs/docs/sandboxes/modal.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 8 +sidebar_position: 9 title: Modal --- diff --git a/docs/docs/sandboxes/monty.mdx b/docs/docs/sandboxes/monty.mdx index 1496327..64b7549 100644 --- a/docs/docs/sandboxes/monty.mdx +++ b/docs/docs/sandboxes/monty.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 10 title: Monty --- diff --git a/examples/README.md b/examples/README.md index b148468..869756a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,68 +17,68 @@ This folder now contains two example sets: Supported sandbox variants: -- `jupyter` +- `datalayer` +- `daytona` - `docker` - `eval` -- `monty` - `google-colab` +- `jupyter-server` - `kaggle` - `modal` -- `daytona` -- `datalayer` +- `monty` Run one-shot examples from `examples/exec/`: ```bash cd exec -python eval_sandbox_example.py -python jupyter_sandbox_example.py +python datalayer_sandbox_example.py +python daytona_sandbox_example.py python docker_sandbox_example.py -python monty_sandbox_example.py +python eval_sandbox_example.py python google_colab_sandbox_example.py +python jupyter_server_sandbox_example.py python kaggle_sandbox_example.py python modal_sandbox_example.py -python daytona_sandbox_example.py -python datalayer_sandbox_example.py +python monty_sandbox_example.py ``` Or run one-shot examples via Make targets: ```bash cd exec -make eval -make jupyter +make datalayer +make daytona make docker -make monty +make eval make google-colab +make jupyter-server make kaggle make modal -make daytona -make datalayer +make monty ``` Run REPL examples from `examples/repl/`: ```bash cd repl -make eval -make jupyter +make datalayer +make daytona make docker -make monty +make eval make google-colab +make jupyter-server make kaggle make modal -make daytona -make datalayer +make monty ``` Notes by variant: +- `datalayer`: requires Datalayer runtime credentials/config. +- `daytona`: requires `code-sandboxes[daytona]` and `DAYTONA_API_KEY` (or + `DAYTONA_JWT_TOKEN` with `DAYTONA_ORGANIZATION_ID`). - `docker`: requires Docker support and a Docker image (for example `code-sandboxes-jupyter:latest`). -- `monty`: requires `code-sandboxes[monty]` (`pydantic-monty`). - `google-colab`: requires `RUNTIME_URL`, `RUNTIME_ID`, and `RUNTIME_PROXY_TOKEN`. - `kaggle`: requires `RUNTIME_CHANNELS_URL`, or `RUNTIME_URL` and `RUNTIME_ID`. - `modal`: requires `MODAL_TOKEN_ID`/`MODAL_TOKEN_SECRET` or `~/.modal.toml`. -- `daytona`: requires `code-sandboxes[daytona]` and `DAYTONA_API_KEY` (or - `DAYTONA_JWT_TOKEN` with `DAYTONA_ORGANIZATION_ID`). -- `datalayer`: requires Datalayer runtime credentials/config. +- `monty`: requires `code-sandboxes[monty]` (`pydantic-monty`). diff --git a/examples/exec/datalayer_sandbox_example.py b/examples/exec/datalayer_sandbox_example.py index 60eb3f5..1064a66 100644 --- a/examples/exec/datalayer_sandbox_example.py +++ b/examples/exec/datalayer_sandbox_example.py @@ -30,7 +30,7 @@ def main() -> None: timeout=60, environment=first_env.name, ) as sandbox: - result = show_and_run(sandbox, "print('hello from datalayer runtime')") + show_and_run(sandbox, "print('hello from datalayer runtime')") except Exception as exc: print("datalayer example failed:", exc) print("Exception type:", type(exc)) diff --git a/examples/exec/daytona_sandbox_example.py b/examples/exec/daytona_sandbox_example.py index b627b09..6f0c49b 100644 --- a/examples/exec/daytona_sandbox_example.py +++ b/examples/exec/daytona_sandbox_example.py @@ -30,9 +30,7 @@ def _has_daytona_auth() -> bool: if os.environ.get("DAYTONA_API_KEY"): return True - return bool( - os.environ.get("DAYTONA_JWT_TOKEN") and os.environ.get("DAYTONA_ORGANIZATION_ID") - ) + return bool(os.environ.get("DAYTONA_JWT_TOKEN") and os.environ.get("DAYTONA_ORGANIZATION_ID")) def _parse_args() -> argparse.Namespace: diff --git a/examples/exec/docker_sandbox_example.py b/examples/exec/docker_sandbox_example.py index 0daa5eb..67be301 100644 --- a/examples/exec/docker_sandbox_example.py +++ b/examples/exec/docker_sandbox_example.py @@ -22,7 +22,7 @@ def main() -> None: timeout=30, image="code-sandboxes-jupyter:latest", ) as sandbox: - result = show_and_run(sandbox, "print('hello from docker')") + show_and_run(sandbox, "print('hello from docker')") error_result = show_and_run(sandbox, "raise RuntimeError('boom')") if error_result.code_error: print( diff --git a/examples/exec/google_colab_sandbox_example.py b/examples/exec/google_colab_sandbox_example.py index 5886be4..3e10c66 100644 --- a/examples/exec/google_colab_sandbox_example.py +++ b/examples/exec/google_colab_sandbox_example.py @@ -36,9 +36,9 @@ def main() -> None: proxy_token=runtime_proxy_token, ) as sandbox: show_and_run(sandbox, "x = 40") - result = show_and_run(sandbox, "x + 2") + show_and_run(sandbox, "x + 2") - result = show_and_run(sandbox, "print('hello from colab')") + show_and_run(sandbox, "print('hello from colab')") except Exception as exc: print("colab example failed:", exc) print( diff --git a/examples/exec/jupyter_server_sandbox_example.py b/examples/exec/jupyter_server_sandbox_example.py index 7e28daf..a588e34 100644 --- a/examples/exec/jupyter_server_sandbox_example.py +++ b/examples/exec/jupyter_server_sandbox_example.py @@ -19,10 +19,10 @@ def main() -> None: with Sandbox.create(variant="jupyter-server", timeout=30) as sandbox: # Test persistent state across executions show_and_run(sandbox, "x = 40") - result = show_and_run(sandbox, "x + 2") + show_and_run(sandbox, "x + 2") # Test stdout - result = show_and_run(sandbox, "print('hello from jupyter')") + show_and_run(sandbox, "print('hello from jupyter')") # Test file operations sandbox.files.write("/tmp/jupyter_test.txt", "Hello from jupyter") diff --git a/examples/exec/kaggle_sandbox_example.py b/examples/exec/kaggle_sandbox_example.py index 6ac214e..99fb1bb 100644 --- a/examples/exec/kaggle_sandbox_example.py +++ b/examples/exec/kaggle_sandbox_example.py @@ -32,7 +32,6 @@ from code_sandboxes import CodeError, Sandbox - GPU_PROBE = """ import shutil import subprocess @@ -40,7 +39,9 @@ smi = shutil.which("nvidia-smi") print("GPU-PROBE: nvidia-smi", "present" if smi else "MISSING") if smi: - listing = subprocess.run(["nvidia-smi", "-L"], check=False, capture_output=True, text=True).stdout.strip() + listing = subprocess.run( + ["nvidia-smi", "-L"], check=False, capture_output=True, text=True + ).stdout.strip() print("GPU-PROBE: devices", listing or "NONE") """ @@ -77,14 +78,11 @@ def _check_gpu_probe(probe: str, flavor: str) -> None: print("gpu_probe:\n", probe) if "nvidia-smi present" not in probe or "devices NONE" in probe: raise RuntimeError( - f"GPU verification failed: no GPU in the Kaggle runtime " - f"(requested {flavor})." + f"GPU verification failed: no GPU in the Kaggle runtime (requested {flavor})." ) # 'T4 x2' must match a 'Tesla T4' listing: the family name is the check. family = flavor.split()[0].lower() - devices = next( - (line for line in probe.splitlines() if "devices" in line), "" - ).lower() + devices = next((line for line in probe.splitlines() if "devices" in line), "").lower() if family not in devices: raise RuntimeError( f"GPU verification failed: requested {flavor} but nvidia-smi " @@ -152,9 +150,9 @@ def _run_interactive(gpu: str | None) -> None: print("mode: interactive (live kaggle.com session)") with Sandbox.create(variant="kaggle", timeout=60, **kwargs) as sandbox: show_and_run(sandbox, "x = 40") - result = show_and_run(sandbox, "x + 2") + show_and_run(sandbox, "x + 2") - result = show_and_run(sandbox, "print('hello from kaggle')") + show_and_run(sandbox, "print('hello from kaggle')") if gpu: probe = show_and_run(sandbox, GPU_PROBE).stdout.strip() @@ -163,9 +161,7 @@ def _run_interactive(gpu: str | None) -> None: def main() -> None: args = _parse_args() - interactive = bool( - os.environ.get("RUNTIME_CHANNELS_URL") or os.environ.get("RUNTIME_URL") - ) + interactive = bool(os.environ.get("RUNTIME_CHANNELS_URL") or os.environ.get("RUNTIME_URL")) try: if interactive: _run_interactive(args.gpu) @@ -188,7 +184,7 @@ def main() -> None: "kernel creation on it. Unset RUNTIME_URL to use batch mode, " "which needs no session." ) - raise SystemExit(1) + raise SystemExit(1) from exc if __name__ == "__main__": diff --git a/examples/exec/modal_sandbox_example.py b/examples/exec/modal_sandbox_example.py index 84c006e..5bc824c 100644 --- a/examples/exec/modal_sandbox_example.py +++ b/examples/exec/modal_sandbox_example.py @@ -53,12 +53,18 @@ def _gpu_probe_code() -> str: smi = shutil.which("nvidia-smi") print("GPU-PROBE: nvidia-smi", "present" if smi else "MISSING") if smi: - listing = subprocess.run(["nvidia-smi", "-L"], check=False, capture_output=True, text=True).stdout.strip() + listing = subprocess.run( + ["nvidia-smi", "-L"], check=False, capture_output=True, text=True + ).stdout.strip() print("GPU-PROBE: devices", listing or "NONE") try: import torch # type: ignore - print("GPU-PROBE: torch", torch.__version__, "cuda", torch.cuda.is_available(), "count", torch.cuda.device_count()) + print( + "GPU-PROBE: torch", torch.__version__, + "cuda", torch.cuda.is_available(), + "count", torch.cuda.device_count(), + ) except Exception as exc: print("GPU-PROBE: torch unavailable:", exc) """ @@ -108,13 +114,11 @@ def main() -> None: print("-- error handling: the next snippet raises deliberately --") error_result = show_and_run(sandbox, "raise RuntimeError('modal failure example')") if error_result.code_error is None: - raise RuntimeError( - "The deliberate failure did not surface as a code_error." - ) + raise RuntimeError("The deliberate failure did not surface as a code_error.") print("error captured as expected — modal example completed.") except Exception as exc: print("modal example failed:", exc) - raise SystemExit(1) + raise SystemExit(1) from exc if __name__ == "__main__": diff --git a/examples/exec/monty_sandbox_example.py b/examples/exec/monty_sandbox_example.py index 8e0fc04..7eea98e 100644 --- a/examples/exec/monty_sandbox_example.py +++ b/examples/exec/monty_sandbox_example.py @@ -18,9 +18,9 @@ def main() -> None: try: with Sandbox.create(variant="monty", timeout=30) as sandbox: show_and_run(sandbox, "x = 21") - result = show_and_run(sandbox, "x * 2") + show_and_run(sandbox, "x * 2") - result = show_and_run(sandbox, "print('hello from monty')") + show_and_run(sandbox, "print('hello from monty')") error_result = show_and_run(sandbox, "raise ValueError('monty failure example')") if error_result.code_error: diff --git a/examples/repl/daytona_sandbox_example.py b/examples/repl/daytona_sandbox_example.py index abec6a8..16e366d 100644 --- a/examples/repl/daytona_sandbox_example.py +++ b/examples/repl/daytona_sandbox_example.py @@ -25,9 +25,7 @@ def _has_daytona_auth() -> bool: if os.environ.get("DAYTONA_API_KEY"): return True - return bool( - os.environ.get("DAYTONA_JWT_TOKEN") and os.environ.get("DAYTONA_ORGANIZATION_ID") - ) + return bool(os.environ.get("DAYTONA_JWT_TOKEN") and os.environ.get("DAYTONA_ORGANIZATION_ID")) def _parse_args() -> argparse.Namespace: diff --git a/examples/repl/kaggle_sandbox_example.py b/examples/repl/kaggle_sandbox_example.py index 50b3843..24f5cf3 100644 --- a/examples/repl/kaggle_sandbox_example.py +++ b/examples/repl/kaggle_sandbox_example.py @@ -49,9 +49,7 @@ def main() -> None: if gpu: # `1`/`true` ask for "a GPU" — the T4 is the everyday one — # anything else names the accelerator (t4, p100, l4, …). - kwargs["gpu"] = ( - "t4" if gpu.strip().lower() in ("1", "true", "yes") else gpu - ) + kwargs["gpu"] = "t4" if gpu.strip().lower() in ("1", "true", "yes") else gpu print() print(f"accelerator: {kwargs['gpu']} — a GPU job queues") print("longer than a CPU one before it boots.") @@ -64,9 +62,7 @@ def main() -> None: kwargs = {"timeout": 600} gpu = os.environ.get("KAGGLE_GPU") if gpu: - kwargs["gpu"] = ( - "t4" if gpu.strip().lower() in ("1", "true", "yes") else gpu - ) + kwargs["gpu"] = "t4" if gpu.strip().lower() in ("1", "true", "yes") else gpu print() print(f"accelerator: {kwargs['gpu']} — every batch job runs") print("with it, and queues longer than a CPU one.") @@ -81,7 +77,7 @@ def main() -> None: "stateful REPL, start a notebook session on kaggle.com and set " "RUNTIME_URL (its proxy URL) or RUNTIME_CHANNELS_URL." ) - raise SystemExit(1) + raise SystemExit(1) from exc if __name__ == "__main__": diff --git a/examples/repl/modal_sandbox_example.py b/examples/repl/modal_sandbox_example.py index c69d4b5..904d0af 100644 --- a/examples/repl/modal_sandbox_example.py +++ b/examples/repl/modal_sandbox_example.py @@ -49,7 +49,7 @@ def main() -> None: run_repl(sandbox) except Exception as exc: print("modal REPL failed:", exc) - raise SystemExit(1) + raise SystemExit(1) from exc if __name__ == "__main__": diff --git a/tests/test_cli_repl.py b/tests/test_cli_repl.py index eb2a2f5..20de4de 100644 --- a/tests/test_cli_repl.py +++ b/tests/test_cli_repl.py @@ -43,7 +43,9 @@ def _fake_create(*args, **kwargs): monkeypatch.setattr(sandbox_cli.Sandbox, "create", staticmethod(_fake_create)) - result = runner.invoke(sandbox_cli.app, ["repl", "--variant", "jupyter-server"], input=":exit\n") + result = runner.invoke( + sandbox_cli.app, ["repl", "--variant", "jupyter-server"], input=":exit\n" + ) assert result.exit_code == 0 assert captured["kwargs"]["variant"] == "jupyter-server" diff --git a/tests/test_daytona.py b/tests/test_daytona.py index 3a87ff9..489d9fb 100644 --- a/tests/test_daytona.py +++ b/tests/test_daytona.py @@ -64,9 +64,7 @@ def run_code( envs=None, timeout=None, ): - self.calls.append( - SimpleNamespace(code=code, context=context, envs=envs, timeout=timeout) - ) + self.calls.append(SimpleNamespace(code=code, context=context, envs=envs, timeout=timeout)) namespace = self.namespaces[context.id if context else None] out, err, error = io.StringIO(), io.StringIO(), None try: @@ -408,9 +406,7 @@ def test_the_network_policy_becomes_daytonas_own_settings(): allowed_hosts=["pypi.org", "files.pythonhosted.org"], ) ) - assert allowed._network_params() == { - "domain_allow_list": "pypi.org,files.pythonhosted.org" - } + assert allowed._network_params() == {"domain_allow_list": "pypi.org,files.pythonhosted.org"} assert _started(SandboxConfig(network_policy="inherit"))._network_params() == {} @@ -497,6 +493,4 @@ def test_the_provider_says_what_it_needs(): assert provider.extra == "daytona" assert not provider.is_available({}) assert provider.is_available({"DAYTONA_API_KEY": "dtn_key"}) - assert provider.is_available( - {"DAYTONA_JWT_TOKEN": "jwt", "DAYTONA_ORGANIZATION_ID": "org"} - ) + assert provider.is_available({"DAYTONA_JWT_TOKEN": "jwt", "DAYTONA_ORGANIZATION_ID": "org"}) diff --git a/tests/test_eval.py b/tests/test_eval.py index 6a86db6..25d13dc 100644 --- a/tests/test_eval.py +++ b/tests/test_eval.py @@ -447,7 +447,7 @@ def test_is_executing_true_during_run(self): def run_in_thread(): # Execute code that waits for the proceed event - sandbox.run_code("import time\n" "started.set()\n" "proceed.wait(5)\n") + sandbox.run_code("import time\nstarted.set()\nproceed.wait(5)\n") with EvalSandbox() as sandbox: sandbox.set_variable("started", started) @@ -482,7 +482,7 @@ def test_interrupt_stops_running_code(self): def run_in_thread(): return sandbox.run_code( - "import time\n" "started.set()\n" "while True:\n" " time.sleep(0.01)\n" + "import time\nstarted.set()\nwhile True:\n time.sleep(0.01)\n" ) with EvalSandbox() as sandbox: @@ -492,7 +492,7 @@ def run_in_thread(): def run_and_capture(): results[0] = sandbox.run_code( - "import time\n" "started.set()\n" "while True:\n" " time.sleep(0.01)\n" + "import time\nstarted.set()\nwhile True:\n time.sleep(0.01)\n" ) thread = threading.Thread(target=run_and_capture) diff --git a/tests/test_factory.py b/tests/test_factory.py index 6d5bfcd..bd556db 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -81,6 +81,32 @@ def test_create_all_supported_variants(self, variant, expected_type): sandbox = Sandbox.create(variant=variant) assert isinstance(sandbox, expected_type) + @pytest.mark.parametrize( + "variant", + ["jupyter-server", "jupyter_server", "JUPYTER-SERVER", " Jupyter-Server "], + ) + def test_a_variant_is_read_in_any_spelling(self, variant): + """The factory was the strictest door into the package, and alone. + + `get_manager` and `get_provider` have always taken the case and the + whitespace a configuration file leaves around a name; `create` took + one spelling and raised on the rest. + """ + assert isinstance(Sandbox.create(variant=variant), JupyterServerSandbox) + assert Sandbox.list_environments(variant=variant) + + def test_a_name_that_is_not_a_variant_still_raises(self): + """Reading loosely is not guessing: `jupyter` names nothing.""" + with pytest.raises(ValueError, match="Unknown sandbox variant"): + Sandbox.create(variant="jupyter") + + def test_the_refusal_names_what_there_is(self): + with pytest.raises(ValueError) as raised: + Sandbox.list_environments(variant="nonesuch") + message = str(raised.value) + assert "jupyter-server" in message + assert "daytona" in message + def test_create_default_variant_is_datalayer(self): """Test that omitting variant uses the datalayer sandbox by default.""" sandbox = Sandbox.create() diff --git a/tests/test_kaggle_execute.py b/tests/test_kaggle_execute.py index f3023e5..8e562c2 100644 --- a/tests/test_kaggle_execute.py +++ b/tests/test_kaggle_execute.py @@ -235,10 +235,7 @@ def test_to_kernel_reply_falls_back_to_log_streams(): slug="me/demo", status="COMPLETE", log=( - "[" - '{"stream_name":"stdout","data":"hello\\n"},' - '{"stream_name":"stderr","data":"warn\\n"}' - "]" + '[{"stream_name":"stdout","data":"hello\\n"},{"stream_name":"stderr","data":"warn\\n"}]' ), ) diff --git a/tests/test_kaggle_live.py b/tests/test_kaggle_live.py index 956ba0a..b4dd065 100644 --- a/tests/test_kaggle_live.py +++ b/tests/test_kaggle_live.py @@ -95,9 +95,10 @@ def read(ref): if inbox.get("seq") == session._seq: api.store[session._k2c] = { "seq": session._seq, - "reply": {"status": "ok", "outputs": [ - {"output_type": "stream", "name": "stdout", "text": "42\n"} - ]}, + "reply": { + "status": "ok", + "outputs": [{"output_type": "stream", "name": "stdout", "text": "42\n"}], + }, } return real_read(ref) diff --git a/tests/test_kaggle_session.py b/tests/test_kaggle_session.py index be751f2..313f562 100644 --- a/tests/test_kaggle_session.py +++ b/tests/test_kaggle_session.py @@ -3,6 +3,8 @@ """The replay-based session of the Kaggle batch mode.""" +from typing import ClassVar + class TestKaggleBatchSession: """The batch session: state carried by replaying the code that ran.""" @@ -47,3 +49,73 @@ def test_missing_sentinel_keeps_everything(self): sandbox = self._sandbox() reply = {"outputs": [{"output_type": "stream", "name": "stdout", "text": "boom"}]} assert sandbox._cut_replayed_outputs(reply, "<>") == reply + + +class TestKaggleStreamingSession: + """What the STREAMING path records, which later turns re-run. + + The two batch paths have to agree: what one records, the other records, + and on the same condition. Streaming judged by the status of the JOB — + and a job whose cell raised still completes, so a failing snippet joined + the replay and failed every turn after it. + """ + + def _sandbox(self, outputs, status="COMPLETE"): + from types import SimpleNamespace + + from code_sandboxes.kaggle_sandbox import KaggleSandbox + + class _Executor: + """No `api` and no `output`: no polling, no artifact download.""" + + def __init__(self): + self.submitted = [] + + def execute(self, code, **_kwargs): + self.submitted.append(code) + return SimpleNamespace( + slug="user/job", + status=status, + log="", + to_kernel_reply=lambda: {"outputs": outputs}, + ) + + sandbox = KaggleSandbox() + sandbox._batch_mode = True + sandbox._executor = _Executor() + return sandbox + + _PRINTED: ClassVar[list] = [{"output_type": "stream", "name": "stdout", "text": "ok\n"}] + _RAISED: ClassVar[list] = [ + {"output_type": "error", "ename": "NameError", "evalue": "x", "traceback": []} + ] + + def test_a_snippet_that_worked_joins_the_session(self): + sandbox = self._sandbox(self._PRINTED) + + list(sandbox.run_code_streaming("x = 1")) + + assert sandbox._session_history == ["x = 1"] + + def test_a_snippet_that_raised_does_not_join_the_session(self): + """The job completed; the code did not. Only the second one counts.""" + sandbox = self._sandbox(self._RAISED) + + list(sandbox.run_code_streaming("print(x)")) + + assert sandbox._session_history == [] + + def test_a_job_that_failed_does_not_join_the_session(self): + sandbox = self._sandbox(self._PRINTED, status="ERROR") + + list(sandbox.run_code_streaming("x = 1")) + + assert sandbox._session_history == [] + + def test_the_next_turn_does_not_replay_what_failed(self): + """Why it matters: the replay runs ahead of every later snippet.""" + sandbox = self._sandbox(self._RAISED) + + list(sandbox.run_code_streaming("print(x)")) + + assert sandbox._session_prelude("y = 2") == ("y = 2", None) diff --git a/tests/test_kernel_client_compatibility.py b/tests/test_kernel_client_compatibility.py index 5ffb21e..6d021e0 100644 --- a/tests/test_kernel_client_compatibility.py +++ b/tests/test_kernel_client_compatibility.py @@ -37,9 +37,7 @@ def _protocol_members(protocol: type) -> list[str]: """The names a protocol demands, read from its own annotations/methods.""" return sorted( - name - for name in getattr(protocol, "__protocol_attrs__", []) - if not name.startswith("_") + name for name in getattr(protocol, "__protocol_attrs__", []) if not name.startswith("_") ) @@ -71,9 +69,7 @@ def test_the_contract_demands_nothing_the_client_does_not_promise(self): to have today. """ public = set(_protocol_members(IJupyterKernelClient)) - overreach = [ - name for name in _protocol_members(ISandboxClient) if name not in public - ] + overreach = [name for name in _protocol_members(ISandboxClient) if name not in public] assert overreach == [], ( "The sandbox contract asks the client for members outside its " f"public protocol: {overreach}" @@ -123,12 +119,8 @@ def test_interrupt_bare(self): _binds(JupyterKernelClient.interrupt, None) def test_identity_and_info_are_readable(self): - assert isinstance( - inspect.getattr_static(JupyterKernelClient, "id"), property - ) - assert isinstance( - inspect.getattr_static(JupyterKernelClient, "kernel_info"), property - ) + assert isinstance(inspect.getattr_static(JupyterKernelClient, "id"), property) + assert isinstance(inspect.getattr_static(JupyterKernelClient, "kernel_info"), property) class TestTheSandboxesExposeTheClient: @@ -137,9 +129,7 @@ class TestTheSandboxesExposeTheClient: def test_the_base_declares_the_accessor(self): from code_sandboxes import Sandbox - assert isinstance( - inspect.getattr_static(Sandbox, "kernel_client"), property - ) + assert isinstance(inspect.getattr_static(Sandbox, "kernel_client"), property) def test_every_kernel_backed_variant_overrides_it(self): # From the package root, as any consumer would: the compatibility diff --git a/tests/test_manage.py b/tests/test_manage.py index 3d55cf1..fd8afe0 100644 --- a/tests/test_manage.py +++ b/tests/test_manage.py @@ -233,9 +233,7 @@ def update(self, sandbox_id, **changes): assert received["sb-1"] == {"tags": {"team": "ai", "env": "dev"}} assert "sb-1" in result.output - result = runner.invoke( - sandbox_cli.app, ["update", "sb-1", "-v", "modal", "--tag", "notavalue"] - ) + result = runner.invoke(sandbox_cli.app, ["update", "sb-1", "-v", "modal", "--tag", "notavalue"]) assert result.exit_code == 1 assert "Not a key=value tag" in result.output diff --git a/tests/test_modal_session.py b/tests/test_modal_session.py index e42d12d..2ce057a 100644 --- a/tests/test_modal_session.py +++ b/tests/test_modal_session.py @@ -18,7 +18,7 @@ def _driver_source() -> str: def _speak(requests): stdin = "".join(json.dumps(r) + "\n" for r in requests) - completed = subprocess.run( + completed = subprocess.run( # noqa: S603 — this interpreter, and the driver of this repo [sys.executable, "-u", "-c", _driver_source()], input=stdin, capture_output=True, @@ -29,21 +29,25 @@ def _speak(requests): def test_state_survives_between_requests(): - replies = _speak([ - {"seq": 1, "code": "x = 1"}, - {"seq": 2, "code": "x"}, - ]) + replies = _speak( + [ + {"seq": 1, "code": "x = 1"}, + {"seq": 2, "code": "x"}, + ] + ) assert replies[0]["status"] == "ok" assert replies[1]["status"] == "ok" assert replies[1]["result"] == "1" def test_stdout_and_errors_come_back_per_request(): - replies = _speak([ - {"seq": 1, "code": "print('hello')"}, - {"seq": 2, "code": "1 / 0"}, - {"seq": 3, "code": "print('still alive')"}, - ]) + replies = _speak( + [ + {"seq": 1, "code": "print('hello')"}, + {"seq": 2, "code": "1 / 0"}, + {"seq": 3, "code": "print('still alive')"}, + ] + ) assert replies[0]["stdout"] == "hello\n" assert replies[1]["status"] == "error" assert replies[1]["error"]["name"] == "ZeroDivisionError" diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..da95537 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,131 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""The provider registry, and the secrets it answers about. + +A service asks this registry two questions about somebody else's account: +which providers that account can use, and what it may launch on them. Both +have to be answered from the SAME credentials — the ones passed in, never the +ones the service itself happens to hold in its environment. +""" + +from __future__ import annotations + +from code_sandboxes.base import Sandbox +from code_sandboxes.models import SandboxEnvironment, SandboxVariant +from code_sandboxes.providers import ( + ProviderRequirement, + SandboxProvider, + available_providers, + get_provider, + provider_catalog, +) + + +def _environment(name: str) -> SandboxEnvironment: + return SandboxEnvironment(name=name, title=name) + + +def test_the_listing_is_given_the_credentials_it_takes_as_arguments(): + asked: list[dict] = [] + + provider = SandboxProvider( + variant=SandboxVariant.DATALAYER, + title="Test", + description="", + requirements=(ProviderRequirement(env_vars=("TEST_TOKEN",)),), + environment_secrets=(("token", "TEST_TOKEN"),), + list_environments=lambda **kwargs: asked.append(kwargs) or [_environment("env")], + ) + + assert provider.environments({"TEST_TOKEN": "abc"}) == [_environment("env")] + assert asked == [{"token": "abc"}] + + +def test_a_credential_that_is_not_there_is_not_passed_as_nothing(): + """Omitting the argument lets the SDK fall back; passing None does not.""" + asked: list[dict] = [] + provider = SandboxProvider( + variant=SandboxVariant.DATALAYER, + title="Test", + description="", + environment_secrets=(("token", "TEST_TOKEN"), ("run_url", "TEST_URL")), + list_environments=lambda **kwargs: asked.append(kwargs) or [], + ) + + provider.environments({"TEST_TOKEN": "abc"}) + + assert asked == [{"token": "abc"}] + + +def test_a_provider_that_cannot_be_reached_ships_nothing_rather_than_raising(): + def explode(**_kwargs): + raise RuntimeError("the platform is down") + + provider = SandboxProvider( + variant=SandboxVariant.DATALAYER, + title="Test", + description="", + list_environments=explode, + ) + + assert provider.environments() == [] + + +def test_datalayer_declares_the_credentials_its_listing_takes(): + """It asks the platform what the ACCOUNT may launch, so it needs one.""" + provider = get_provider("datalayer") + + assert provider.environment_secrets == ( + ("token", "DATALAYER_TOKEN"), + ("run_url", "DATALAYER_RUN_URL"), + ) + + +def test_the_catalog_answers_both_questions_from_the_same_secrets(monkeypatch): + """Enabled and its environments, from the account — not the process. + + A catalog that read `enabled` from the secrets passed in and the + environments from the environment of the service reported every Datalayer + account as enabled with nothing to launch. + """ + seen: list[dict] = [] + + def fake_list_environments(cls, variant=SandboxVariant.DATALAYER, **kwargs): + seen.append({"variant": str(variant), **kwargs}) + return [_environment("ai-agents-env")] + + monkeypatch.setattr(Sandbox, "list_environments", classmethod(fake_list_environments)) + + catalog = provider_catalog({"DATALAYER_TOKEN": "account-token"}) + datalayer = next(entry for entry in catalog if entry["name"] == "datalayer") + + assert datalayer["enabled"] + assert [env["name"] for env in datalayer["environments"]] == ["ai-agents-env"] + assert {"variant": str(SandboxVariant.DATALAYER), "token": "account-token"} in seen + + +def test_a_provider_that_is_not_enabled_is_never_asked(monkeypatch): + """Asking an unusable provider what it ships is a call that fails.""" + + def fail_if_called(cls, variant=None, **kwargs): + raise AssertionError(f"{variant} was asked for environments while disabled") + + monkeypatch.setattr(Sandbox, "list_environments", classmethod(fail_if_called)) + + catalog = provider_catalog({}) + datalayer = next(entry for entry in catalog if entry["name"] == "datalayer") + + assert not datalayer["enabled"] + assert datalayer["environments"] == [] + + +def test_availability_is_read_from_the_secrets_given_not_from_the_process(): + names = {provider.name for provider in available_providers({"DATALAYER_TOKEN": "t"})} + + assert "datalayer" in names + # Nothing was read from os.environ: a provider with no requirements is + # available anywhere, and the credentialed ones are not. + assert "eval" in names + assert "kaggle" not in names From 3037ae1d29e8eea667f2910466b6592adf2fd53c Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 20 Aug 2026 20:23:00 +0200 Subject: [PATCH 3/7] fix: ic --- CHANGELOG.md | 12 + README.md | 2 +- code_sandboxes/__init__.py | 14 + code_sandboxes/cli.py | 291 +++++++++++------- code_sandboxes/console.py | 210 +++++++++++++ code_sandboxes/daytona_sandbox.py | 18 +- docs/docs/cli/index.mdx | 103 +++++-- docs/docs/examples/index.mdx | 7 +- examples/README.md | 8 +- examples/exec/datalayer_sandbox_example.py | 4 +- examples/exec/daytona_sandbox_example.py | 4 +- examples/exec/docker_sandbox_example.py | 4 +- examples/exec/eval_sandbox_example.py | 4 +- examples/exec/exec_common.py | 47 --- examples/exec/google_colab_sandbox_example.py | 4 +- .../exec/jupyter_server_sandbox_example.py | 4 +- examples/exec/kaggle_sandbox_example.py | 4 +- examples/exec/modal_sandbox_example.py | 4 +- examples/exec/monty_sandbox_example.py | 4 +- examples/repl/datalayer_sandbox_example.py | 4 +- examples/repl/daytona_sandbox_example.py | 4 +- examples/repl/docker_sandbox_example.py | 4 +- examples/repl/eval_sandbox_example.py | 4 +- examples/repl/google_colab_sandbox_example.py | 4 +- .../repl/jupyter_server_sandbox_example.py | 4 +- examples/repl/kaggle_sandbox_example.py | 4 +- examples/repl/modal_sandbox_example.py | 4 +- examples/repl/monty_sandbox_example.py | 4 +- examples/repl/repl_common.py | 76 ----- tests/test_cli_exec.py | 124 ++++++++ tests/test_cli_repl.py | 5 +- tests/test_console.py | 284 +++++++++++++++++ tests/test_daytona.py | 6 +- tests/test_kernel_client_compatibility.py | 4 +- 34 files changed, 959 insertions(+), 324 deletions(-) create mode 100644 code_sandboxes/console.py delete mode 100644 examples/exec/exec_common.py delete mode 100644 examples/repl/repl_common.py create mode 100644 tests/test_cli_exec.py create mode 100644 tests/test_console.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e5af29..4b5a3e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ ## Unreleased +- Added `code-sandboxes exec`, which runs one snippet in a fresh sandbox of any + variant and exits with the status the code earned — `0` when it ran cleanly, + `1` when it raised — so it composes in a shell. The code comes from an + argument, from `--file`, or from standard input; `--quiet` prints only what + the code produced. `exec` and `repl` take the same options. +- Moved the machinery for showing a run — `show_code`, `show_result`, + `show_and_run`, `run_repl`, `repl_prompt` — into `code_sandboxes.console`, + exported from the package. It existed three times over: in the CLI, in the + REPL examples and in the exec examples, disagreeing about whether the value + of a trailing expression is shown, whether stderr is told apart from stdout, + and which words end a session. The examples now import it like any other + consumer, and `examples/*/[exec|repl]_common.py` are gone. - Added the `daytona` sandbox variant (`DaytonaSandbox`), running code in a [Daytona](https://www.daytona.io/docs/) cloud sandbox. It drives the sandbox's code interpreter rather than `process.code_run`, so state persists diff --git a/README.md b/README.md index 32bf8b3..3aafaad 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ pip install code-sandboxes[kaggle] export KAGGLE_API_KEY="" # Launch the REPL -sandbox repl --variant kaggle +code-sandboxes repl --variant kaggle ``` For batch execution, configure Kaggle credentials and create the sandbox diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index 7542fd0..b198abc 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -61,6 +61,14 @@ from .base import Sandbox from .client import CodeExecutionOutcome, CodeSandboxClient, execution_result_to_reply from .commands import CommandResult, ProcessHandle, SandboxCommands +from .console import ( + EXIT_COMMANDS, + repl_prompt, + run_repl, + show_and_run, + show_code, + show_result, +) from .datalayer_sandbox import DatalayerSandbox from .daytona_sandbox import DaytonaSandbox from .docker_sandbox import DockerSandbox @@ -135,6 +143,7 @@ #: Everything this package exports, in one sorted list — the groups it #: used to be split into stopped matching what they sat above. __all__ = [ + "EXIT_COMMANDS", "KAGGLE_API_TOKEN_ENV", "PROVIDERS", "CodeError", @@ -203,4 +212,9 @@ "manageable_variants", "parse_google_colab_channels_url", "parse_kaggle_channels_url", + "repl_prompt", + "run_repl", + "show_and_run", + "show_code", + "show_result", ] diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py index 44804c0..50f14eb 100644 --- a/code_sandboxes/cli.py +++ b/code_sandboxes/cli.py @@ -2,11 +2,15 @@ # # BSD 3-Clause License -"""Typer CLI for code sandboxes: REPL sessions and CRUD management.""" +"""Typer CLI for code sandboxes: running code, and CRUD management.""" from __future__ import annotations +import contextlib import os +import sys +from collections.abc import Iterator +from pathlib import Path from typing import Any import typer @@ -14,8 +18,8 @@ from rich.table import Table from . import Sandbox +from .console import run_repl, show_and_run, show_result from .manage import SandboxManagementError, get_manager, manageable_variants -from .models import Result app = typer.Typer(help="Code sandboxes: run a REPL, list, create and delete sandboxes.") @@ -34,8 +38,6 @@ "datalayer", } -_EXIT_COMMANDS = {":exit", ":quit", "exit", "quit"} - @app.callback(invoke_without_command=True) def _root(ctx: typer.Context) -> None: @@ -44,33 +46,6 @@ def _root(ctx: typer.Context) -> None: _run_repl(variant="jupyter-server") -def _print_result(result: Any) -> None: - if not getattr(result, "execution_ok", True): - msg = getattr(result, "execution_error", None) or "Execution failed" - typer.secho(msg, fg=typer.colors.RED) - return - - for line in getattr(result, "stdout", "").splitlines(): - typer.echo(line) - - for line in getattr(result, "stderr", "").splitlines(): - typer.secho(line, fg=typer.colors.YELLOW) - - code_error = getattr(result, "code_error", None) - if code_error is not None: - typer.secho(f"{code_error.name}: {code_error.value}", fg=typer.colors.RED) - - # Prefer the main result and avoid duplicating stdout. - text = None - results = getattr(result, "results", []) - for item in results: - if isinstance(item, Result) and item.is_main_result: - text = item.text - break - if text: - typer.echo(text) - - def _resolve_variant(variant: str | None) -> str: if variant: selected = variant.strip().lower() @@ -141,20 +116,77 @@ def _resolve_variant_kwargs( return kwargs -def _run_repl( - variant: str | None = None, - timeout: float = 60.0, - environment: str | None = None, +#: The options both ways of running code take. Declared once: `exec` and +#: `repl` open the same sandbox and differ only in what they then do with it, +#: and two copies of nine options drift. +_RUN_VARIANT_OPTION = typer.Option( + None, + "--variant", + "-v", + help=( + "Sandbox variant (datalayer, daytona, docker, eval, " + "google_colab/google-colab, jupyter-server, kaggle, modal, monty)." + ), +) +_RUN_TIMEOUT_OPTION = typer.Option(60.0, help="Code execution timeout (seconds).") +_RUN_ENVIRONMENT_OPTION = typer.Option( + None, help="Sandbox environment (used by variants such as datalayer)." +) +_RUN_SERVER_URL_OPTION = typer.Option(None, help="Colab/Kaggle runtime URL.") +_RUN_KERNEL_ID_OPTION = typer.Option(None, help="Colab/Kaggle kernel ID.") +_RUN_PROXY_TOKEN_OPTION = typer.Option(None, help="Colab runtime proxy token.") +_RUN_TOKEN_OPTION = typer.Option(None, help="Datalayer/Kaggle API token override.") +_RUN_RUN_URL_OPTION = typer.Option(None, help="Datalayer run URL override.") +_EXEC_CODE_ARGUMENT = typer.Argument( + None, + help="The code to run. Read from stdin when neither this nor --file is given.", +) +_EXEC_FILE_OPTION = typer.Option(None, "--file", "-f", help="Read the code from a file instead.") +_EXEC_QUIET_OPTION = typer.Option( + False, + "--quiet", + "-q", + help="Print only what the code produced — no banner, no echo of the code.", +) +_RUN_GPU_OPTION = typer.Option( + None, + "--gpu", + help=( + "GPU flavor / accelerator for supported variants " + "(modal/datalayer examples: T4, A10G, A100, H100; " + "daytona examples: H100, H200, RTX-4090; " + "kaggle examples: NvidiaTeslaT4, NvidiaTeslaP100, or aliases T4/P100)." + ), +) + + +@contextlib.contextmanager +def _started_sandbox( + variant: str | None, + timeout: float, + environment: str | None, + *, + announce: bool = True, server_url: str | None = None, kernel_id: str | None = None, proxy_token: str | None = None, token: str | None = None, run_url: str | None = None, gpu: str | None = None, -) -> None: - selected_variant = _resolve_variant(variant) +) -> Iterator[Sandbox]: + """A started sandbox of the variant asked for, gone again on the way out. + + Both ways of running code want the same thing — resolve the variant, + gather what that variant needs to connect, start it, and be certain it is + terminated afterwards. They differ only in what happens in between. + + A failure to START is reported here, because there is nothing to report + it to yet. A failure inside the block is the caller's: it travels, so + `exec` can end with the status its code earned. + """ + selected = _resolve_variant(variant) sandbox_kwargs = _resolve_variant_kwargs( - selected_variant, + selected, server_url=server_url, kernel_id=kernel_id, proxy_token=proxy_token, @@ -162,90 +194,48 @@ def _run_repl( run_url=run_url, gpu=gpu, ) - - typer.secho(f"Starting sandbox variant: {selected_variant}", fg=typer.colors.CYAN) - + if announce: + console.print(f"Starting sandbox variant: {selected}", style="cyan") + started = False try: with Sandbox.create( - variant=selected_variant, + variant=selected, timeout=timeout, environment=environment, **sandbox_kwargs, ) as sandbox: - sandbox_id = sandbox.sandbox_id or "" - typer.secho( - f"Sandbox started (id={sandbox_id}). Type code and press Enter.", - fg=typer.colors.GREEN, - ) - typer.echo("Use :exit or Ctrl+D to terminate.") - - while True: - try: - code = input(">>> ") - except EOFError: - typer.echo("") - break - except KeyboardInterrupt: - typer.echo("\n(Interrupted. Type :exit to quit.)") - continue - - if not code.strip(): - continue - if code.strip() in _EXIT_COMMANDS: - break - - try: - result = sandbox.run_code(code) - except KeyboardInterrupt: - typer.echo("\n(Execution interrupted.)") - continue - except Exception as exc: - typer.secho(f"Execution failed: {exc}", fg=typer.colors.RED) - continue - - _print_result(result) + started = True + if announce: + identifier = sandbox.sandbox_id or "" + console.print(f"Sandbox started (id={identifier}).", style="green") + yield sandbox except Exception as exc: - typer.secho(f"Failed to start REPL: {exc}", fg=typer.colors.RED) + if started: + # Not ours to report: whatever the block raised travels, so that + # `exec` can end with the status its own code earned. + raise + console.print(f"Failed to start the {selected} sandbox: {exc}", style="red") raise typer.Exit(code=1) from None - - typer.secho("Sandbox terminated.", fg=typer.colors.GREEN) + if announce: + console.print("Sandbox terminated.", style="green") @app.command() def repl( - variant: str | None = typer.Option( - None, - "--variant", - "-v", - help=( - "Sandbox variant (jupyter, docker, eval, monty, " - "google_colab/google-colab, kaggle, modal, daytona, datalayer)." - ), - ), - timeout: float = typer.Option(60.0, help="Default code execution timeout (seconds)."), - environment: str | None = typer.Option( - None, - help="Sandbox environment (used by variants such as datalayer).", - ), - server_url: str | None = typer.Option(None, help="Colab runtime URL."), - kernel_id: str | None = typer.Option(None, help="Colab kernel ID."), - proxy_token: str | None = typer.Option(None, help="Colab runtime proxy token."), - token: str | None = typer.Option(None, help="Datalayer API token override."), - run_url: str | None = typer.Option(None, help="Datalayer run URL override."), - gpu: str | None = typer.Option( - None, - "--gpu", - help=( - "GPU flavor / accelerator for supported variants " - "(modal/datalayer examples: T4, A10G, A100, H100; " - "daytona examples: H100, H200, RTX-4090; " - "kaggle examples: NvidiaTeslaT4, NvidiaTeslaP100, or aliases T4/P100)." - ), - ), + variant: str | None = _RUN_VARIANT_OPTION, + timeout: float = _RUN_TIMEOUT_OPTION, + environment: str | None = _RUN_ENVIRONMENT_OPTION, + server_url: str | None = _RUN_SERVER_URL_OPTION, + kernel_id: str | None = _RUN_KERNEL_ID_OPTION, + proxy_token: str | None = _RUN_PROXY_TOKEN_OPTION, + token: str | None = _RUN_TOKEN_OPTION, + run_url: str | None = _RUN_RUN_URL_OPTION, + gpu: str | None = _RUN_GPU_OPTION, ) -> None: - """Launch an interactive REPL against the selected sandbox variant. + """Open an interactive prompt on a sandbox of the selected variant. - The sandbox is always terminated when this command exits. + State is kept between lines. The sandbox is always terminated when this + command exits. """ _run_repl( variant=variant, @@ -260,6 +250,91 @@ def repl( ) +def _run_repl( + variant: str | None = None, + timeout: float = 60.0, + environment: str | None = None, + server_url: str | None = None, + kernel_id: str | None = None, + proxy_token: str | None = None, + token: str | None = None, + run_url: str | None = None, + gpu: str | None = None, +) -> None: + with _started_sandbox( + variant, + timeout, + environment, + server_url=server_url, + kernel_id=kernel_id, + proxy_token=proxy_token, + token=token, + run_url=run_url, + gpu=gpu, + ) as sandbox: + run_repl(sandbox, console=console) + + +def _code_to_run(code: str | None, file: Path | None) -> str: + """The snippet to run, from the argument, a file, or what was piped in.""" + if file is not None: + if code is not None: + raise typer.BadParameter("Give the code or --file, not both.") + return file.read_text(encoding="utf-8") + if code is not None: + return code + if sys.stdin.isatty(): + raise typer.BadParameter( + "No code to run: pass it as an argument, with --file, or on stdin." + ) + return sys.stdin.read() + + +@app.command("exec") +def exec_code( + code: str | None = _EXEC_CODE_ARGUMENT, + file: Path | None = _EXEC_FILE_OPTION, + quiet: bool = _EXEC_QUIET_OPTION, + variant: str | None = _RUN_VARIANT_OPTION, + timeout: float = _RUN_TIMEOUT_OPTION, + environment: str | None = _RUN_ENVIRONMENT_OPTION, + server_url: str | None = _RUN_SERVER_URL_OPTION, + kernel_id: str | None = _RUN_KERNEL_ID_OPTION, + proxy_token: str | None = _RUN_PROXY_TOKEN_OPTION, + token: str | None = _RUN_TOKEN_OPTION, + run_url: str | None = _RUN_RUN_URL_OPTION, + gpu: str | None = _RUN_GPU_OPTION, +) -> None: + """Run one snippet in a fresh sandbox and print what it produced. + + The sandbox is created, the code is run, and the sandbox is terminated. + The exit status is the code's own — 0 when it ran cleanly, 1 when it + raised or the sandbox could not run it — so this composes in a shell: + + code-sandboxes exec -v eval -q 'print(40 + 2)' | wc -l + """ + snippet = _code_to_run(code, file) + with _started_sandbox( + variant, + timeout, + environment, + announce=not quiet, + server_url=server_url, + kernel_id=kernel_id, + proxy_token=proxy_token, + token=token, + run_url=run_url, + gpu=gpu, + ) as sandbox: + if quiet: + result = sandbox.run_code(snippet) + show_result(result, console=console, labelled=False) + else: + result = show_and_run(sandbox, snippet, console=console) + if not result.success: + raise typer.Exit(code=1) + + def _manager_kwargs( server_url: str | None = None, token: str | None = None, diff --git a/code_sandboxes/console.py b/code_sandboxes/console.py new file mode 100644 index 0000000..8a38f63 --- /dev/null +++ b/code_sandboxes/console.py @@ -0,0 +1,210 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Showing a sandbox at work — one snippet, or a prompt full of them. + +Two things are wanted of every sandbox here: run a snippet and show what came +back, or hold a prompt open and do that repeatedly. Three programs had each +grown their own copy — the CLI had a REPL with one way of rendering a result, +the REPL examples had a second, the exec examples a third — and they disagreed +about the things a reader actually notices: whether the value of the last +expression is shown at all, whether stderr is told apart from stdout, which +words end a session. + +This is that machinery, once. `code-sandboxes exec` and `code-sandboxes repl` +run on it, and so does every example — which is what makes an example worth +reading: it shows how to use a sandbox rather than how to print things. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from rich.console import Console + +if TYPE_CHECKING: + from .base import Sandbox + from .models import ExecutionResult + +__all__ = [ + "EXIT_COMMANDS", + "repl_prompt", + "run_repl", + "show_and_run", + "show_code", + "show_result", +] + +#: What ends a session, typed at the prompt. Both spellings: `:exit` is what +#: the help offers, and `exit` is what someone types anyway. +EXIT_COMMANDS = frozenset({":exit", ":quit", "exit", "quit"}) + +_console = Console() + + +def _out(console: Console | None) -> Console: + return console if console is not None else _console + + +def _write(console: Console, line: str, *, style: str | None = None, indent: bool = False) -> None: + """One line of a sandbox's output, printed as the text it is. + + `markup` and `highlight` off, always: what comes back from a sandbox is + data, and rich reads `[1, 2]` as a style tag and colours anything that + looks like a number or a path. A REPL that renders a list as a colour is + not showing the list. + """ + console.print( + f" {line}" if indent else line, + style=style, + markup=False, + highlight=False, + ) + + +def show_code(code: str, *, console: Console | None = None) -> None: + """Print the code about to be submitted, indented under its marker.""" + out = _out(console) + out.print(">>> code:", style="cyan") + for line in code.strip("\n").splitlines(): + _write(out, line, indent=True) + + +def show_result( + result: ExecutionResult, + *, + console: Console | None = None, + labelled: bool = True, +) -> None: + """Print what an execution came back with, and only that. + + `labelled` puts each stream under a marker of its own, which is what a + transcript of several snippets needs to stay readable. A REPL, where the + reader typed the line a moment ago and is watching for the answer, has no + use for it and reads better without. + """ + out = _out(console) + stdout = (result.stdout or "").strip("\n") + stderr = (result.stderr or "").strip("\n") + text = (result.text or "").strip() + + def block(marker: str, lines: list[str], style: str | None = None) -> None: + if labelled: + out.print(marker, style=style or "cyan") + for line in lines: + _write(out, line, style=style, indent=labelled) + + if stdout: + block("<<< stdout:", stdout.splitlines()) + # The value of the last expression, when it is not the stdout again. + if text and text != stdout.strip(): + _write(out, f"<<< result: {text}" if labelled else text) + if stderr: + block("<<< stderr:", stderr.splitlines(), style="yellow") + if result.code_error is not None: + error = f"{result.code_error.name}: {result.code_error.value}" + _write(out, f"<<< error: {error}" if labelled else error, style="red") + if not result.execution_ok: + # Not the code failing but the sandbox failing to run it, which is a + # different thing and says so. + failed = result.execution_error or "the sandbox could not run this" + _write(out, f"<<< execution error: {failed}", style="red") + elif labelled and not stdout and not text and result.code_error is None: + # A snippet can run perfectly and say nothing; in a transcript that + # has to be visible, or the reader looks for output that never came. + out.print("<<< (no output)", style="dim") + + +def show_and_run( + sandbox: Sandbox, + code: str, + *, + console: Console | None = None, + **kwargs: Any, +) -> ExecutionResult: + """Print the code, run it, print what came back, and return the result.""" + show_code(code, console=console) + result = sandbox.run_code(code, **kwargs) + show_result(result, console=console) + return result + + +def repl_prompt(sandbox: Sandbox) -> str: + """The prompt, naming which sandbox the line is about to be run in. + + A bare `>>>` is ambiguous the moment a second terminal is open, and these + prompts are usually opened in pairs — one against a local kernel and one + against something in a cloud, to compare them. + """ + info = sandbox.info + if info is None: + return "sandbox>>> " + name = info.name or (info.id[:8] if info.id else "sandbox") + return f"sandbox({info.variant or 'unknown'}:{name})>>> " + + +def _show_help(console: Console) -> 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.", + style="dim", + ) + console.print( + f"{', '.join(sorted(EXIT_COMMANDS))} — leave, terminating the sandbox.", + style="dim", + ) + console.print(":help — this.", style="dim") + + +def run_repl( + sandbox: Sandbox, + *, + console: Console | None = None, + banner: bool = True, +) -> 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. + """ + out = _out(console) + prompt = repl_prompt(sandbox) + 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") + + while True: + try: + line = input(prompt) + except EOFError: + out.print("") + break + except KeyboardInterrupt: + # Ctrl-C abandons the line, as a shell does; it does not leave. + out.print("\nInterrupted. Use :exit to leave.", style="yellow") + continue + + code = line.strip() + if not code: + continue + if code in EXIT_COMMANDS: + break + if code == ":help": + _show_help(out) + continue + + try: + result = sandbox.run_code(code) + except KeyboardInterrupt: + out.print("\nExecution interrupted.", style="yellow") + continue + except Exception as exc: + # A prompt outlives its lines: whatever the sandbox threw is + # reported, and the next line still gets to run. + _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/daytona_sandbox.py b/code_sandboxes/daytona_sandbox.py index e0aff4d..87757da 100644 --- a/code_sandboxes/daytona_sandbox.py +++ b/code_sandboxes/daytona_sandbox.py @@ -402,12 +402,13 @@ def _network_params(self) -> dict[str, Any]: def _resources(self, daytona: Any) -> Any | None: """The machine asked for, or nothing when the defaults will do.""" - cpu = int(self.config.cpu_limit) if self.config.cpu_limit else None + # Whole units here, and never zero: a fraction of a core or of a + # GiB is still a request, and rounding it away asks for a machine + # with none of that resource rather than for one with the default. + cpu = max(1, math.ceil(self.config.cpu_limit)) if self.config.cpu_limit else None memory = None if self.config.memory_limit: - # Bytes here, whole GiB there — and never zero, which would be a - # sandbox with no memory rather than one with the default. - memory = max(1, round(self.config.memory_limit / 1024**3)) + memory = max(1, math.ceil(self.config.memory_limit / 1024**3)) gpu_type = _gpu_type(self.config.gpu, daytona) if self.config.gpu else None if cpu is None and memory is None and gpu_type is None: return None @@ -617,4 +618,11 @@ def _write_file(self, path: str, content: bytes) -> None: def _read_file(self, path: str) -> bytes: if not self._started or self._sandbox is None: raise SandboxNotStartedError() - return self._sandbox.fs.download_file(path) or b"" + content = self._sandbox.fs.download_file(path) + if content is None: + # A file that is not there raises out of the SDK, so this is the + # other case: an answer carrying neither content nor error. + # Reading it as an empty file would make a failed read look like + # a successful one. + raise FileNotFoundError(f"Could not read file: {path}") + return content diff --git a/docs/docs/cli/index.mdx b/docs/docs/cli/index.mdx index 3e2a98a..bfb66d1 100644 --- a/docs/docs/cli/index.mdx +++ b/docs/docs/cli/index.mdx @@ -1,27 +1,77 @@ --- -title: CLI REPL +title: Running Code sidebar_position: 1 --- -# CLI REPL +# Running Code -Code Sandboxes provides a Typer-based CLI that launches an interactive REPL -against a selected sandbox variant. The same CLI also +Code Sandboxes ships a Typer CLI with two ways to run code in any variant: +`exec` for one snippet, `repl` for a prompt. The same CLI also [creates, lists and deletes sandboxes](/cli/management) across variants. +Both commands create a sandbox, use it, and terminate it. Nothing is left +running behind you — to keep one, see +[`code-sandboxes create`](/cli/management). + For canonical variant setup details (requirements, credentials, and parameters), see [Sandboxes](/sandboxes). For package installation and extras, see [Installation](/installation). ```bash -sandbox repl --variant jupyter +code-sandboxes exec --variant eval 'print(40 + 2)' +code-sandboxes repl --variant jupyter-server ``` -`code-sandboxes` remains available as an alias. +`code-sandbox` is available as an alias for the same command. -## Variant Selection +## `exec` — one snippet + +The code comes from an argument, from `--file`, or from standard input: + +```bash +code-sandboxes exec -v eval 'x = 40; x + 2' +code-sandboxes exec -v eval --file analysis.py +echo 'print("hello")' | code-sandboxes exec -v eval +``` + +The exit status is the code's own — `0` when it ran cleanly, `1` when it +raised or the sandbox could not run it — so this composes in a shell. With +`--quiet` nothing is printed but what the code produced: + +```bash +if code-sandboxes exec -v eval -q 'import numpy' ; then + echo "numpy is there" +fi +``` + +## `repl` — a prompt + +```bash +# Explicit variant +code-sandboxes repl --variant monty + +# Or omit it and choose interactively +code-sandboxes repl + +# Datalayer with overrides +code-sandboxes repl --variant datalayer --token "$DATALAYER_API_KEY" \ + --run-url "https://prod1.datalayer.run" +``` + +State is kept between lines, and the value of an expression is shown as a +REPL shows it. The prompt names the sandbox the line will run in, which +matters as soon as two are open side by side: + +``` +sandbox(daytona:tan-law-5384)>>> x = 40 +sandbox(daytona:tan-law-5384)>>> x + 2 +42 +``` -You can either pass `--variant` directly or omit it and choose interactively. +Use any of `:exit`, `:quit`, `exit`, `quit` or `Ctrl+D` to leave, and `:help` +for a reminder. On exit, the sandbox is terminated. + +## Variant Selection Supported variants: @@ -35,6 +85,9 @@ Supported variants: - `modal` - `monty` +`exec` and `repl` take the same options: `--variant`, `--timeout`, +`--environment`, `--gpu`, and the connection settings a variant needs. + ## Variant-specific Behavior - `daytona`: starts a Daytona sandbox; `--gpu` takes Daytona's own flavors @@ -45,33 +98,29 @@ Supported variants: - `modal`: starts a Modal sandbox container. - `monty`: starts a Monty REPL-backed sandbox. -## Usage - -```bash -# Interactive variant prompt -sandbox repl - -# Explicit variant -sandbox repl --variant monty +## The Same Machinery, From Python -# Datalayer with overrides -sandbox repl --variant datalayer --token "$DATALAYER_API_KEY" --run-url "https://prod1.datalayer.run" -``` +The CLI has nothing of its own: `exec` and `repl` are `show_and_run` and +`run_repl`, which the package exports and every example uses. -## Exiting and Cleanup +```python +from code_sandboxes import Sandbox, run_repl, show_and_run -Use any of the following to exit: +with Sandbox.create(variant="eval") as sandbox: + show_and_run(sandbox, "x = 40") + result = show_and_run(sandbox, "x + 2") # prints the code, then "42" + assert result.text == "42" -- `:exit` -- `:quit` -- `exit` -- `quit` -- `Ctrl+D` +with Sandbox.create(variant="eval") as sandbox: + run_repl(sandbox) # the prompt, on your own sandbox +``` -On exit, the created sandbox resource is terminated automatically. +`show_code`, `show_result` and `repl_prompt` are exported too, for a program +that wants the pieces rather than the whole. ## Related Docs +- [Managing Sandboxes](/cli/management) - [Sandboxes](/sandboxes) - [Installation](/installation) - [API Reference](/api-reference) diff --git a/docs/docs/examples/index.mdx b/docs/docs/examples/index.mdx index 08057ec..7b4a9fd 100644 --- a/docs/docs/examples/index.mdx +++ b/docs/docs/examples/index.mdx @@ -85,10 +85,13 @@ make monty See the Makefile for all targets: https://github.com/datalayer/code-sandboxes/blob/main/examples/Makefile -For an interactive command-line REPL across variants, see [CLI REPL](/cli). +Every example runs on the machinery the package exports — `show_and_run` +for the exec examples, `run_repl` for the REPL ones — which is the same code +behind `code-sandboxes exec` and `code-sandboxes repl`. See +[Running Code](/cli). ## Related Docs - [Sandboxes](/sandboxes) - [Installation](/installation) -- [CLI REPL](/cli) +- [Running Code](/cli) diff --git a/examples/README.md b/examples/README.md index 869756a..91d5397 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,11 +10,17 @@ # { } 📦 Code Sandboxes Examples -This folder now contains two example sets: +This folder contains two example sets: - `exec/`: one-shot execution examples (run a predefined script and exit). - `repl/`: interactive REPL examples (run ad-hoc code in a loop). +Neither set carries machinery of its own. Both import it from the package — +`show_and_run` prints a snippet, runs it and prints what came back; +`run_repl` holds a prompt open — which is the same code behind +`code-sandboxes exec` and `code-sandboxes repl`. That is the point of an +example here: it shows how to use a sandbox, not how to print things. + Supported sandbox variants: - `datalayer` diff --git a/examples/exec/datalayer_sandbox_example.py b/examples/exec/datalayer_sandbox_example.py index 1064a66..18a0fb9 100644 --- a/examples/exec/datalayer_sandbox_example.py +++ b/examples/exec/datalayer_sandbox_example.py @@ -9,9 +9,7 @@ This requires Datalayer runtime credentials/config. """ -from exec_common import show_and_run - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, show_and_run def main() -> None: diff --git a/examples/exec/daytona_sandbox_example.py b/examples/exec/daytona_sandbox_example.py index 6f0c49b..f24bacd 100644 --- a/examples/exec/daytona_sandbox_example.py +++ b/examples/exec/daytona_sandbox_example.py @@ -22,9 +22,7 @@ import argparse import os -from exec_common import show_and_run - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, show_and_run def _has_daytona_auth() -> bool: diff --git a/examples/exec/docker_sandbox_example.py b/examples/exec/docker_sandbox_example.py index 67be301..a1648de 100644 --- a/examples/exec/docker_sandbox_example.py +++ b/examples/exec/docker_sandbox_example.py @@ -10,9 +10,7 @@ Build it with: make -C .. build-docker """ -from exec_common import show_and_run - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, show_and_run def main() -> None: diff --git a/examples/exec/eval_sandbox_example.py b/examples/exec/eval_sandbox_example.py index bf200ae..a895202 100644 --- a/examples/exec/eval_sandbox_example.py +++ b/examples/exec/eval_sandbox_example.py @@ -7,9 +7,7 @@ python examples/eval_sandbox_example.py """ -from exec_common import show_and_run - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, show_and_run def main() -> None: diff --git a/examples/exec/exec_common.py b/examples/exec/exec_common.py deleted file mode 100644 index 95563fb..0000000 --- a/examples/exec/exec_common.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) 2025-2026 Datalayer, Inc. -# BSD 3-Clause License - -"""Shared helper for exec-style sandbox examples. - -Every run reads the same way in the terminal, whatever the provider: the code -that was submitted, then what came back — stdout, the value of the last -expression, and the error when there is one. An example that printed only its -own commentary left the reader guessing which snippet produced which lines. -""" - -from __future__ import annotations - -from code_sandboxes import Sandbox - - -def show_code(code: str) -> None: - """Print the code about to be submitted, indented under its marker.""" - print(">>> code:") - for line in code.strip("\n").splitlines(): - print(f" {line}") - - -def show_result(result) -> None: - """Print what an execution came back with, and only what it came back with.""" - stdout = (result.stdout or "").strip("\n") - if stdout: - print("<<< stdout:") - for line in stdout.splitlines(): - print(f" {line}") - text = (result.text or "").strip() - # The value of the last expression, when it is not just the stdout again. - if text and text != stdout.strip(): - print(f"<<< result: {text}") - error = getattr(result, "code_error", None) - if error is not None: - print(f"<<< error: {error.name}: {error.value}") - if not stdout and not text and error is None: - print("<<< (no output)") - - -def show_and_run(sandbox: Sandbox, code: str, **kwargs): - """Print the code, run it, print what came back, and return the result.""" - show_code(code) - result = sandbox.run_code(code, **kwargs) - show_result(result) - return result diff --git a/examples/exec/google_colab_sandbox_example.py b/examples/exec/google_colab_sandbox_example.py index 3e10c66..f65503f 100644 --- a/examples/exec/google_colab_sandbox_example.py +++ b/examples/exec/google_colab_sandbox_example.py @@ -10,9 +10,7 @@ import os -from exec_common import show_and_run - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, show_and_run def _require(name: str) -> str: diff --git a/examples/exec/jupyter_server_sandbox_example.py b/examples/exec/jupyter_server_sandbox_example.py index a588e34..4c3e7f6 100644 --- a/examples/exec/jupyter_server_sandbox_example.py +++ b/examples/exec/jupyter_server_sandbox_example.py @@ -9,9 +9,7 @@ Note: This requires jupyter_server and jupyter-kernel-client. """ -from exec_common import show_and_run - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, show_and_run def main() -> None: diff --git a/examples/exec/kaggle_sandbox_example.py b/examples/exec/kaggle_sandbox_example.py index 99fb1bb..4917002 100644 --- a/examples/exec/kaggle_sandbox_example.py +++ b/examples/exec/kaggle_sandbox_example.py @@ -28,9 +28,7 @@ import argparse import os -from exec_common import show_and_run, show_code - -from code_sandboxes import CodeError, Sandbox +from code_sandboxes import CodeError, Sandbox, show_and_run, show_code GPU_PROBE = """ import shutil diff --git a/examples/exec/modal_sandbox_example.py b/examples/exec/modal_sandbox_example.py index 5bc824c..a659e68 100644 --- a/examples/exec/modal_sandbox_example.py +++ b/examples/exec/modal_sandbox_example.py @@ -19,9 +19,7 @@ import os from pathlib import Path -from exec_common import show_and_run - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, show_and_run def _has_modal_auth() -> bool: diff --git a/examples/exec/monty_sandbox_example.py b/examples/exec/monty_sandbox_example.py index 7eea98e..91a9e18 100644 --- a/examples/exec/monty_sandbox_example.py +++ b/examples/exec/monty_sandbox_example.py @@ -9,9 +9,7 @@ Note: This requires code-sandboxes[monty] / pydantic-monty. """ -from exec_common import show_and_run - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, show_and_run def main() -> None: diff --git a/examples/repl/datalayer_sandbox_example.py b/examples/repl/datalayer_sandbox_example.py index 84f4af6..07496bf 100644 --- a/examples/repl/datalayer_sandbox_example.py +++ b/examples/repl/datalayer_sandbox_example.py @@ -3,9 +3,7 @@ """REPL example: datalayer sandbox (cloud runtime).""" -from repl_common import run_repl - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, run_repl def main() -> None: diff --git a/examples/repl/daytona_sandbox_example.py b/examples/repl/daytona_sandbox_example.py index 16e366d..787501f 100644 --- a/examples/repl/daytona_sandbox_example.py +++ b/examples/repl/daytona_sandbox_example.py @@ -17,9 +17,7 @@ import argparse import os -from repl_common import run_repl - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, run_repl def _has_daytona_auth() -> bool: diff --git a/examples/repl/docker_sandbox_example.py b/examples/repl/docker_sandbox_example.py index 5864ffa..6882cf7 100644 --- a/examples/repl/docker_sandbox_example.py +++ b/examples/repl/docker_sandbox_example.py @@ -3,9 +3,7 @@ """REPL example: docker sandbox (container isolation).""" -from repl_common import run_repl - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, run_repl def main() -> None: diff --git a/examples/repl/eval_sandbox_example.py b/examples/repl/eval_sandbox_example.py index b793ab9..6ea1aec 100644 --- a/examples/repl/eval_sandbox_example.py +++ b/examples/repl/eval_sandbox_example.py @@ -3,9 +3,7 @@ """REPL example: eval sandbox (no isolation).""" -from repl_common import run_repl - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, run_repl def main() -> None: diff --git a/examples/repl/google_colab_sandbox_example.py b/examples/repl/google_colab_sandbox_example.py index 59bf7c4..25360be 100644 --- a/examples/repl/google_colab_sandbox_example.py +++ b/examples/repl/google_colab_sandbox_example.py @@ -5,9 +5,7 @@ import os -from repl_common import run_repl - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, run_repl def _require(name: str) -> str: diff --git a/examples/repl/jupyter_server_sandbox_example.py b/examples/repl/jupyter_server_sandbox_example.py index 9d68be5..f790add 100644 --- a/examples/repl/jupyter_server_sandbox_example.py +++ b/examples/repl/jupyter_server_sandbox_example.py @@ -3,9 +3,7 @@ """REPL example: jupyter sandbox (persistent kernel state).""" -from repl_common import run_repl - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, run_repl def main() -> None: diff --git a/examples/repl/kaggle_sandbox_example.py b/examples/repl/kaggle_sandbox_example.py index 24f5cf3..a131dd7 100644 --- a/examples/repl/kaggle_sandbox_example.py +++ b/examples/repl/kaggle_sandbox_example.py @@ -15,9 +15,7 @@ import os -from repl_common import run_repl - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, run_repl def main() -> None: diff --git a/examples/repl/modal_sandbox_example.py b/examples/repl/modal_sandbox_example.py index 904d0af..b9d27a0 100644 --- a/examples/repl/modal_sandbox_example.py +++ b/examples/repl/modal_sandbox_example.py @@ -7,9 +7,7 @@ import os from pathlib import Path -from repl_common import run_repl - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, run_repl def _has_modal_auth() -> bool: diff --git a/examples/repl/monty_sandbox_example.py b/examples/repl/monty_sandbox_example.py index 78f683d..2ec4703 100644 --- a/examples/repl/monty_sandbox_example.py +++ b/examples/repl/monty_sandbox_example.py @@ -3,9 +3,7 @@ """REPL example: monty sandbox (secure in-process interpreter).""" -from repl_common import run_repl - -from code_sandboxes import Sandbox +from code_sandboxes import Sandbox, run_repl def main() -> None: diff --git a/examples/repl/repl_common.py b/examples/repl/repl_common.py deleted file mode 100644 index 2333e6e..0000000 --- a/examples/repl/repl_common.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) 2025-2026 Datalayer, Inc. -# BSD 3-Clause License - -"""Shared REPL helper for sandbox examples.""" - -from __future__ import annotations - -from code_sandboxes import Sandbox - - -def _build_prompt(sandbox: Sandbox) -> str: - info = sandbox.info - if info is None: - return "sandbox>>> " - - variant = info.variant or "unknown" - name_or_id = info.name or (info.id[:8] if info.id else "sandbox") - return f"sandbox({variant}:{name_or_id})>>> " - - -def _read_input(prompt: str) -> str | None: - try: - return input(prompt).strip() - except EOFError: - print() - return None - except KeyboardInterrupt: - print("\nInterrupted. Use :quit to exit.") - return "" - - -def _handle_repl_command(code: str) -> bool: - if code in {":quit", ":exit"}: - return False - if code == ":help": - print("Enter Python expressions/statements.") - print(":quit or :exit to leave.") - return True - - -def _print_result(result) -> None: - if result.stdout: - print(result.stdout.rstrip()) - if result.text and result.text != result.stdout.strip(): - print(result.text) - if result.stderr: - print(result.stderr.rstrip()) - if result.code_error: - print(f"{result.code_error.name}: {result.code_error.value}") - if not result.execution_ok and result.execution_error: - print(f"Execution error: {result.execution_error}") - - -def run_repl(sandbox: Sandbox) -> None: - """Run a small interactive Python REPL on a sandbox.""" - prompt = _build_prompt(sandbox) - - print("Sandbox REPL ready.") - print("Type Python code and press Enter.") - print("Use :quit or :exit to leave, :help for help.") - - while True: - code = _read_input(prompt) - if code is None: - break - if not code: - continue - if code.startswith(":"): - if not _handle_repl_command(code): - break - continue - - result = sandbox.run_code(code) - _print_result(result) - - print("REPL closed.") diff --git a/tests/test_cli_exec.py b/tests/test_cli_exec.py new file mode 100644 index 0000000..89f99cd --- /dev/null +++ b/tests/test_cli_exec.py @@ -0,0 +1,124 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""`code-sandboxes exec`: one snippet, and a status a shell can read. + +Run against the `eval` variant, which needs nothing and runs in this process, +so these are the real command end to end rather than a mock of it. +""" + +from __future__ import annotations + +from typer.testing import CliRunner + +from code_sandboxes import cli as sandbox_cli + +runner = CliRunner() + + +def _exec(*args: str, **kwargs) -> object: + return runner.invoke(sandbox_cli.app, ["exec", "--variant", "eval", *args], **kwargs) + + +def test_code_given_as_an_argument_runs(): + result = _exec("print(40 + 2)") + + assert result.exit_code == 0 + assert "42" in result.stdout + + +def test_the_value_of_the_last_expression_comes_back(): + result = _exec("x = 40\nx + 2") + + assert result.exit_code == 0 + assert "<<< result: 42" in result.stdout + + +def test_code_can_come_from_a_file(tmp_path): + snippet = tmp_path / "snippet.py" + snippet.write_text("print('from a file')\n", encoding="utf-8") + + result = _exec("--file", str(snippet)) + + assert result.exit_code == 0 + assert "from a file" in result.stdout + + +def test_code_can_come_from_stdin(): + result = _exec(input="print('from stdin')\n") + + assert result.exit_code == 0 + assert "from stdin" in result.stdout + + +def test_giving_it_both_ways_is_refused(tmp_path): + snippet = tmp_path / "snippet.py" + snippet.write_text("print('file')\n", encoding="utf-8") + + result = _exec("print('argument')", "--file", str(snippet)) + + assert result.exit_code != 0 + + +def test_the_status_is_the_code_s_own(): + """What makes this composable in a shell: a snippet that raised fails.""" + assert _exec("print('fine')").exit_code == 0 + assert _exec("raise ValueError('boom')").exit_code == 1 + + +def test_a_failing_snippet_still_shows_its_error(): + result = _exec("raise ValueError('boom')") + + assert "ValueError: boom" in result.stdout + + +def test_quiet_prints_only_what_the_code_produced(): + result = _exec("--quiet", "print(40 + 2)") + + assert result.exit_code == 0 + assert result.stdout.strip() == "42" + + +def test_the_sandbox_is_terminated_either_way(monkeypatch): + stopped: list[bool] = [] + real_stop = sandbox_cli.Sandbox.stop + + def spy(self): + stopped.append(True) + return real_stop(self) + + monkeypatch.setattr("code_sandboxes.eval_sandbox.EvalSandbox.stop", spy) + + _exec("print('ok')") + _exec("raise ValueError('boom')") + + assert len(stopped) == 2 + + +def test_the_variant_and_its_settings_are_forwarded(monkeypatch): + captured: dict = {} + + def fake_create(*_args, **kwargs): + captured.update(kwargs) + from code_sandboxes.eval_sandbox import EvalSandbox + + return EvalSandbox() + + monkeypatch.setattr(sandbox_cli.Sandbox, "create", staticmethod(fake_create)) + + result = runner.invoke( + sandbox_cli.app, + ["exec", "--variant", "datalayer", "--gpu", "T4", "--timeout", "12", "1 + 1"], + ) + + assert result.exit_code == 0 + assert captured["variant"] == "datalayer" + assert captured["gpu"] == "T4" + assert captured["timeout"] == 12.0 + + +def test_an_unknown_variant_is_refused_before_anything_starts(): + result = runner.invoke(sandbox_cli.app, ["exec", "--variant", "nonesuch", "1 + 1"]) + + assert result.exit_code != 0 diff --git a/tests/test_cli_repl.py b/tests/test_cli_repl.py index 20de4de..fc1c109 100644 --- a/tests/test_cli_repl.py +++ b/tests/test_cli_repl.py @@ -9,13 +9,16 @@ from typer.testing import CliRunner from code_sandboxes import cli as sandbox_cli -from code_sandboxes.models import ExecutionResult, Logs, Result +from code_sandboxes.models import ExecutionResult, Logs, Result, SandboxInfo class _FakeSandbox: def __init__(self): self.sandbox_id = "sandbox-123" self.exited = False + # The prompt names the sandbox it is about to run a line in, so a + # stand-in for one has to answer the same question a real one does. + self.info = SandboxInfo(id=self.sandbox_id, variant="fake", name="fake-sandbox") def __enter__(self): return self diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..cd2be59 --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,284 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""The one way a run is shown, and the one prompt that shows it repeatedly. + +This machinery used to exist three times over — in the CLI, in the REPL +examples and in the exec examples — so what is worth pinning down is the +things those copies disagreed about: whether the value of the last expression +is shown, whether stderr is told apart, and what ends a session. +""" + +from __future__ import annotations + +import builtins + +import pytest +from rich.console import Console + +from code_sandboxes.console import ( + EXIT_COMMANDS, + repl_prompt, + run_repl, + show_and_run, + show_result, +) +from code_sandboxes.models import ( + CodeError, + ExecutionResult, + Logs, + OutputMessage, + Result, + SandboxInfo, +) + + +def _console(width: int = 200) -> tuple[Console, list[str]]: + """A console that keeps what it was told, instead of a terminal.""" + console = Console(record=True, width=width, force_terminal=False, no_color=True) + lines: list[str] = [] + return console, lines + + +def _rendered(console: Console) -> list[str]: + return [line.rstrip() for line in console.export_text().splitlines()] + + +def _result( + stdout: str = "", + stderr: str = "", + text: str | None = None, + error: CodeError | None = None, + ok: bool = True, + failure: str | None = None, +) -> ExecutionResult: + return ExecutionResult( + results=[Result(data={"text/plain": text}, is_main_result=True)] if text else [], + logs=Logs( + stdout=[OutputMessage(line=line) for line in stdout.splitlines()], + stderr=[OutputMessage(line=line, error=True) for line in stderr.splitlines()], + ), + execution_ok=ok, + execution_error=failure, + code_error=error, + ) + + +class _FakeSandbox: + """Enough of a sandbox to be shown: an identity and an answer.""" + + def __init__(self, answers: dict[str, ExecutionResult] | None = None): + self.info = SandboxInfo(id="abcdef123456", variant="eval", name=None) + self.ran: list[str] = [] + self._answers = answers or {} + + def run_code(self, code: str, **_kwargs) -> ExecutionResult: + self.ran.append(code) + return self._answers.get(code, _result(stdout=f"ran {code}")) + + +# --- What a result looks like -------------------------------------------- + + +def test_the_value_of_the_last_expression_is_shown(): + console, _ = _console() + + show_result(_result(text="42"), console=console) + + assert "<<< result: 42" in _rendered(console) + + +def test_a_value_that_is_only_the_stdout_again_is_not_shown_twice(): + console, _ = _console() + + show_result(_result(stdout="42", text="42"), console=console) + + rendered = _rendered(console) + assert "<<< stdout:" in rendered + assert not any(line.startswith("<<< result:") for line in rendered) + + +def test_stderr_is_told_apart_from_stdout(): + console, _ = _console() + + show_result(_result(stdout="fine", stderr="careful"), console=console) + + rendered = _rendered(console) + assert rendered.index("<<< stdout:") < rendered.index("<<< stderr:") + assert " careful" in rendered + + +def test_a_raising_snippet_shows_its_error(): + console, _ = _console() + + show_result(_result(error=CodeError(name="ValueError", value="boom")), console=console) + + assert "<<< error: ValueError: boom" in _rendered(console) + + +def test_a_sandbox_that_could_not_run_it_says_so_differently(): + """The code failing and the sandbox failing are not the same event.""" + console, _ = _console() + + show_result(_result(ok=False, failure="the websocket closed"), console=console) + + rendered = _rendered(console) + assert "<<< execution error: the websocket closed" in rendered + assert not any(line.startswith("<<< error:") for line in rendered) + + +def test_a_snippet_that_produced_nothing_says_so(): + console, _ = _console() + + show_result(_result(), console=console) + + assert "<<< (no output)" in _rendered(console) + + +def test_the_prompt_form_drops_the_markers(): + """At a prompt the reader typed the line; the labels are noise.""" + console, _ = _console() + + show_result(_result(stdout="hello", text="42"), console=console, labelled=False) + + assert _rendered(console) == ["hello", "42"] + + +def test_output_that_looks_like_markup_is_shown_as_itself(): + """`rich` reads `[dim]` as a style; a sandbox printing it means the text.""" + console, _ = _console() + + show_result(_result(stdout="[dim]not a style[/dim]"), console=console) + + assert " [dim]not a style[/dim]" in _rendered(console) + + +def test_show_and_run_prints_the_code_then_the_answer(): + console, _ = _console() + sandbox = _FakeSandbox({"1 + 1": _result(text="2")}) + + result = show_and_run(sandbox, "1 + 1", console=console) + + assert sandbox.ran == ["1 + 1"] + assert result.text == "2" + rendered = _rendered(console) + assert rendered.index(">>> code:") < rendered.index("<<< result: 2") + assert " 1 + 1" in rendered + + +# --- The prompt ----------------------------------------------------------- + + +def test_the_prompt_names_the_sandbox(): + sandbox = _FakeSandbox() + + assert repl_prompt(sandbox) == "sandbox(eval:abcdef12)>>> " + + sandbox.info.name = "tan-law-5384" + assert repl_prompt(sandbox) == "sandbox(eval:tan-law-5384)>>> " + + +def _typing(monkeypatch, *lines: str) -> None: + """Stand in for someone at the keyboard, who eventually stops typing.""" + typed = iter(lines) + + def fake_input(_prompt: str = "") -> str: + try: + return next(typed) + except StopIteration: + raise EOFError from None + + monkeypatch.setattr(builtins, "input", fake_input) + + +@pytest.mark.parametrize("command", sorted(EXIT_COMMANDS)) +def test_every_exit_command_leaves(monkeypatch, command): + console, _ = _console() + sandbox = _FakeSandbox() + _typing(monkeypatch, command, "never_reached") + + run_repl(sandbox, console=console, banner=False) + + assert sandbox.ran == [] + + +def test_the_prompt_runs_what_is_typed_and_shows_the_answer(monkeypatch): + console, _ = _console() + sandbox = _FakeSandbox({"1 + 1": _result(text="2")}) + _typing(monkeypatch, "1 + 1", ":exit") + + run_repl(sandbox, console=console, banner=False) + + assert sandbox.ran == ["1 + 1"] + assert "2" in _rendered(console) + + +def test_blank_lines_are_not_run(monkeypatch): + console, _ = _console() + sandbox = _FakeSandbox() + _typing(monkeypatch, "", " ", ":exit") + + run_repl(sandbox, console=console, banner=False) + + assert sandbox.ran == [] + + +def test_running_out_of_input_leaves(monkeypatch): + """Ctrl-D ends the session; it is not an error.""" + console, _ = _console() + sandbox = _FakeSandbox() + _typing(monkeypatch) + + run_repl(sandbox, console=console, banner=False) + + assert "REPL closed." in _rendered(console) + + +def test_help_is_shown_without_running_anything(monkeypatch): + console, _ = _console() + sandbox = _FakeSandbox() + _typing(monkeypatch, ":help", ":exit") + + run_repl(sandbox, console=console, banner=False) + + assert sandbox.ran == [] + assert any(":help" in line for line in _rendered(console)) + + +def test_a_line_that_blows_up_the_sandbox_does_not_end_the_session(monkeypatch): + """A prompt outlives its lines: the next one still gets to run.""" + console, _ = _console() + sandbox = _FakeSandbox() + calls: list[str] = [] + + def explode(code: str, **_kwargs): + calls.append(code) + if code == "boom": + raise RuntimeError("the connection went away") + return _result(stdout="still here") + + sandbox.run_code = explode + _typing(monkeypatch, "boom", "after", ":exit") + + run_repl(sandbox, console=console, banner=False) + + assert calls == ["boom", "after"] + rendered = _rendered(console) + assert any("the connection went away" in line for line in rendered) + assert "still here" in rendered + + +def test_an_interrupted_line_does_not_end_the_session(monkeypatch): + console, _ = _console() + sandbox = _FakeSandbox() + + def interrupted(_code: str, **_kwargs): + raise KeyboardInterrupt + + sandbox.run_code = interrupted + _typing(monkeypatch, "while True: pass", ":exit") + + run_repl(sandbox, console=console, banner=False) + + assert any("interrupted" in line.lower() for line in _rendered(console)) diff --git a/tests/test_daytona.py b/tests/test_daytona.py index 489d9fb..6b3bec5 100644 --- a/tests/test_daytona.py +++ b/tests/test_daytona.py @@ -90,7 +90,11 @@ def __init__(self) -> None: self.files: dict[str, bytes] = {} def upload_file(self, src, dst, timeout=1800): - self.files[dst] = src if isinstance(src, bytes) else open(src, "rb").read() + if isinstance(src, bytes): + self.files[dst] = src + return + with open(src, "rb") as handle: + self.files[dst] = handle.read() def download_file(self, *args): return self.files.get(args[0]) diff --git a/tests/test_kernel_client_compatibility.py b/tests/test_kernel_client_compatibility.py index 6d021e0..35d674b 100644 --- a/tests/test_kernel_client_compatibility.py +++ b/tests/test_kernel_client_compatibility.py @@ -160,5 +160,7 @@ def test_the_kaggle_live_session_answers_the_same_calls(self): _binds(KaggleLiveSession.stop, None, shutdown_kernel=False) _binds(KaggleLiveSession.get_variable, None, "x") - session = KaggleLiveSession(executor=object()) + # Its own `api`, so the shapes can be checked without the `kaggle` + # distribution: the constructor imports it only to default this. + session = KaggleLiveSession(executor=object(), api=object()) assert isinstance(session.id, str) and session.id From 1f27ce2c79dc75594b3ae019b33d3ecb8b759fb5 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Fri, 21 Aug 2026 07:31:57 +0200 Subject: [PATCH 4/7] varian: google-colab --- README.md | 2 +- code_sandboxes/base.py | 4 ++-- code_sandboxes/cli.py | 6 +++--- code_sandboxes/google_colab_sandbox.py | 6 +++--- code_sandboxes/manage.py | 4 ++-- code_sandboxes/models.py | 16 ++++++++-------- docs/docs/api-reference/index.mdx | 4 ++-- docs/docs/cli/management.mdx | 2 +- docs/docs/sandboxes/google-colab.mdx | 4 ++-- examples/exec/google_colab_sandbox_example.py | 2 +- examples/repl/google_colab_sandbox_example.py | 2 +- tests/test_cli_repl.py | 2 +- tests/test_factory.py | 4 ++-- tests/test_manage.py | 4 ++-- tests/test_models.py | 8 ++++---- 15 files changed, 35 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 3aafaad..93eae4b 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ directly to the sandbox: ```python from code_sandboxes import Sandbox -with Sandbox.create(variant="google_colab", channels_url=channels_url) as sandbox: +with Sandbox.create(variant="google-colab", channels_url=channels_url) as sandbox: print(sandbox.run_code("x = 1 + 1; print(x)").stdout) ``` diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index d190534..6ecdd20 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -317,7 +317,7 @@ def create( # noqa: C901 from .datalayer_sandbox import DatalayerSandbox sandbox = DatalayerSandbox(config=config, **kwargs) - elif variant_value == "google_colab": + elif variant_value == "google-colab": from .google_colab_sandbox import GoogleColabSandbox sandbox = GoogleColabSandbox(config=config, **kwargs) @@ -415,7 +415,7 @@ def list_environments( from .kaggle_sandbox import KaggleSandbox return KaggleSandbox.list_environments() - if variant_value == "google_colab": + if variant_value == "google-colab": from .google_colab_sandbox import GoogleColabSandbox return GoogleColabSandbox.list_environments() diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py index 50f14eb..65417c8 100644 --- a/code_sandboxes/cli.py +++ b/code_sandboxes/cli.py @@ -30,7 +30,7 @@ "docker", "eval", "monty", - "google_colab", + "google-colab", "google-colab", "kaggle", "modal", @@ -63,7 +63,7 @@ def _resolve_variant(variant: str | None) -> str: + ", ".join(sorted(_SUPPORTED_REPL_VARIANTS)) ) if selected == "google-colab": - return "google_colab" + return "google-colab" return selected @@ -82,7 +82,7 @@ def _resolve_variant_kwargs( # Match `jupyter console` behavior by launching local Jupyter on random port. kwargs["port"] = 0 - if variant == "google_colab": + if variant == "google-colab": kwargs["server_url"] = server_url or typer.prompt("Colab runtime URL (RUNTIME_URL)") kwargs["kernel_id"] = kernel_id or typer.prompt("Colab kernel id (RUNTIME_ID)") kwargs["proxy_token"] = proxy_token or typer.prompt( diff --git a/code_sandboxes/google_colab_sandbox.py b/code_sandboxes/google_colab_sandbox.py index 9b58f6b..a4cffab 100644 --- a/code_sandboxes/google_colab_sandbox.py +++ b/code_sandboxes/google_colab_sandbox.py @@ -79,13 +79,13 @@ def __init__( def list_environments(cls) -> list[SandboxEnvironment]: return [ SandboxEnvironment( - name="google_colab", + name="google-colab", title="Google Colab", language="python", owner="google", visibility="cloud", burning_rate=0.0, - metadata={"variant": "google_colab"}, + metadata={"variant": "google-colab"}, ) ] @@ -122,7 +122,7 @@ def start(self) -> None: self._default_context = self.create_context("default") self._info = SandboxInfo( id=self._sandbox_id, - variant="google_colab", + variant="google-colab", status=SandboxStatus.RUNNING, created_at=time.time(), name=self.config.name, diff --git a/code_sandboxes/manage.py b/code_sandboxes/manage.py index 5e7c581..742875f 100644 --- a/code_sandboxes/manage.py +++ b/code_sandboxes/manage.py @@ -334,7 +334,7 @@ class GoogleColabSandboxManager(JupyterServerSandboxManager): ``RUNTIME_PROXY_TOKEN`` when not passed explicitly. """ - variant = "google_colab" + variant = "google-colab" def __init__( self, @@ -832,7 +832,7 @@ def create(self, **kwargs: Any) -> SandboxInfo: "monty": MontySandboxManager, "docker": DockerSandboxManager, "jupyter-server": JupyterServerSandboxManager, - "google_colab": GoogleColabSandboxManager, + "google-colab": GoogleColabSandboxManager, "kaggle": KaggleSandboxManager, "modal": ModalSandboxManager, "daytona": DaytonaSandboxManager, diff --git a/code_sandboxes/models.py b/code_sandboxes/models.py index ff772da..0c478d4 100644 --- a/code_sandboxes/models.py +++ b/code_sandboxes/models.py @@ -53,27 +53,27 @@ class MIMEType(str, Enum): class SandboxStatus(str, Enum): """Status of a sandbox.""" + ERROR = "error" PENDING = "pending" - STARTING = "starting" RUNNING = "running" - STOPPING = "stopping" + STARTING = "starting" STOPPED = "stopped" - ERROR = "error" + STOPPING = "stopping" TERMINATED = "terminated" class SandboxVariant(str, Enum): """Supported sandbox variants.""" - EVAL = "eval" - DOCKER = "docker" - JUPYTER = "jupyter-server" DATALAYER = "datalayer" DAYTONA = "daytona" - GOOGLE_COLAB = "google_colab" + DOCKER = "docker" + EVAL = "eval" + GOOGLE_COLAB = "google-colab" + JUPYTER = "jupyter-server" KAGGLE = "kaggle" - MONTY = "monty" MODAL = "modal" + MONTY = "monty" class GPUType(str, Enum): diff --git a/docs/docs/api-reference/index.mdx b/docs/docs/api-reference/index.mdx index 195ef26..049d3e6 100644 --- a/docs/docs/api-reference/index.mdx +++ b/docs/docs/api-reference/index.mdx @@ -36,7 +36,7 @@ def create( | Parameter | Type | Description | |-----------|------|-------------| -| `variant` | `str` | Sandbox type: `"eval"`, `"docker"`, `"jupyter-server"`, `"monty"`, `"kaggle"`, `"google_colab"`, `"modal"`, or `"datalayer"`. Defaults to `"datalayer"`. | +| `variant` | `str` | Sandbox type: `"eval"`, `"docker"`, `"jupyter-server"`, `"monty"`, `"kaggle"`, `"google-colab"`, `"modal"`, or `"datalayer"`. Defaults to `"datalayer"`. | | `timeout` | `float` | Execution timeout in seconds | | `environment` | `str` | Runtime environment name | | `gpu` | `str` | GPU type (e.g., `"T4"`, `"A100"`, `"H100"`) | @@ -82,7 +82,7 @@ def list_environments( | Parameter | Type | Description | |-----------|------|-------------| -| `variant` | `str` | Sandbox type: `"eval"`, `"docker"`, `"jupyter-server"`, `"monty"`, `"kaggle"`, `"google_colab"`, `"modal"`, or `"datalayer"` | +| `variant` | `str` | Sandbox type: `"eval"`, `"docker"`, `"jupyter-server"`, `"monty"`, `"kaggle"`, `"google-colab"`, `"modal"`, or `"datalayer"` | | `**kwargs` | `dict` | Variant-specific arguments (e.g., credentials, run URL) | Legacy `local-eval`, `local-docker`, and `local-jupyter` variant names are not supported. diff --git a/docs/docs/cli/management.mdx b/docs/docs/cli/management.mdx index 7e0ffce..ba8510b 100644 --- a/docs/docs/cli/management.mdx +++ b/docs/docs/cli/management.mdx @@ -73,7 +73,7 @@ Connection settings go to `get_manager` and stay out of the verbs: ```python get_manager("jupyter-server", server_url="http://localhost:8888", token="...") -get_manager("google_colab", server_url="https://...", proxy_token="...") +get_manager("google-colab", server_url="https://...", proxy_token="...") get_manager("modal", app_name="code-sandboxes") get_manager("daytona", api_key="dtn_...", target="eu") get_manager("kaggle", username="...") diff --git a/docs/docs/sandboxes/google-colab.mdx b/docs/docs/sandboxes/google-colab.mdx index b2702ec..c81be3c 100644 --- a/docs/docs/sandboxes/google-colab.mdx +++ b/docs/docs/sandboxes/google-colab.mdx @@ -25,7 +25,7 @@ after the runtime is reassigned or reconnected. from code_sandboxes import Sandbox with Sandbox.create( - variant="google_colab", + variant="google-colab", server_url="https://8080-m-s-kkb-...-d.us-east1-0.prod.colab.dev", kernel_id="c9bba548-3995-4f26-8e1a-7b8fbb10c578", proxy_token="eyJhbGci....", @@ -39,7 +39,7 @@ Or pass a channels URL directly: ```python with Sandbox.create( - variant="google_colab", + variant="google-colab", channels_url=( "wss:///api/kernels//channels" "?session_id=<...>&colab-runtime-proxy-token=&colab-client-agent=web" diff --git a/examples/exec/google_colab_sandbox_example.py b/examples/exec/google_colab_sandbox_example.py index f65503f..9ab0d61 100644 --- a/examples/exec/google_colab_sandbox_example.py +++ b/examples/exec/google_colab_sandbox_example.py @@ -27,7 +27,7 @@ def main() -> None: runtime_proxy_token = _require("RUNTIME_PROXY_TOKEN") with Sandbox.create( - variant="google_colab", + variant="google-colab", timeout=60, server_url=runtime_url, kernel_id=runtime_id, diff --git a/examples/repl/google_colab_sandbox_example.py b/examples/repl/google_colab_sandbox_example.py index 25360be..50e06fe 100644 --- a/examples/repl/google_colab_sandbox_example.py +++ b/examples/repl/google_colab_sandbox_example.py @@ -22,7 +22,7 @@ def main() -> None: runtime_proxy_token = _require("RUNTIME_PROXY_TOKEN") with Sandbox.create( - variant="google_colab", + variant="google-colab", timeout=60, server_url=runtime_url, kernel_id=runtime_id, diff --git a/tests/test_cli_repl.py b/tests/test_cli_repl.py index fc1c109..8bff64c 100644 --- a/tests/test_cli_repl.py +++ b/tests/test_cli_repl.py @@ -75,7 +75,7 @@ def _fake_create(*args, **kwargs): ) assert result.exit_code == 0 - assert captured["kwargs"]["variant"] == "google_colab" + assert captured["kwargs"]["variant"] == "google-colab" assert captured["kwargs"]["server_url"] == "https://colab-host.example" assert captured["kwargs"]["kernel_id"] == "kernel-abc" assert captured["kwargs"]["proxy_token"] == "proxy-xyz" # noqa: S105 diff --git a/tests/test_factory.py b/tests/test_factory.py index bd556db..5810ea0 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -70,7 +70,7 @@ def test_create_invalid_variant(self): ("jupyter-server", JupyterServerSandbox), ("docker", DockerSandbox), ("datalayer", DatalayerSandbox), - ("google_colab", GoogleColabSandbox), + ("google-colab", GoogleColabSandbox), ("kaggle", KaggleSandbox), ("monty", MontySandbox), ("modal", ModalSandbox), @@ -115,7 +115,7 @@ def test_create_default_variant_is_datalayer(self): def test_create_colab_forwards_connection_kwargs(self): """Test that Colab-specific connection kwargs are propagated.""" sandbox = Sandbox.create( - variant="google_colab", + variant="google-colab", server_url="https://colab-host.example", kernel_id="kernel-id", proxy_token="proxy-token", # noqa: S106 diff --git a/tests/test_manage.py b/tests/test_manage.py index fd8afe0..c69ce84 100644 --- a/tests/test_manage.py +++ b/tests/test_manage.py @@ -26,7 +26,7 @@ def test_every_variant_has_a_manager(): "daytona", "docker", "eval", - "google_colab", + "google-colab", "jupyter-server", "kaggle", "modal", @@ -38,7 +38,7 @@ def test_every_variant_has_a_manager(): def test_the_colab_spelling_with_a_dash_is_accepted(monkeypatch): monkeypatch.setenv("RUNTIME_URL", "https://colab.example/proxy") - assert get_manager("google-colab").variant == "google_colab" + assert get_manager("google-colab").variant == "google-colab" def test_an_unknown_variant_is_named_in_the_error(): diff --git a/tests/test_models.py b/tests/test_models.py index 35bcb26..caba9cf 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -41,14 +41,14 @@ def test_sandbox_status_enum(self): def test_sandbox_variant_enum(self): """Test SandboxVariant enum values.""" - assert SandboxVariantEnum.EVAL.value == "eval" + assert SandboxVariantEnum.DATALAYER.value == "datalayer" assert SandboxVariantEnum.DOCKER.value == "docker" + assert SandboxVariantEnum.EVAL.value == "eval" + assert SandboxVariantEnum.GOOGLE_COLAB.value == "google-colab" assert SandboxVariantEnum.JUPYTER.value == "jupyter-server" - assert SandboxVariantEnum.DATALAYER.value == "datalayer" - assert SandboxVariantEnum.GOOGLE_COLAB.value == "google_colab" assert SandboxVariantEnum.KAGGLE.value == "kaggle" - assert SandboxVariantEnum.MONTY.value == "monty" assert SandboxVariantEnum.MODAL.value == "modal" + assert SandboxVariantEnum.MONTY.value == "monty" def test_gpu_type_enum(self): """Test GPUType enum values.""" From d810efa7e2fcf30c0f95dc04a740b75b16d656e1 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Fri, 21 Aug 2026 07:32:45 +0200 Subject: [PATCH 5/7] bump --- code_sandboxes/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code_sandboxes/__version__.py b/code_sandboxes/__version__.py index 4fde1de..31f8550 100644 --- a/code_sandboxes/__version__.py +++ b/code_sandboxes/__version__.py @@ -3,4 +3,4 @@ """Code Sandboxes.""" -__version__ = "1.0.10" +__version__ = "1.1.0" From be263b91a51e6215f8d7916a8c31e1b7d48e908c Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Fri, 21 Aug 2026 07:41:46 +0200 Subject: [PATCH 6/7] fix: test --- CHANGELOG.md | 15 ++++++++++ README.md | 2 +- code_sandboxes/base.py | 19 ++----------- code_sandboxes/cli.py | 52 +++++++++++++++++------------------ code_sandboxes/manage.py | 19 ++++++------- code_sandboxes/models.py | 15 ++++++++++ code_sandboxes/providers.py | 10 +++---- docs/docs/cli/management.mdx | 4 +-- docs/docs/sandboxes/index.mdx | 7 +++-- pyproject.toml | 2 +- tests/test_factory.py | 26 ++++++++++++++++++ tests/test_manage.py | 10 +++++++ 12 files changed, 116 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b5a3e2..1216efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,18 @@ ## Unreleased +- Renamed the `google_colab` variant to `google-colab`, so every canonical + variant name is spelled the one way (`jupyter-server` already was). Any + spelling is still accepted everywhere a variant is named — `normalize_variant` + now folds to the canonical dashed form rather than to underscores, which is + what a dispatcher compares against, so the two can no longer drift apart. + - Added `code-sandboxes exec`, which runs one snippet in a fresh sandbox of any variant and exits with the status the code earned — `0` when it ran cleanly, `1` when it raised — so it composes in a shell. The code comes from an argument, from `--file`, or from standard input; `--quiet` prints only what the code produced. `exec` and `repl` take the same options. + - Moved the machinery for showing a run — `show_code`, `show_result`, `show_and_run`, `run_repl`, `repl_prompt` — into `code_sandboxes.console`, exported from the package. It existed three times over: in the CLI, in the @@ -20,6 +27,7 @@ of a trailing expression is shown, whether stderr is told apart from stdout, and which words end a session. The examples now import it like any other consumer, and `examples/*/[exec|repl]_common.py` are gone. + - Added the `daytona` sandbox variant (`DaytonaSandbox`), running code in a [Daytona](https://www.daytona.io/docs/) cloud sandbox. It drives the sandbox's code interpreter rather than `process.code_run`, so state persists @@ -31,6 +39,7 @@ `DAYTONA_JWT_TOKEN` with `DAYTONA_ORGANIZATION_ID`) and install with `pip install code-sandboxes[daytona]`. `get_manager("daytona")` answers the CRUD verbs over an organization's sandboxes. + - Added the `kaggle` sandbox variant (`KaggleSandbox`) to connect to a Kaggle interactive notebook runtime via `jupyter-kernel-client`'s `KaggleKernelClient`. Authenticate with a Kaggle API token (`token` argument or @@ -39,18 +48,24 @@ `server_url`/`kernel_id` or a notebook session `channels_url` (the signed JWT in the proxied URL provides the authentication). Install with `pip install code-sandboxes[kaggle]`. + - Enhanced `KaggleSandbox` with a transparent batch primitive: when no runtime connection details are provided, it automatically executes code through `KaggleKernelExecutor` (submit/poll/download) so integrations like `jupyter-mcp-server` can run on Kaggle without requiring interactive runtime wiring. + - Added Kaggle accelerator forwarding in batch mode: `Sandbox.create(variant="kaggle", gpu=...)` now passes the value to `KaggleKernelExecutor.execute(accelerator=...)`, supporting both Kaggle API values (`NvidiaTeslaT4`, ...) and friendly aliases (`T4`, `P100`, ...). + - Updated `ColabSandbox` to be reuse-only for existing Colab runtimes and added `channels_url` parsing support for extracting `server_url` / `kernel_id` / `proxy_token` directly from the Colab WebSocket channels URL. + - Breaking change: sandbox variant names are `eval`, `docker`, `jupyter`, and `datalayer`. + - Removed support for the older `local-*` variant names from the public API and documentation. + - Clarified in the documentation that `Sandbox.create()` defaults to `datalayer`. diff --git a/README.md b/README.md index 93eae4b..9708380 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Canonical variant names: - `daytona` - `docker` - `eval` -- `google_colab` +- `google-colab` - `jupyter-server` - `kaggle` - `modal` diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index 6ecdd20..b8ab02a 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -27,6 +27,7 @@ SandboxInfo, SandboxStatus, SandboxVariant, + normalize_variant, ) @@ -54,20 +55,6 @@ def generate_sandbox_name() -> str: return f"{colour}-{fake.word()}-{suffix}" -def normalize_variant(variant: SandboxVariant | str) -> str: - """One spelling of a variant, as every dispatcher of this package reads it. - - The value of a variant may carry a dash — `jupyter-server` — and a caller - types it with a dash, with an underscore, in capitals, or with the - whitespace a configuration file left around it. `get_manager` and - `get_provider` have always answered to all of those; the factory here - accepted one spelling alone, which made the front door of the package the - strictest thing in it. - """ - value = variant.value if isinstance(variant, SandboxVariant) else str(variant) - return value.strip().lower().replace("-", "_") - - #: The Datalayer environment used when a caller names none. Every cluster #: provides it; the previous default, `python-cpu-env`, does not exist on #: current deployments. @@ -309,7 +296,7 @@ def create( # noqa: C901 from .docker_sandbox import DockerSandbox sandbox = DockerSandbox(config=config, **kwargs) - elif variant_value == "jupyter_server": + elif variant_value == "jupyter-server": from .jupyter_server_sandbox import JupyterServerSandbox sandbox = JupyterServerSandbox(config=config, **kwargs) @@ -395,7 +382,7 @@ def list_environments( from .docker_sandbox import DockerSandbox return DockerSandbox.list_environments() - if variant_value == "jupyter_server": + if variant_value == "jupyter-server": from .jupyter_server_sandbox import JupyterServerSandbox return JupyterServerSandbox.list_environments() diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py index 65417c8..61645ca 100644 --- a/code_sandboxes/cli.py +++ b/code_sandboxes/cli.py @@ -20,23 +20,28 @@ from . import Sandbox from .console import run_repl, show_and_run, show_result from .manage import SandboxManagementError, get_manager, manageable_variants +from .models import normalize_variant app = typer.Typer(help="Code sandboxes: run a REPL, list, create and delete sandboxes.") console = Console() -_SUPPORTED_REPL_VARIANTS = { - "jupyter-server", - "docker", - "eval", - "monty", - "google-colab", - "google-colab", - "kaggle", - "modal", - "daytona", - "datalayer", -} +#: The variants code can be run in from here, by their canonical names. A +#: caller may spell one with an underscore or in capitals; `normalize_variant` +#: brings it back to one of these. +_SUPPORTED_RUN_VARIANTS = frozenset( + { + "datalayer", + "daytona", + "docker", + "eval", + "google-colab", + "jupyter-server", + "kaggle", + "modal", + "monty", + } +) @app.callback(invoke_without_command=True) @@ -47,23 +52,18 @@ def _root(ctx: typer.Context) -> None: def _resolve_variant(variant: str | None) -> str: - if variant: - selected = variant.strip().lower() - else: - selected = typer.prompt( + if not variant: + variant = typer.prompt( "Sandbox variant", default="jupyter-server", show_default=True, ) - selected = selected.strip().lower() - - if selected not in _SUPPORTED_REPL_VARIANTS: + selected = normalize_variant(variant) + if selected not in _SUPPORTED_RUN_VARIANTS: raise typer.BadParameter( - f"Unsupported variant: {selected}. Supported values: " - + ", ".join(sorted(_SUPPORTED_REPL_VARIANTS)) + f"Unsupported variant: {variant}. Supported values: " + + ", ".join(sorted(_SUPPORTED_RUN_VARIANTS)) ) - if selected == "google-colab": - return "google-colab" return selected @@ -78,7 +78,7 @@ def _resolve_variant_kwargs( ) -> dict[str, Any]: kwargs: dict[str, Any] = {} - if variant.strip().lower().replace("-", "_") == "jupyter_server": + if variant == "jupyter-server": # Match `jupyter console` behavior by launching local Jupyter on random port. kwargs["port"] = 0 @@ -125,7 +125,7 @@ def _resolve_variant_kwargs( "-v", help=( "Sandbox variant (datalayer, daytona, docker, eval, " - "google_colab/google-colab, jupyter-server, kaggle, modal, monty)." + "google-colab, jupyter-server, kaggle, modal, monty)." ), ) _RUN_TIMEOUT_OPTION = typer.Option(60.0, help="Code execution timeout (seconds).") @@ -382,7 +382,7 @@ def _add_sandbox_row(table: Table, info: Any) -> None: "-v", help="Sandbox variant (" + ", ".join(manageable_variants()) + ").", ) -_SERVER_URL_OPTION = typer.Option(None, help="Server URL (jupyter, google_colab).") +_SERVER_URL_OPTION = typer.Option(None, help="Server URL (jupyter, google-colab).") _TOKEN_OPTION = typer.Option(None, help="API token (jupyter, datalayer).") _PROXY_TOKEN_OPTION = typer.Option(None, help="Colab runtime proxy token.") _RUN_URL_OPTION = typer.Option(None, help="Datalayer run URL override.") diff --git a/code_sandboxes/manage.py b/code_sandboxes/manage.py index 742875f..e6795f1 100644 --- a/code_sandboxes/manage.py +++ b/code_sandboxes/manage.py @@ -42,7 +42,7 @@ from abc import ABC, abstractmethod from typing import Any -from .models import SandboxInfo, SandboxStatus +from .models import SandboxInfo, SandboxStatus, normalize_variant __all__ = [ "SandboxManagementError", @@ -849,10 +849,11 @@ def get_manager(variant: str, **kwargs: Any) -> SandboxManager: """The manager for a variant. Args: - variant: One of :func:`manageable_variants` (``google-colab`` is - accepted for ``google_colab``). + variant: One of :func:`manageable_variants`, in any spelling — + ``google-colab``, ``google_colab`` and ``Google Colab`` all name + the same one. **kwargs: Variant-specific connection settings — ``server_url`` / - ``token`` (jupyter), ``proxy_token`` (google_colab), ``app_name`` + ``token`` (jupyter), ``proxy_token`` (google-colab), ``app_name`` (modal), ``api_key`` / ``api_url`` / ``target`` (daytona), ``username`` (kaggle), ``token`` / ``run_url`` (datalayer), ``docker_client`` (docker). @@ -863,13 +864,9 @@ def get_manager(variant: str, **kwargs: Any) -> SandboxManager: Raises: ValueError: For an unknown variant. """ - normalized = variant.strip().lower().replace("-", "_") - # Keys carry the variant's own spelling — `jupyter-server` with its dash — - # and lookups arrive in either form: compare in one normal form. - manager_class = next( - (cls for key, cls in _MANAGERS.items() if key.replace("-", "_") == normalized), - None, - ) + # The keys are the canonical names, which is what `normalize_variant` + # answers with, so a lookup in any spelling lands on one of them. + manager_class = _MANAGERS.get(normalize_variant(variant)) if manager_class is None: raise ValueError( f"Unknown sandbox variant: {variant}. " diff --git a/code_sandboxes/models.py b/code_sandboxes/models.py index 0c478d4..72abe96 100644 --- a/code_sandboxes/models.py +++ b/code_sandboxes/models.py @@ -76,6 +76,21 @@ class SandboxVariant(str, Enum): MONTY = "monty" +def normalize_variant(variant: "SandboxVariant | str") -> str: + """One spelling of a variant, the canonical one. + + A caller types a variant with a dash, with an underscore, in capitals, or + with the whitespace a configuration file left around it, and every + dispatcher in this package has to answer to all of them. The normal form + is the value of the enum — `google-colab`, `jupyter-server` — so that what + a dispatcher compares against reads exactly like what a caller types and + what `SandboxVariant` holds. Folding the other way, to underscores, meant + the two drifted apart the first time a variant was renamed. + """ + value = variant.value if isinstance(variant, SandboxVariant) else str(variant) + return value.strip().lower().replace("_", "-") + + class GPUType(str, Enum): """Available GPU types for cloud sandboxes.""" diff --git a/code_sandboxes/providers.py b/code_sandboxes/providers.py index d6f24ea..1244669 100644 --- a/code_sandboxes/providers.py +++ b/code_sandboxes/providers.py @@ -27,7 +27,7 @@ from pathlib import Path from typing import Callable -from .models import SandboxEnvironment, SandboxVariant +from .models import SandboxEnvironment, SandboxVariant, normalize_variant __all__ = [ "PROVIDERS", @@ -280,11 +280,11 @@ def get_provider(name: str) -> SandboxProvider | None: Args: name: Identifier of the provider, which is that of its variant. """ - wanted = (name or "").replace("-", "_").lower() + # A provider is named by its variant, so the canonical name is what to + # compare against, whatever spelling the lookup arrived in. + wanted = normalize_variant(name or "") for provider in PROVIDERS: - # The value of a variant may carry a dash — `jupyter-server` — while - # lookups arrive in either spelling: compare in one normal form. - if provider.name.replace("-", "_") == wanted: + if provider.name == wanted: return provider return None diff --git a/docs/docs/cli/management.mdx b/docs/docs/cli/management.mdx index ba8510b..42bf7e0 100644 --- a/docs/docs/cli/management.mdx +++ b/docs/docs/cli/management.mdx @@ -59,7 +59,7 @@ below the table instead of hiding the ones that answered. from code_sandboxes import get_manager, manageable_variants print(manageable_variants()) -# ['datalayer', 'daytona', 'docker', 'eval', 'google_colab', 'jupyter-server', 'kaggle', 'modal', 'monty'] +# ['datalayer', 'daytona', 'docker', 'eval', 'google-colab', 'jupyter-server', 'kaggle', 'modal', 'monty'] manager = get_manager("modal") for info in manager.list(): @@ -88,7 +88,7 @@ get_manager("datalayer", token="...", run_url="https://...") | `daytona` | a sandbox of the Daytona organization | the labels | the Daytona sandbox | | `docker` | a container labelled `code-sandboxes` | the name | the container (forced) | | `eval`, `monty` | an object inside the creating process | — not supported | — not supported | -| `google_colab` | a kernel of the Colab runtime | — nothing in place | the kernel | +| `google-colab` | a kernel of the Colab runtime | — nothing in place | the kernel | | `jupyter-server` | a kernel of the Jupyter Server | — nothing in place | the kernel | | `kaggle` | a kernel on kaggle.com (batch mode creates one per run) | the code (a new version) | the kernel | | `modal` | a Modal sandbox of the `code-sandboxes` app | the tags | the Modal sandbox | diff --git a/docs/docs/sandboxes/index.mdx b/docs/docs/sandboxes/index.mdx index 003ba53..24e39bf 100644 --- a/docs/docs/sandboxes/index.mdx +++ b/docs/docs/sandboxes/index.mdx @@ -12,10 +12,11 @@ A sandbox is an isolated environment where code can be executed safely. Code San Use `Sandbox.create()` to create a new sandbox: Canonical variant names are `datalayer`, `daytona`, `docker`, `eval`, -`google_colab`, `jupyter-server`, `kaggle`, `modal`, and `monty`. Older `local-*` +`google-colab`, `jupyter-server`, `kaggle`, `modal`, and `monty`. Older `local-*` names are no longer supported. -The CLI also accepts `google-colab` as an alias for `google_colab`. +A name is read in whatever spelling it arrives in: `google-colab`, +`google_colab` and `Google Colab` all name the same variant. ```python from code_sandboxes import Sandbox @@ -67,7 +68,7 @@ Each page below explains how to configure each variant. | [`daytona`](./daytona) | Daytona cloud sandbox with a stateful interpreter | | [`docker`](./docker) | Jupyter execution in a Docker container | | [`eval`](./eval) | In-process `exec()` for fast development-only runs | -| [`google_colab`](./google-colab) | Google Colab runtime via runtime proxy | +| [`google-colab`](./google-colab) | Google Colab runtime via runtime proxy | | [`jupyter-server`](./jupyter-server) | Jupyter kernel-backed execution with persistent state | | [`kaggle`](./kaggle) | Kaggle runtime (interactive or batch) | | [`modal`](./modal) | Modal container execution | diff --git a/pyproject.toml b/pyproject.toml index aaa98a4..1cdd7fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ all = [ ] test = [ "ipykernel", - "jupyter_server>=1.6,<3", + "jupyter-server>=1.6,<3", "pytest>=7.0", "pytest-asyncio>=0.21", "pytest-cov>=4.0", diff --git a/tests/test_factory.py b/tests/test_factory.py index 5810ea0..28ca021 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -4,6 +4,8 @@ """Sandbox factory tests.""" +import warnings + import pytest from code_sandboxes.base import Sandbox, SandboxVariant @@ -95,6 +97,30 @@ def test_a_variant_is_read_in_any_spelling(self, variant): assert isinstance(Sandbox.create(variant=variant), JupyterServerSandbox) assert Sandbox.list_environments(variant=variant) + @pytest.mark.parametrize("variant", list(SandboxVariant)) + def test_every_variant_of_the_enum_can_be_created(self, variant): + """The enum and what the factory branches on cannot drift apart. + + They did: `google_colab` was renamed to `google-colab` in the enum and + in the branch, while the normalizer between them still folded dashes + to underscores — so the one variant that had just been renamed was the + one that could no longer be created. + """ + for spelling in ( + variant, + variant.value, + variant.value.replace("-", "_"), + variant.value.upper(), + f" {variant.value} ", + ): + assert Sandbox.create(variant=spelling) is not None + with warnings.catch_warnings(): + # Reaching the variant is what is under test. Some of them + # import a platform client on the way, which has deprecation + # notices of its own that are nothing to do with this. + warnings.simplefilter("ignore") + assert Sandbox.list_environments(variant=spelling) is not None + def test_a_name_that_is_not_a_variant_still_raises(self): """Reading loosely is not guessing: `jupyter` names nothing.""" with pytest.raises(ValueError, match="Unknown sandbox variant"): diff --git a/tests/test_manage.py b/tests/test_manage.py index c69ce84..61b8bd5 100644 --- a/tests/test_manage.py +++ b/tests/test_manage.py @@ -20,6 +20,16 @@ from code_sandboxes.models import SandboxInfo, SandboxStatus +def test_every_variant_of_the_enum_has_a_manager_in_any_spelling(): + """`manageable_variants` is the enum, and answers to how it is spelled.""" + from code_sandboxes.models import SandboxVariant + + for variant in SandboxVariant: + assert variant.value in manageable_variants() + for spelling in (variant.value, variant.value.replace("-", "_")): + assert get_manager(spelling).variant == variant.value + + def test_every_variant_has_a_manager(): assert manageable_variants() == [ "datalayer", From b9dbd4c52b0d6191b66bddeebecc28196224bdec Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Fri, 21 Aug 2026 07:52:09 +0200 Subject: [PATCH 7/7] daytona: spot gpu --- CHANGELOG.md | 12 ++ code_sandboxes/cli.py | 78 +++++++++--- code_sandboxes/daytona_sandbox.py | 146 +++++++++++++++++++---- docs/docs/cli/index.mdx | 6 +- docs/docs/sandboxes/daytona.mdx | 69 ++++++++++- examples/exec/Makefile | 9 +- examples/exec/daytona_sandbox_example.py | 53 ++++++-- examples/repl/Makefile | 9 +- examples/repl/daytona_sandbox_example.py | 22 +++- tests/test_daytona.py | 134 ++++++++++++++++++++- 10 files changed, 477 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1216efd..f5ecb4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ ## Unreleased +- Added GPU support to the `daytona` variant. `gpu=` takes Daytona's own + flavors, `gpu_count=` how many, and several names comma-separated are an + ordered list of preferences Daytona falls back along — `gpu="H100,H200"` + takes an H200 when no H100 is free. `spot=True` runs on preemptible + capacity, which is far cheaper and outside the GPU quota; it is GPU-only and + built from an image with `auto_delete_interval=0`, both checked before the + request rather than left to come back as an API error. + `DaytonaSandbox.preempted_at()` answers when a spot sandbox was reclaimed, + and `run_code` asks on your behalf so that an eviction is not reported as a + dropped connection. `code-sandboxes exec/repl --spot` reaches it from the + CLI. + - Renamed the `google_colab` variant to `google-colab`, so every canonical variant name is spelled the one way (`jupyter-server` already was). Any spelling is still accepted everywhere a variant is named — `normalize_variant` diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py index 61645ca..2651716 100644 --- a/code_sandboxes/cli.py +++ b/code_sandboxes/cli.py @@ -67,6 +67,42 @@ def _resolve_variant(variant: str | None) -> str: return selected +def _colab_kwargs( + server_url: str | None, kernel_id: str | None, proxy_token: str | None +) -> dict[str, Any]: + """What a Colab runtime needs, asked for whatever was not given.""" + return { + "server_url": server_url or typer.prompt("Colab runtime URL (RUNTIME_URL)"), + "kernel_id": kernel_id or typer.prompt("Colab kernel id (RUNTIME_ID)"), + "proxy_token": proxy_token + or typer.prompt( + "Colab runtime proxy token (RUNTIME_PROXY_TOKEN)", + hide_input=True, + ), + } + + +def _kaggle_kwargs( + server_url: str | None, kernel_id: str | None, token: str | None +) -> dict[str, Any]: + """What a Kaggle runtime needs; the kernel is optional, the URL is not.""" + kwargs: dict[str, Any] = { + "server_url": server_url or typer.prompt("Kaggle runtime proxy URL (RUNTIME_URL)"), + } + # kernel_id is optional: leave empty to create a new kernel (needs a token). + resolved_kernel_id = kernel_id or typer.prompt( + "Kaggle kernel id (RUNTIME_ID, leave empty to create a new kernel)", + default="", + show_default=False, + ) + if resolved_kernel_id: + kwargs["kernel_id"] = resolved_kernel_id + resolved_token = token or os.environ.get("KAGGLE_API_TOKEN") + if resolved_token: + kwargs["token"] = resolved_token + return kwargs + + def _resolve_variant_kwargs( variant: str, server_url: str | None, @@ -75,6 +111,7 @@ def _resolve_variant_kwargs( token: str | None, run_url: str | None, gpu: str | None, + spot: bool = False, ) -> dict[str, Any]: kwargs: dict[str, Any] = {} @@ -83,26 +120,10 @@ def _resolve_variant_kwargs( kwargs["port"] = 0 if variant == "google-colab": - kwargs["server_url"] = server_url or typer.prompt("Colab runtime URL (RUNTIME_URL)") - kwargs["kernel_id"] = kernel_id or typer.prompt("Colab kernel id (RUNTIME_ID)") - kwargs["proxy_token"] = proxy_token or typer.prompt( - "Colab runtime proxy token (RUNTIME_PROXY_TOKEN)", - hide_input=True, - ) + kwargs.update(_colab_kwargs(server_url, kernel_id, proxy_token)) if variant == "kaggle": - kwargs["server_url"] = server_url or typer.prompt("Kaggle runtime proxy URL (RUNTIME_URL)") - # kernel_id is optional: leave empty to create a new kernel (needs a token). - resolved_kernel_id = kernel_id or typer.prompt( - "Kaggle kernel id (RUNTIME_ID, leave empty to create a new kernel)", - default="", - show_default=False, - ) - if resolved_kernel_id: - kwargs["kernel_id"] = resolved_kernel_id - resolved_token = token or os.environ.get("KAGGLE_API_TOKEN") - if resolved_token: - kwargs["token"] = resolved_token + kwargs.update(_kaggle_kwargs(server_url, kernel_id, token)) if variant == "datalayer": if token: @@ -113,6 +134,11 @@ def _resolve_variant_kwargs( if variant in {"modal", "daytona", "datalayer", "kaggle"} and gpu: kwargs["gpu"] = gpu + if spot: + if variant != "daytona": + raise typer.BadParameter(f"--spot is a daytona option; {variant} has none.") + kwargs["spot"] = True + return kwargs @@ -148,6 +174,14 @@ def _resolve_variant_kwargs( "-q", help="Print only what the code produced — no banner, no echo of the code.", ) +_RUN_SPOT_OPTION = typer.Option( + False, + "--spot", + help=( + "Run on preemptible GPU capacity (daytona): far cheaper and outside " + "the GPU quota, and reclaimed without warning. Needs --gpu." + ), +) _RUN_GPU_OPTION = typer.Option( None, "--gpu", @@ -173,6 +207,7 @@ def _started_sandbox( token: str | None = None, run_url: str | None = None, gpu: str | None = None, + spot: bool = False, ) -> Iterator[Sandbox]: """A started sandbox of the variant asked for, gone again on the way out. @@ -193,6 +228,7 @@ def _started_sandbox( token=token, run_url=run_url, gpu=gpu, + spot=spot, ) if announce: console.print(f"Starting sandbox variant: {selected}", style="cyan") @@ -231,6 +267,7 @@ def repl( token: str | None = _RUN_TOKEN_OPTION, run_url: str | None = _RUN_RUN_URL_OPTION, gpu: str | None = _RUN_GPU_OPTION, + spot: bool = _RUN_SPOT_OPTION, ) -> None: """Open an interactive prompt on a sandbox of the selected variant. @@ -247,6 +284,7 @@ def repl( token=token, run_url=run_url, gpu=gpu, + spot=spot, ) @@ -260,6 +298,7 @@ def _run_repl( token: str | None = None, run_url: str | None = None, gpu: str | None = None, + spot: bool = False, ) -> None: with _started_sandbox( variant, @@ -271,6 +310,7 @@ def _run_repl( token=token, run_url=run_url, gpu=gpu, + spot=spot, ) as sandbox: run_repl(sandbox, console=console) @@ -304,6 +344,7 @@ def exec_code( token: str | None = _RUN_TOKEN_OPTION, run_url: str | None = _RUN_RUN_URL_OPTION, gpu: str | None = _RUN_GPU_OPTION, + spot: bool = _RUN_SPOT_OPTION, ) -> None: """Run one snippet in a fresh sandbox and print what it produced. @@ -325,6 +366,7 @@ def exec_code( token=token, run_url=run_url, gpu=gpu, + spot=spot, ) as sandbox: if quiet: result = sandbox.run_code(snippet) diff --git a/code_sandboxes/daytona_sandbox.py b/code_sandboxes/daytona_sandbox.py index 87757da..0f50a94 100644 --- a/code_sandboxes/daytona_sandbox.py +++ b/code_sandboxes/daytona_sandbox.py @@ -22,6 +22,7 @@ from __future__ import annotations import ast +import contextlib import json import logging import math @@ -180,27 +181,39 @@ def flush(self) -> list[str]: return [rest] if rest else [] -def _gpu_type(flavor: str, daytona: Any) -> Any: - """The Daytona GPU of that name, or a refusal naming the ones there are. +def _gpu_types(flavors: str, daytona: Any) -> list[Any]: + """The Daytona GPUs named, in the order they are preferred. + + One name asks for one GPU; several, comma-separated, ask for the first of + them that Daytona can find — `"H100,H200,RTX-4090"` takes an H100 when + there is one and an H200 when there is not, rather than failing. That is + Daytona's own fallback, and it matters most for spot capacity, where what + is free changes minute to minute. The flavours differ from one provider to the next — a `T4` is Modal's vocabulary, not Daytona's — so a name that means nothing here is said so at once, rather than reaching the API as an invalid enum. """ - wanted = flavor.strip().upper().replace("_", "-") offered = [ candidate for candidate in daytona.GpuType if not candidate.value.lower().startswith("unknown") ] - for candidate in offered: - if candidate.value.upper() == wanted: - return candidate - raise SandboxConfigurationError( - f"Daytona has no GPU called {flavor!r}. It offers: " - + ", ".join(candidate.value for candidate in offered) - + "." - ) + by_name = {candidate.value.upper(): candidate for candidate in offered} + wanted: list[Any] = [] + for flavor in flavors.split(","): + name = flavor.strip().upper().replace("_", "-") + if not name: + continue + if name not in by_name: + raise SandboxConfigurationError( + f"Daytona has no GPU called {flavor.strip()!r}. It offers: " + + ", ".join(candidate.value for candidate in offered) + + "." + ) + if by_name[name] not in wanted: + wanted.append(by_name[name]) + return wanted def _import_daytona() -> Any: @@ -232,6 +245,12 @@ class DaytonaSandbox(Sandbox): image, so asking for any of them builds one when none is given. python_version: Python of that image. Daytona's own default when omitted. + gpu_count: How many GPUs to attach when one is asked for. + spot: Whether to run on PREEMPTIBLE GPU capacity. Much cheaper than + on-demand and outside the organization's GPU quota, at the price + of being reclaimed without warning at any moment — see + :meth:`preempted_at`. GPU-only, and Daytona refuses it for a + sandbox that asks for none. delete_on_stop: Whether :meth:`stop` DELETES the sandbox, which is the default and what a ``with`` block should do, or merely stops it — leaving it in the organization, to be started again. @@ -248,6 +267,8 @@ def __init__( snapshot: str | None = None, image: Any | None = None, python_version: str | None = None, + gpu_count: int = 1, + spot: bool = False, delete_on_stop: bool = True, **kwargs, ): @@ -260,6 +281,8 @@ def __init__( self._snapshot = snapshot self._image = image self._python_version = python_version + self._gpu_count = gpu_count + self._spot = spot self._delete_on_stop = delete_on_stop self._daytona: Any | None = None self._sandbox: Any | None = None @@ -275,10 +298,12 @@ def list_environments(cls) -> list[SandboxEnvironment]: """The environments this provider ships. Daytona takes a machine specification per sandbox rather than a - catalogue of named ones, so what is offered here are the two shapes - worth naming — a plain sandbox, and one with a GPU attached — and - choosing an environment stays what it is everywhere else: choosing - between named things. + catalogue of named ones, so what is offered here are the shapes worth + naming — a plain sandbox, one with a GPU, and one on the preemptible + capacity a GPU can be had cheaply on — and choosing an environment + stays what it is everywhere else: choosing between named things. + + The shape is asked for by argument, not by name: `gpu=` and `spot=`. """ return [ SandboxEnvironment( @@ -297,7 +322,16 @@ def list_environments(cls) -> list[SandboxEnvironment]: owner="daytona", visibility="cloud", burning_rate=0.0, - metadata={"variant": "daytona", "gpu": "H100"}, + metadata={"variant": "daytona", "gpu": "H100", "spot": False}, + ), + SandboxEnvironment( + name="daytona-gpu-spot", + title="Daytona GPU (spot)", + language="python", + owner="daytona", + visibility="cloud", + burning_rate=0.0, + metadata={"variant": "daytona", "gpu": "H100", "spot": True}, ), ] @@ -320,11 +354,13 @@ def start(self) -> None: "daytona_sandbox_id": self._sandbox.id, "snapshot": getattr(self._sandbox, "snapshot", None), "target": getattr(self._sandbox, "target", None), + "spot": bool(getattr(self._sandbox, "spot", self._spot)), }, resources=ResourceConfig( cpu=getattr(self._sandbox, "cpu", None), memory=getattr(self._sandbox, "memory", None), gpu=getattr(self._sandbox, "gpu_type", None), + gpu_count=getattr(self._sandbox, "gpu", None) or 1, ), config=self.config, ) @@ -359,6 +395,8 @@ def _create_params(self, daytona: Any) -> Any: common.update(self._network_params()) resources = self._resources(daytona) + if self._spot: + common.update(self._spot_params(resources)) if self._image is not None or resources is not None: image = self._image if image is None: @@ -370,6 +408,29 @@ def _create_params(self, daytona: Any) -> Any: return daytona.CreateSandboxFromImageParams(image=image, resources=resources, **common) return daytona.CreateSandboxFromSnapshotParams(snapshot=self._snapshot, **common) + 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. + """ + if resources is None or getattr(resources, "gpu", None) in (None, 0): + raise SandboxConfigurationError( + "spot=True asks for preemptible GPU capacity, so it needs a " + "GPU: pass gpu=... (for example gpu='H100', or " + "gpu='H100,H200' to fall back to the second when the first " + "is unavailable)." + ) + if self._snapshot is not None: + raise SandboxConfigurationError( + "spot=True builds from an image, which is what carries a " + "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} + def _labels(self) -> dict[str, str]: """The metadata the sandbox carries in Daytona. @@ -409,14 +470,17 @@ def _resources(self, daytona: Any) -> Any | None: memory = None if self.config.memory_limit: memory = max(1, math.ceil(self.config.memory_limit / 1024**3)) - gpu_type = _gpu_type(self.config.gpu, daytona) if self.config.gpu else None - if cpu is None and memory is None and gpu_type is None: + gpu_types = _gpu_types(self.config.gpu, daytona) if self.config.gpu else [] + if cpu is None and memory is None and not gpu_types: return None return daytona.Resources( cpu=cpu, memory=memory, - gpu=1 if gpu_type is not None else None, - gpu_type=gpu_type, + gpu=max(1, self._gpu_count) if gpu_types else None, + # One name goes as one name and several as the ordered list + # Daytona falls back along; a list of one would work too, but the + # simple case should read like the simple case in the request. + gpu_type=gpu_types[0] if len(gpu_types) == 1 else (gpu_types or None), ) def stop(self) -> None: @@ -437,6 +501,46 @@ def stop(self) -> None: if self._info: self._info.status = SandboxStatus.STOPPED + def preempted_at(self) -> str | None: + """When this spot sandbox was reclaimed, or `None` while it still runs. + + Daytona takes preemptible capacity back without warning — no signal, + no webhook — so the only way to know is to ask, which this does + freshly rather than from the record the sandbox was made with. A + reclaimed sandbox stays retrievable for 24 hours, which is long + enough to find out what became of it. + + Always `None` for a sandbox that is not on spot capacity: there is + nothing that would reclaim it. + """ + if self._sandbox is None: + return None + with contextlib.suppress(Exception): + # A sandbox that has just gone away cannot always be asked; the + # answer then is whatever was last known, which is honest. + self._sandbox.refresh_data() + evicted = getattr(self._sandbox, "spot_evicted_at", None) + return str(evicted) if evicted else None + + def _failure_reason(self, error: Exception) -> str: + """Why the execution did not happen, named as closely as it can be. + + A reclaimed spot sandbox fails the way a dropped connection does, and + a caller reading "the websocket closed" has no way to tell that it was + outbid rather than broken. Asking costs a round trip, so it is only + asked of a sandbox that could have been reclaimed at all. + """ + if self._spot: + evicted = self.preempted_at() + if evicted: + return ( + f"The spot sandbox was reclaimed at {evicted}: preemptible " + "capacity is taken back when on-demand capacity needs it. " + "Run this again on a new sandbox, or ask for on-demand " + "capacity with spot=False." + ) + return f"Failed to execute code on Daytona: {error}" + def _interpreter_context(self, context: Context | None) -> Any | None: """The Daytona context one of ours stands for, made on first use. @@ -519,7 +623,7 @@ def feed_stderr(chunk: Any) -> None: except Exception as error: return ExecutionResult( execution_ok=False, - execution_error=f"Failed to execute code on Daytona: {error}", + execution_error=self._failure_reason(error), started_at=started_at, completed_at=time.time(), context_id=context.id if context else "default", diff --git a/docs/docs/cli/index.mdx b/docs/docs/cli/index.mdx index bfb66d1..dc65dd3 100644 --- a/docs/docs/cli/index.mdx +++ b/docs/docs/cli/index.mdx @@ -86,12 +86,14 @@ Supported variants: - `monty` `exec` and `repl` take the same options: `--variant`, `--timeout`, -`--environment`, `--gpu`, and the connection settings a variant needs. +`--environment`, `--gpu`, `--spot`, and the connection settings a variant +needs. ## Variant-specific Behavior - `daytona`: starts a Daytona sandbox; `--gpu` takes Daytona's own flavors - (`H100`, `H200`, `RTX-4090`, ...). + (`H100`, `H200`, `RTX-4090`, ...), several of them comma-separated to fall + back along, and `--spot` runs on preemptible capacity. - `google-colab`: prompts for runtime URL, kernel ID, and proxy token. - `jupyter-server`: starts a managed local Jupyter server on a random port. - `kaggle`: supports either interactive runtime settings or credential-based batch execution. diff --git a/docs/docs/sandboxes/daytona.mdx b/docs/docs/sandboxes/daytona.mdx index ed7c211..7c91723 100644 --- a/docs/docs/sandboxes/daytona.mdx +++ b/docs/docs/sandboxes/daytona.mdx @@ -19,7 +19,8 @@ sandbox.run_code("x + 2").text # "42" - **Requirements:** `code-sandboxes[daytona]` (installs `daytona`). - **Parameters:** `api_key`, `api_url`, `target`, `jwt_token`, - `organization_id`, `snapshot`, `image`, `python_version`, `delete_on_stop`. + `organization_id`, `snapshot`, `image`, `python_version`, `gpu_count`, + `spot`, `delete_on_stop`. ## How To Obtain Daytona Credentials @@ -77,6 +78,15 @@ created, naming the ones it does: ```python Sandbox.create(variant="daytona", gpu="H100") +Sandbox.create(variant="daytona", gpu="H100", gpu_count=2) +``` + +Name several, comma-separated, to say which you would **prefer**. Daytona takes +the first of them it can find, so a workload that runs on either does not fail +because the better card is busy: + +```python +Sandbox.create(variant="daytona", gpu="H100,H200,RTX-4090") ``` A GPU — like any request for `cpu` or `memory` — is a machine specification, @@ -86,6 +96,63 @@ the default snapshot, and takes longer to come up. Pass `image=` to choose that image yourself, or `snapshot=` to start from a snapshot of your organization when you need no specification. +### Spot GPUs + +`spot=True` runs on **preemptible** capacity: far cheaper than on-demand, and +outside your organization's GPU quota — available capacity is the only limit. +The price is that Daytona can take the sandbox back at any moment, without +warning, when on-demand capacity needs it. + +```python +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. + +An ordered list of GPUs is worth more on spot than anywhere else — what is +free changes minute to minute. + +#### Surviving preemption + +There is no warning and no webhook. What there is, is a timestamp: a reclaimed +sandbox is marked and stays retrievable for 24 hours. Ask it directly: + +```python +if sandbox.preempted_at(): + print("reclaimed, start again elsewhere") +``` + +You rarely have to. A reclaimed sandbox fails the way a dropped connection +does, so `run_code` asks on your behalf and says which it was: + +```python +result = sandbox.run_code("train()") +if not result.execution_ok: + print(result.execution_error) + # "The spot sandbox was reclaimed at 2026-08-21T10:00:00Z: preemptible + # capacity is taken back when on-demand capacity needs it. ..." +``` + +The question costs a round trip, so it is only asked of a sandbox that could +have been reclaimed at all. + +#### Falling back to on-demand + +When there is no spot capacity, creation **fails immediately** — there is no +queue to wait in. Falling back is left to you, deliberately: on-demand costs +more and counts against your quota, and that is not a decision to make quietly +on your behalf. + +```python +try: + sandbox = Sandbox.create(variant="daytona", gpu="H100", spot=True) +except Exception: + sandbox = Sandbox.create(variant="daytona", gpu="H100") +``` + ### Network policy The policy of the configuration becomes Daytona's own network settings: diff --git a/examples/exec/Makefile b/examples/exec/Makefile index 166c63f..c8f56a3 100644 --- a/examples/exec/Makefile +++ b/examples/exec/Makefile @@ -2,7 +2,7 @@ PYTHON ?= python -.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu modal modal-gpu daytona daytona-gpu datalayer +.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu modal modal-gpu daytona daytona-gpu daytona-gpu-spot datalayer all: eval docker jupyter-server monty google-colab modal daytona datalayer @@ -55,5 +55,12 @@ daytona-gpu: ## Daytona example on a GPU (DAYTONA_GPU flavor, default H100) @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-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 + datalayer: $(PYTHON) datalayer_sandbox_example.py diff --git a/examples/exec/daytona_sandbox_example.py b/examples/exec/daytona_sandbox_example.py index f24bacd..d1a5aed 100644 --- a/examples/exec/daytona_sandbox_example.py +++ b/examples/exec/daytona_sandbox_example.py @@ -13,6 +13,10 @@ GPU options: - pass `--gpu H100` (or H200/RTX-4090/RTX-5090/RTX-PRO-6000), or - set DAYTONA_GPU in the environment. +- name several — `--gpu H100,H200` — to fall back to the second when the + first is unavailable. +- add `--spot` for preemptible capacity: far cheaper, outside the GPU quota, + and reclaimed without warning. The run below says which happened. Asking for a GPU asks for a machine specification, and Daytona takes one only when the sandbox is built from an IMAGE — so a GPU run starts from a Debian @@ -36,7 +40,20 @@ def _parse_args() -> argparse.Namespace: parser.add_argument( "--gpu", default=os.environ.get("DAYTONA_GPU"), - help="Optional GPU (for example: H100, H200, RTX-4090).", + help=( + "Optional GPU (for example: H100, H200, RTX-4090). Several, " + "comma-separated, are an ordered list of preferences Daytona " + "falls back along." + ), + ) + parser.add_argument( + "--spot", + action="store_true", + default=bool(os.environ.get("DAYTONA_SPOT")), + help=( + "Run on preemptible GPU capacity: far cheaper and outside the GPU " + "quota, and reclaimed without warning. Needs --gpu." + ), ) return parser.parse_args() @@ -61,6 +78,22 @@ def _gpu_probe_code() -> str: """ +def _verify_gpu(sandbox, 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 + # device listed. + if "nvidia-smi present" not in probe: + raise RuntimeError("GPU requested but nvidia-smi is missing in the sandbox.") + if "devices NONE" in probe or "GPU-PROBE: devices" not in probe: + raise RuntimeError("GPU requested but no device is listed by nvidia-smi.") + print(f"GPU verified: {gpu} is present.") + 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") + + def main() -> None: args = _parse_args() if not _has_daytona_auth(): @@ -70,12 +103,16 @@ def main() -> None: raise SystemExit(1) if args.gpu: - print(f"Launching daytona sandbox with GPU: {args.gpu}") + capacity = "spot (preemptible)" if args.spot else "on-demand" + print(f"Launching daytona sandbox with GPU: {args.gpu} on {capacity} capacity") + elif args.spot: + print("--spot needs --gpu: preemptible capacity is GPU capacity.") + raise SystemExit(1) else: print("Launching daytona sandbox without GPU.") try: - with Sandbox.create(variant="daytona", timeout=60, gpu=args.gpu) as sandbox: + with Sandbox.create(variant="daytona", timeout=60, gpu=args.gpu, spot=args.spot) as sandbox: print(f"Sandbox: {sandbox.sandbox_id}") # What tells this variant apart from a per-snippet runner: the @@ -96,15 +133,7 @@ def main() -> None: print("file round trip:", sandbox.files.read_bytes("/tmp/hello.bin")) if args.gpu: - gpu_result = show_and_run(sandbox, _gpu_probe_code()) - probe = gpu_result.stdout.strip() - # A GPU run must PROVE the GPU: the driver present, and at - # least one device listed. - if "nvidia-smi present" not in probe: - raise RuntimeError("GPU requested but nvidia-smi is missing in the sandbox.") - if "devices NONE" in probe or "GPU-PROBE: devices" not in probe: - raise RuntimeError("GPU requested but no device is listed by nvidia-smi.") - print(f"GPU verified: {args.gpu} is present.") + _verify_gpu(sandbox, 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/repl/Makefile b/examples/repl/Makefile index 7820189..84b86b9 100644 --- a/examples/repl/Makefile +++ b/examples/repl/Makefile @@ -2,7 +2,7 @@ PYTHON ?= python -.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu kaggle-live kaggle-gpu-live modal modal-gpu daytona daytona-gpu datalayer +.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu kaggle-live kaggle-gpu-live modal modal-gpu daytona daytona-gpu daytona-gpu-spot datalayer all: eval docker jupyter-server monty google-colab modal daytona datalayer @@ -59,5 +59,12 @@ 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-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 + datalayer: $(PYTHON) datalayer_sandbox_example.py diff --git a/examples/repl/daytona_sandbox_example.py b/examples/repl/daytona_sandbox_example.py index 787501f..cd43d7b 100644 --- a/examples/repl/daytona_sandbox_example.py +++ b/examples/repl/daytona_sandbox_example.py @@ -31,7 +31,20 @@ def _parse_args() -> argparse.Namespace: parser.add_argument( "--gpu", default=os.environ.get("DAYTONA_GPU"), - help="Optional GPU (for example: H100, H200, RTX-4090).", + help=( + "Optional GPU (for example: H100, H200, RTX-4090). Several, " + "comma-separated, are an ordered list of preferences Daytona " + "falls back along." + ), + ) + parser.add_argument( + "--spot", + action="store_true", + default=bool(os.environ.get("DAYTONA_SPOT")), + help=( + "Run on preemptible GPU capacity: far cheaper and outside the GPU " + "quota, and reclaimed without warning. Needs --gpu." + ), ) parser.add_argument( "--keep", @@ -53,7 +66,11 @@ def main() -> None: raise SystemExit(1) if args.gpu: - print(f"Launching daytona sandbox REPL with GPU: {args.gpu}") + capacity = "spot (preemptible)" if args.spot else "on-demand" + print(f"Launching daytona sandbox REPL with GPU: {args.gpu} on {capacity}") + elif args.spot: + print("--spot needs --gpu: preemptible capacity is GPU capacity.") + raise SystemExit(1) else: print("Launching daytona sandbox REPL without GPU.") @@ -62,6 +79,7 @@ def main() -> None: variant="daytona", timeout=60, gpu=args.gpu, + spot=args.spot, delete_on_stop=not args.keep, ) as sandbox: print(f"Sandbox: {sandbox.sandbox_id}") diff --git a/tests/test_daytona.py b/tests/test_daytona.py index 6b3bec5..d262169 100644 --- a/tests/test_daytona.py +++ b/tests/test_daytona.py @@ -109,6 +109,13 @@ def __init__(self) -> None: self.fs = _FakeFilesystem() self.deleted = False self.stopped = False + self.spot_evicted_at = None + #: How often the record was re-read. Asking Daytona is a round trip, + #: so a test can say when one was worth making. + self.refreshes = 0 + + def refresh_data(self): + self.refreshes += 1 def delete(self): self.deleted = True @@ -436,12 +443,30 @@ def test_the_name_travels_as_a_label_not_as_daytonas_name(): def test_a_gpu_daytona_does_not_have_is_refused_by_name(): daytona = pytest.importorskip("daytona") - from code_sandboxes.daytona_sandbox import _gpu_type + from code_sandboxes.daytona_sandbox import _gpu_types with pytest.raises(SandboxConfigurationError, match="no GPU called 'T4'"): - _gpu_type("T4", daytona) + _gpu_types("T4", daytona) + + assert _gpu_types("h100", daytona) == [daytona.GpuType.H100] + + +def test_several_gpus_are_an_ordered_list_of_preferences(): + """Daytona takes the first of them it can find, which is the point.""" + daytona = pytest.importorskip("daytona") + from code_sandboxes.daytona_sandbox import _gpu_types - assert _gpu_type("h100", daytona) == daytona.GpuType.H100 + assert _gpu_types("H100, rtx_4090 ,H200", daytona) == [ + daytona.GpuType.H100, + daytona.GpuType.RTX_4090, + daytona.GpuType.H200, + ] + # A name repeated is still one preference, in the place it was first named. + assert _gpu_types("H100,H100", daytona) == [daytona.GpuType.H100] + # And one bad name in a list is still a refusal: falling back silently to + # the rest would hand out a GPU nobody asked for. + with pytest.raises(SandboxConfigurationError, match="no GPU called 'T4'"): + _gpu_types("H100,T4", daytona) def test_resources_are_only_asked_for_when_the_configuration_says_so(): @@ -455,6 +480,108 @@ def test_resources_are_only_asked_for_when_the_configuration_says_so(): assert resources.gpu_type == daytona.GpuType.H100 +def test_spot_asks_for_exactly_what_the_documented_example_does(): + """Preemptible capacity: an image, `spot`, and no lingering afterwards.""" + daytona = pytest.importorskip("daytona") + + params = _started(SandboxConfig(gpu="H100,H200"), spot=True, gpu_count=2)._create_params( + daytona + ) + + assert isinstance(params, daytona.CreateSandboxFromImageParams) + assert params.spot is True + # Mandatory for spot: a reclaimed sandbox does not hang about. + assert params.auto_delete_interval == 0 + assert params.resources.gpu == 2 + assert params.resources.gpu_type == [daytona.GpuType.H100, daytona.GpuType.H200] + + +def test_one_gpu_goes_as_one_name_not_as_a_list_of_one(): + daytona = pytest.importorskip("daytona") + + resources = _started(SandboxConfig(gpu="H100"))._resources(daytona) + + assert resources.gpu_type == daytona.GpuType.H100 + + +def test_spot_without_a_gpu_is_refused_with_the_reason(): + """Daytona rejects it too; saying so here costs no round trip.""" + daytona = pytest.importorskip("daytona") + + with pytest.raises(SandboxConfigurationError, match="needs a GPU"): + _started(SandboxConfig(), spot=True)._create_params(daytona) + + +def test_spot_from_a_snapshot_is_refused(): + """Only an image carries a machine specification, and spot needs one.""" + daytona = pytest.importorskip("daytona") + + sandbox = _started(SandboxConfig(gpu="H100"), spot=True, snapshot="my-snapshot") + with pytest.raises(SandboxConfigurationError, match="snapshot"): + sandbox._create_params(daytona) + + +def test_a_sandbox_that_was_not_asked_for_on_spot_does_not_say_spot(): + daytona = pytest.importorskip("daytona") + + params = _started(SandboxConfig(gpu="H100"))._create_params(daytona) + + assert params.spot is None + + +def test_being_reclaimed_is_reported_as_being_reclaimed(): + """A dropped connection and an eviction read alike without asking.""" + sandbox = _started(SandboxConfig(gpu="H100"), spot=True) + sandbox._sandbox.spot_evicted_at = "2026-08-21T10:00:00Z" + + def gone(*_args, **_kwargs): + raise RuntimeError("websocket closed") + + sandbox._sandbox.code_interpreter.run_code = gone + result = sandbox.run_code("1 + 1") + + assert not result.execution_ok + assert "reclaimed at 2026-08-21T10:00:00Z" in (result.execution_error or "") + + +def test_a_failure_that_is_not_an_eviction_is_still_reported_as_itself(): + sandbox = _started(SandboxConfig(gpu="H100"), spot=True) + + def gone(*_args, **_kwargs): + raise RuntimeError("websocket closed") + + sandbox._sandbox.code_interpreter.run_code = gone + result = sandbox.run_code("1 + 1") + + assert "websocket closed" in (result.execution_error or "") + assert "reclaimed" not in (result.execution_error or "") + + +def test_a_sandbox_on_demand_is_never_asked_whether_it_was_reclaimed(): + """The question costs a round trip and has one answer for on-demand.""" + sandbox = _started(SandboxConfig()) + + def gone(*_args, **_kwargs): + raise RuntimeError("websocket closed") + + sandbox._sandbox.code_interpreter.run_code = gone + result = sandbox.run_code("1 + 1") + + assert "websocket closed" in (result.execution_error or "") + assert sandbox._sandbox.refreshes == 0 + + +def test_the_eviction_is_read_freshly_rather_than_from_the_old_record(): + """It happened after the sandbox was made, so the copy it holds is stale.""" + sandbox = _started(SandboxConfig(gpu="H100"), spot=True) + + assert sandbox.preempted_at() is None + assert sandbox._sandbox.refreshes == 1 + + sandbox._sandbox.spot_evicted_at = "2026-08-21T10:00:00Z" + assert sandbox.preempted_at() == "2026-08-21T10:00:00Z" + + def test_asking_for_resources_creates_from_an_image(): """Daytona takes a machine specification with an image, not a snapshot.""" daytona = pytest.importorskip("daytona") @@ -485,6 +612,7 @@ def test_the_variant_is_registered_everywhere_a_variant_is_named(): assert [env.name for env in Sandbox.list_environments(variant="daytona")] == [ "daytona-default", "daytona-gpu", + "daytona-gpu-spot", ] assert get_provider("daytona") is not None assert "daytona" in manageable_variants()