From aaf0c4025390fb19332620877bc1c0083cf920a5 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Wed, 19 Aug 2026 13:33:15 +0200 Subject: [PATCH 1/5] providers --- code_sandboxes/__init__.py | 13 ++ code_sandboxes/kaggle_sandbox.py | 24 +++- code_sandboxes/modal_sandbox.py | 24 +++- code_sandboxes/providers.py | 233 +++++++++++++++++++++++++++++++ 4 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 code_sandboxes/providers.py diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index 5d69b37..a6a368f 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -121,10 +121,23 @@ SnapshotInfo, TunnelInfo, ) +from .providers import ( + PROVIDERS, + ProviderRequirement, + SandboxProvider, + available_providers, + get_provider, +) from .monty_sandbox import MontySandbox __all__ = [ "KAGGLE_API_TOKEN_ENV", + # Providers + "PROVIDERS", + "ProviderRequirement", + "SandboxProvider", + "available_providers", + "get_provider", # Models "CodeError", "CodeExecutionOutcome", diff --git a/code_sandboxes/kaggle_sandbox.py b/code_sandboxes/kaggle_sandbox.py index 4c398d0..1e8713d 100644 --- a/code_sandboxes/kaggle_sandbox.py +++ b/code_sandboxes/kaggle_sandbox.py @@ -97,16 +97,32 @@ def __init__( @classmethod def list_environments(cls) -> list[SandboxEnvironment]: + """The environments this provider ships. + + A provider offers environments the way Datalayer offers its own — a + named machine to run in, not a free-form choice of hardware. Kaggle + gives a notebook session either on CPU alone or with an accelerator + attached, so it ships one of each and nothing in between. + """ return [ SandboxEnvironment( - name="kaggle", - title="Kaggle", + name="kaggle-cpu", + title="Kaggle CPU", language="python", owner="kaggle", visibility="cloud", burning_rate=0.0, - metadata={"variant": "kaggle"}, - ) + metadata={"variant": "kaggle", "accelerator": None}, + ), + SandboxEnvironment( + name="kaggle-gpu", + title="Kaggle GPU", + language="python", + owner="kaggle", + visibility="cloud", + burning_rate=0.0, + metadata={"variant": "kaggle", "accelerator": "T4"}, + ), ] def start(self) -> None: diff --git a/code_sandboxes/modal_sandbox.py b/code_sandboxes/modal_sandbox.py index dba612e..51ecd0b 100644 --- a/code_sandboxes/modal_sandbox.py +++ b/code_sandboxes/modal_sandbox.py @@ -122,16 +122,32 @@ def __init__( @classmethod def list_environments(cls) -> list[SandboxEnvironment]: + """The environments this provider ships. + + Modal takes a machine specification per sandbox; what is offered here + are the two shapes worth naming — a plain container, and one with a + GPU attached — so that choosing an environment is choosing between + two named things, as it is with every other provider. + """ return [ SandboxEnvironment( - name="modal", - title="Modal", + name="modal-cpu", + title="Modal CPU", language="python", owner="modal", visibility="cloud", burning_rate=0.0, - metadata={"variant": "modal"}, - ) + metadata={"variant": "modal", "gpu": None}, + ), + SandboxEnvironment( + name="modal-gpu", + title="Modal GPU", + language="python", + owner="modal", + visibility="cloud", + burning_rate=0.0, + metadata={"variant": "modal", "gpu": "T4"}, + ), ] def start(self) -> None: diff --git a/code_sandboxes/providers.py b/code_sandboxes/providers.py new file mode 100644 index 0000000..5c74e59 --- /dev/null +++ b/code_sandboxes/providers.py @@ -0,0 +1,233 @@ +# Copyright (c) 2023-2025 Datalayer, Inc. +# Distributed under the terms of the Modified BSD License. + +"""What each provider needs before it can run anything. + +A sandbox variant is only usable when its credentials are on hand: Kaggle wants +an API token, Modal a token pair, Datalayer an account. Every caller that +offers sandboxes — the CLI, the web application, the JupyterLab extension — +has to answer the same question first, "which of these can this machine +actually use", and each of them was answering it on its own. + +This module is that answer, once: a provider declares what it requires, how to +tell whether the requirement is met, and which environments it ships. Nothing +here starts a sandbox or reads a secret's value — it reports what is present, +so a caller can offer what will work and say why the rest is missing. + +The environments themselves stay with the variants that own them +(``Sandbox.list_environments``): a provider ships environments the way +Datalayer ships ``ai-agents-env``, and only the variant knows its own. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Optional + +from .models import SandboxEnvironment, SandboxVariant + +__all__ = [ + "ProviderRequirement", + "SandboxProvider", + "PROVIDERS", + "available_providers", + "get_provider", +] + + +@dataclass(frozen=True) +class ProviderRequirement: + """One way of satisfying a provider's credentials. + + A provider may accept several: Kaggle takes a single API token, a + username/key pair, or the file its own CLI writes. Any one of them is + enough, which is why they are listed rather than merged. + """ + + #: 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 + #: What to tell someone who has none of it. + hint: str = "" + + def is_met(self) -> bool: + """Whether this way of providing the credentials is satisfied here.""" + if self.env_vars and all(os.environ.get(name) for name in self.env_vars): + return True + if self.file and Path(self.file).expanduser().is_file(): + return True + return False + + +@dataclass(frozen=True) +class SandboxProvider: + """A place sandboxes can run, and what it takes to run there.""" + + variant: SandboxVariant + title: str + description: str + #: 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 + #: Whether the provider can be used with no credentials at all. + needs_credentials: bool = True + #: Read the environments this provider ships, when it can be asked. + list_environments: Optional[Callable[[], list[SandboxEnvironment]]] = field( + default=None, repr=False + ) + + @property + def name(self) -> str: + """The identifier of the provider, which is that of its variant.""" + return self.variant.value + + def is_available(self) -> bool: + """Whether this machine has what the provider requires.""" + if not self.needs_credentials or not self.requirements: + return True + return any(requirement.is_met() for requirement in self.requirements) + + 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.""" + if self.list_environments is None: + return [] + try: + return self.list_environments() + except Exception: # noqa: BLE001 + # 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]]: + """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]: + from .base import Sandbox + + return Sandbox.list_environments(variant=variant) + + return read + + +#: Every provider, whether or not this machine can use it. +PROVIDERS: tuple[SandboxProvider, ...] = ( + SandboxProvider( + variant=SandboxVariant.DATALAYER, + title="Datalayer", + description=( + "Sandboxes of the Datalayer platform, in the environments the " + "account may launch — `ai-agents-env` and the rest." + ), + requirements=( + ProviderRequirement( + env_vars=("DATALAYER_TOKEN",), + hint="Sign in with `datalayer login`, or set DATALAYER_TOKEN.", + ), + ), + list_environments=_environments_of(SandboxVariant.DATALAYER), + ), + SandboxProvider( + variant=SandboxVariant.JUPYTER, + title="Jupyter Server", + description=( + "Kernels of a Jupyter Server — the one this application is running " + "against, or any other that is reachable." + ), + requirements=( + ProviderRequirement( + env_vars=("JUPYTER_SERVER_URL",), + hint="Set JUPYTER_SERVER_URL (and JUPYTER_TOKEN if it is secured).", + ), + ), + list_environments=_environments_of(SandboxVariant.JUPYTER), + ), + SandboxProvider( + variant=SandboxVariant.KAGGLE, + title="Kaggle", + description=( + "Kaggle notebook sessions, interactively against a running kernel " + "or as a batch job." + ), + extra="kaggle", + requirements=( + ProviderRequirement( + env_vars=("KAGGLE_API_TOKEN",), + hint="Set KAGGLE_API_TOKEN to the contents of your kaggle.json.", + ), + ProviderRequirement( + env_vars=("KAGGLE_USERNAME", "KAGGLE_KEY"), + hint="Set KAGGLE_USERNAME and KAGGLE_KEY.", + ), + ProviderRequirement( + file="~/.kaggle/kaggle.json", + hint="Place kaggle.json in ~/.kaggle/, as the Kaggle CLI does.", + ), + ), + list_environments=_environments_of(SandboxVariant.KAGGLE), + ), + SandboxProvider( + variant=SandboxVariant.MODAL, + title="Modal", + description="Containers on Modal, with or without a GPU attached.", + extra="modal", + requirements=( + ProviderRequirement( + env_vars=("MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"), + hint="Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET.", + ), + ProviderRequirement( + file="~/.modal.toml", + hint="Run `modal token new`, which writes ~/.modal.toml.", + ), + ), + list_environments=_environments_of(SandboxVariant.MODAL), + ), + SandboxProvider( + variant=SandboxVariant.DOCKER, + title="Docker", + description="Containers on the Docker daemon of this machine.", + needs_credentials=False, + list_environments=_environments_of(SandboxVariant.DOCKER), + ), + SandboxProvider( + variant=SandboxVariant.EVAL, + title="Eval", + description=( + "Code evaluated in this very process. For tests and examples; it " + "isolates nothing." + ), + needs_credentials=False, + list_environments=_environments_of(SandboxVariant.EVAL), + ), +) + + +def get_provider(name: str) -> Optional[SandboxProvider]: + """The provider of that name, if there is one. + + Args: + name: Identifier of the provider, which is that of its variant. + """ + wanted = (name or "").replace("-", "_").lower() + for provider in PROVIDERS: + if provider.name == wanted: + return provider + return None + + +def available_providers() -> tuple[SandboxProvider, ...]: + """The providers this machine has the credentials for.""" + return tuple(provider for provider in PROVIDERS if provider.is_available()) From 0e11f8ada6b0866b511181f64164730dbc9bd2c5 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 20 Aug 2026 04:23:17 +0200 Subject: [PATCH 2/5] examples --- code_sandboxes/__init__.py | 6 +- code_sandboxes/base.py | 23 +- code_sandboxes/cli.py | 10 +- ...r_sandbox.py => jupyter_server_sandbox.py} | 14 +- code_sandboxes/kaggle_execute.py | 6 +- code_sandboxes/kaggle_live.py | 459 ++++++++++++++++++ code_sandboxes/kaggle_sandbox.py | 119 ++++- code_sandboxes/manage.py | 21 +- code_sandboxes/modal_sandbox.py | 180 ++++++- code_sandboxes/models.py | 2 +- code_sandboxes/providers.py | 87 +++- docs/docs/api-reference/index.mdx | 4 +- docs/docs/cli/index.mdx | 4 +- docs/docs/cli/management.mdx | 4 +- docs/docs/comparison/index.mdx | 2 +- docs/docs/examples/index.mdx | 2 +- docs/docs/index.mdx | 2 +- docs/docs/sandboxes/index.mdx | 6 +- .../{jupyter.mdx => jupyter-server.mdx} | 8 +- docs/docs/sandboxes/kaggle.mdx | 49 ++ examples/exec/Makefile | 8 +- examples/exec/datalayer_sandbox_example.py | 1 - examples/exec/docker_sandbox_example.py | 1 - examples/exec/eval_sandbox_example.py | 1 - examples/exec/exec_common.py | 37 +- examples/exec/google_colab_sandbox_example.py | 2 - ...e.py => jupyter_server_sandbox_example.py} | 6 +- examples/exec/kaggle_sandbox_example.py | 7 +- examples/exec/modal_sandbox_example.py | 15 +- examples/exec/monty_sandbox_example.py | 2 - examples/repl/Makefile | 20 +- ...e.py => jupyter_server_sandbox_example.py} | 2 +- examples/repl/kaggle_sandbox_example.py | 35 +- tests/test_cli_repl.py | 6 +- tests/test_factory.py | 6 +- ...test_jupyter.py => test_jupyter_server.py} | 24 +- tests/test_kaggle_live.py | 132 +++++ tests/test_kaggle_session.py | 49 ++ tests/test_manage.py | 2 +- tests/test_modal_session.py | 56 +++ tests/test_models.py | 2 +- tests/test_sandboxes.py | 10 +- 42 files changed, 1303 insertions(+), 129 deletions(-) rename code_sandboxes/{jupyter_sandbox.py => jupyter_server_sandbox.py} (98%) create mode 100644 code_sandboxes/kaggle_live.py rename docs/docs/sandboxes/{jupyter.mdx => jupyter-server.mdx} (85%) rename examples/exec/{jupyter_sandbox_example.py => jupyter_server_sandbox_example.py} (83%) rename examples/repl/{jupyter_sandbox_example.py => jupyter_server_sandbox_example.py} (85%) rename tests/{test_jupyter.py => test_jupyter_server.py} (92%) create mode 100644 tests/test_kaggle_live.py create mode 100644 tests/test_kaggle_session.py create mode 100644 tests/test_modal_session.py diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index a6a368f..bfdaefe 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -13,7 +13,7 @@ Remote sandboxes (out-of-process execution via Jupyter kernel protocol): - DockerSandbox: Docker container based, good isolation - - JupyterSandbox: Jupyter Server with persistent kernel state + - JupyterServerSandbox: Jupyter Server with persistent kernel state - DatalayerSandbox: Cloud-based Datalayer runtime, full isolation - GoogleColabSandbox: Google Colab runtime, connects to an assigned kernel - KaggleSandbox: Kaggle runtime, connects to an interactive notebook kernel @@ -91,7 +91,7 @@ ) from .google_colab_sandbox import GoogleColabSandbox from .interfaces import ISandboxClient -from .jupyter_sandbox import JupyterSandbox +from .jupyter_server_sandbox import JupyterServerSandbox from .kaggle import KAGGLE_API_TOKEN_ENV, KaggleKernelClient, parse_kaggle_channels_url from .kaggle_execute import KaggleExecutionResult, KaggleKernelExecutor from .kaggle_sandbox import KaggleSandbox @@ -158,7 +158,7 @@ "GoogleColabKernelClient", "GoogleColabSandbox", "ISandboxClient", - "JupyterSandbox", + "JupyterServerSandbox", "KaggleExecutionResult", "KaggleKernelClient", "KaggleKernelExecutor", diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index 87725ae..48bbeed 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -232,7 +232,7 @@ def create( # noqa: C901 variant: The type of sandbox to create. - "eval": Simple Python exec() based, minimal isolation - "docker": Docker container based (requires Docker) - - "jupyter": Jupyter Server with persistent kernel state + - "jupyter-server": Jupyter Server with persistent kernel state - "datalayer": Cloud-based Datalayer runtime (default) config: Optional full configuration object (overrides individual params). timeout: Default timeout for code execution in seconds. @@ -286,7 +286,12 @@ def create( # noqa: C901 from .eval_sandbox import EvalSandbox - variant_value = variant.value if isinstance(variant, SandboxVariant) else variant + # 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("-", "_") if variant_value == "eval": sandbox = EvalSandbox(config=config, **kwargs) @@ -295,10 +300,10 @@ def create( # noqa: C901 from .docker_sandbox import DockerSandbox sandbox = DockerSandbox(config=config, **kwargs) - elif variant_value == "jupyter": - from .jupyter_sandbox import JupyterSandbox + elif variant_value == "jupyter_server": + from .jupyter_server_sandbox import JupyterServerSandbox - sandbox = JupyterSandbox(config=config, **kwargs) + sandbox = JupyterServerSandbox(config=config, **kwargs) elif variant_value == "datalayer": from .datalayer_sandbox import DatalayerSandbox @@ -379,10 +384,10 @@ def list_environments( from .docker_sandbox import DockerSandbox return DockerSandbox.list_environments() - if variant_value == "jupyter": - from .jupyter_sandbox import JupyterSandbox + if variant_value == "jupyter_server": + from .jupyter_server_sandbox import JupyterServerSandbox - return JupyterSandbox.list_environments() + return JupyterServerSandbox.list_environments() if variant_value == "monty": from .monty_sandbox import MontySandbox @@ -405,7 +410,7 @@ def list_environments( return DatalayerSandbox.list_environments(**kwargs) raise ValueError( f"Unknown sandbox variant: {variant}. " - "Supported variants: eval, docker, jupyter, monty, modal, " + "Supported variants: eval, docker, jupyter-server, monty, modal, " "kaggle, google_colab, datalayer" ) diff --git a/code_sandboxes/cli.py b/code_sandboxes/cli.py index 1832e4b..bec35c1 100644 --- a/code_sandboxes/cli.py +++ b/code_sandboxes/cli.py @@ -22,7 +22,7 @@ console = Console() _SUPPORTED_REPL_VARIANTS = { - "jupyter", + "jupyter-server", "docker", "eval", "monty", @@ -40,7 +40,7 @@ def _root(ctx: typer.Context) -> None: """Code sandboxes CLI.""" if ctx.invoked_subcommand is None: - _run_repl(variant="jupyter") + _run_repl(variant="jupyter-server") def _print_result(result: Any) -> None: @@ -76,7 +76,7 @@ def _resolve_variant(variant: str | None) -> str: else: selected = typer.prompt( "Sandbox variant", - default="jupyter", + default="jupyter-server", show_default=True, ) selected = selected.strip().lower() @@ -102,7 +102,7 @@ def _resolve_variant_kwargs( ) -> dict[str, Any]: kwargs: dict[str, Any] = {} - if variant == "jupyter": + if variant.strip().lower().replace("-", "_") == "jupyter_server": # Match `jupyter console` behavior by launching local Jupyter on random port. kwargs["port"] = 0 @@ -485,7 +485,7 @@ def create( create_kwargs: dict[str, Any] = {} if gpu: create_kwargs["gpu"] = gpu - if variant.strip().lower().replace("-", "_") == "jupyter": + if variant.strip().lower().replace("-", "_") == "jupyter_server": if environment: create_kwargs["kernel_name"] = environment elif environment: diff --git a/code_sandboxes/jupyter_sandbox.py b/code_sandboxes/jupyter_server_sandbox.py similarity index 98% rename from code_sandboxes/jupyter_sandbox.py rename to code_sandboxes/jupyter_server_sandbox.py index fa565b9..443f673 100644 --- a/code_sandboxes/jupyter_sandbox.py +++ b/code_sandboxes/jupyter_server_sandbox.py @@ -49,7 +49,7 @@ logger = logging.getLogger(__name__) -class JupyterSandbox(Sandbox): +class JupyterServerSandbox(Sandbox): """Jupyter Server sandbox using a persistent kernel. Pass ``headers`` to send extra HTTP headers on every request to an external @@ -129,13 +129,13 @@ def __init__( def list_environments(cls) -> list[SandboxEnvironment]: return [ SandboxEnvironment( - name="jupyter", + name="jupyter-server", title="Jupyter", language="python", owner="local", visibility="local", burning_rate=0.0, - metadata={"variant": "jupyter"}, + metadata={"variant": "jupyter-server"}, ) ] @@ -260,7 +260,7 @@ def _start_local_server_inprocess(self, workdir: str, port: int) -> None: from jupyter_server.serverapp import ServerApp except Exception as exc: raise SandboxConfigurationError( - "jupyter_server is required for JupyterSandbox. " + "jupyter_server is required for JupyterServerSandbox. " "Install it with: pip install code-sandboxes[test]" ) from exc @@ -369,7 +369,7 @@ def start(self) -> None: from jupyter_kernel_client import JupyterKernelClient except ImportError as exc: raise SandboxConfigurationError( - "jupyter-kernel-client is required for JupyterSandbox. " + "jupyter-kernel-client is required for JupyterServerSandbox. " "Install it with: pip install code-sandboxes[test]" ) from exc @@ -407,7 +407,7 @@ def start(self) -> None: self._default_context = self.create_context("default") self._info = SandboxInfo( id=self._sandbox_id, - variant="jupyter", + variant="jupyter-server", status=SandboxStatus.RUNNING, created_at=time.time(), name=self.config.name, @@ -525,7 +525,7 @@ def run_code( raise SandboxNotStartedError() if language != "python": - raise ValueError(f"JupyterSandbox only supports Python, got: {language}") + raise ValueError(f"JupyterServerSandbox only supports Python, got: {language}") started_at = time.time() diff --git a/code_sandboxes/kaggle_execute.py b/code_sandboxes/kaggle_execute.py index 20eabb1..5c5c7ee 100644 --- a/code_sandboxes/kaggle_execute.py +++ b/code_sandboxes/kaggle_execute.py @@ -399,13 +399,13 @@ def execute( A :class:`KaggleExecutionResult` describing the run. """ username = self._resolve_username() - slug = slug or _slugify(title or f"jkc-run-{uuid.uuid4().hex[:8]}") + slug = slug or _slugify(title or f"code-sandbox-run-{uuid.uuid4().hex[:8]}") ref = f"{username}/{slug}" title = title or slug normalized_accelerator = _normalize_accelerator(accelerator) resolved_enable_gpu = bool(enable_gpu or normalized_accelerator) - with tempfile.TemporaryDirectory(prefix="jkc-kaggle-") as tmp: + with tempfile.TemporaryDirectory(prefix="code-sandbox-kaggle-") as tmp: folder = Path(tmp) code_file = self._write_sources(folder, code, kernel_type, language) self._write_metadata( @@ -573,7 +573,7 @@ def _download_output( ) -> None: try: if output_dir is None: - output_dir = tempfile.mkdtemp(prefix="jkc-kaggle-out-") + output_dir = tempfile.mkdtemp(prefix="code-sandbox-kaggle-out-") files = self.output(slug, output_dir) result.output_dir = output_dir result.output_files = files diff --git a/code_sandboxes/kaggle_live.py b/code_sandboxes/kaggle_live.py new file mode 100644 index 0000000..1956540 --- /dev/null +++ b/code_sandboxes/kaggle_live.py @@ -0,0 +1,459 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# Distributed under the terms of the Modified BSD License. + +"""A live Kaggle kernel, with no service but Kaggle itself. + +A batch job is stateless and a Kaggle interactive session has no public API — +the runtime URL its editor uses is minted by the web frontend and nothing +else. What both ends CAN reach is Kaggle's own datasets API, so that is the +whole transport: a batch job runs an agent that starts a real ipykernel and +polls a private dataset for code; the client pushes code there and polls a +second private dataset for the outputs the agent publishes. No tunnel, no +third-party relay — the rendezvous is the account's own storage. + +The price is latency, and it is stated rather than hidden: a turn costs a +dataset version upload and a poll on each side — seconds, not milliseconds — +against a batch job's minute-plus per turn and no state at all. The state is +real: one kernel process lives for the whole session. + +The agent embeds the account's API credentials in the (private) kernel source, +because a batch job has no other way to authenticate against the datasets API: +Kaggle attaches secrets in its UI only. The kernel is pushed private, to the +user's own account; still, it is their token in their artifact, so the mode is +opt-in (``live=True``). + +@module kaggle_live +""" + +from __future__ import annotations + +import contextlib +import io +import json +import tempfile +import time +import uuid +from pathlib import Path +from typing import Callable, Any, Optional + +__all__ = ["KaggleLiveSession", "build_agent_code", "resolve_kaggle_credentials"] + +#: How often each side looks for a message from the other, in seconds. +POLL_SECONDS = 3.0 + +#: While a turn is in flight — and shortly after one — both ends poll this +#: fast instead. The wait between polls is pure added latency on top of +#: Kaggle's own dataset-version processing, and an active REPL sends its next +#: input right away; only an idle session deserves the slow cadence. +FAST_POLL_SECONDS = 0.75 + +#: How long after the last activity the fast cadence is kept. +FAST_WINDOW_SECONDS = 60.0 + +#: The agent exits after this long without a message, so an abandoned session +#: does not burn quota until the batch timeout. +IDLE_TIMEOUT_SECONDS = 30 * 60 + +#: How long the client waits for the agent to boot: the job queues, the image +#: boots, the kernel starts — minutes on a busy day, longer with a GPU. +READY_TIMEOUT_SECONDS = 900 + + +def _username_of_access_token(token: str) -> str: + """The account an access token belongs to, asked of Kaggle itself. + + The token (``KGAT_…``) carries no username; the kaggle client learns it + by introspection. Its own ``authenticate()`` calls ``exit(1)`` on + failure — a library must not — so the access-token step is run alone. + """ + from kaggle.api.kaggle_api_extended import KaggleApi + + api = KaggleApi() + api._load_config() + if not api._authenticate_with_access_token(): + raise RuntimeError( + "KAGGLE_API_TOKEN was not accepted by Kaggle. The token may be " + "expired or revoked — create one at kaggle.com/settings." + ) + username = api.config_values.get(api.CONFIG_NAME_USER) + if not username: + raise RuntimeError("Kaggle accepted the token but named no account.") + return str(username) + + +def resolve_kaggle_credentials() -> tuple[str, dict[str, str]]: + """The account's username, and the environment that authenticates it. + + The same three places the provider registry accepts, in the same order: + ``KAGGLE_API_TOKEN`` — an access token (``KGAT_…``) or the contents of + ``kaggle.json`` —, the ``KAGGLE_USERNAME``/``KAGGLE_KEY`` pair, and + ``~/.kaggle/kaggle.json``. + + Returns ``(username, agent_env)``: the username names the dataset bus, + and ``agent_env`` is exactly the environment the AGENT sets inside the + batch job so its kaggle client authenticates as the same account. + """ + import os + + token = os.environ.get("KAGGLE_API_TOKEN") + if token: + try: + parsed = json.loads(token) + return parsed["username"], { + "KAGGLE_USERNAME": parsed["username"], + "KAGGLE_KEY": parsed["key"], + } + except (ValueError, KeyError): + # Not the JSON of kaggle.json: an ACCESS token, which the kaggle + # client authenticates by introspection — inside the job as well, + # where the same variable is all it needs. + return _username_of_access_token(token), {"KAGGLE_API_TOKEN": token} + username = os.environ.get("KAGGLE_USERNAME") + key = os.environ.get("KAGGLE_KEY") + if username and key: + return username, {"KAGGLE_USERNAME": username, "KAGGLE_KEY": key} + config = Path("~/.kaggle/kaggle.json").expanduser() + if config.is_file(): + parsed = json.loads(config.read_text(encoding="utf-8")) + return parsed["username"], { + "KAGGLE_USERNAME": parsed["username"], + "KAGGLE_KEY": parsed["key"], + } + raise RuntimeError( + "No Kaggle credentials found. Set KAGGLE_API_TOKEN, " + "KAGGLE_USERNAME/KAGGLE_KEY, or place ~/.kaggle/kaggle.json." + ) + + +def build_agent_code( + agent_env: dict[str, str], + c2k_ref: str, + k2c_ref: str, + *, + poll_seconds: float = POLL_SECONDS, + idle_timeout: float = IDLE_TIMEOUT_SECONDS, +) -> str: + """The script the batch job runs: a kernel, fed from the dataset bus. + + Standalone on purpose — it imports nothing of this package, only what a + Kaggle image already carries: the ``kaggle`` client, ``jupyter_client`` + and ``ipykernel``. + """ + settings = json.dumps( + { + "auth": agent_env, + "c2k": c2k_ref, + "k2c": k2c_ref, + "poll": poll_seconds, + "fast": FAST_POLL_SECONDS, + "fast_window": FAST_WINDOW_SECONDS, + "idle": idle_timeout, + } + ) + template = ''' +import json, os, shutil, tempfile, time +from pathlib import Path + +SETTINGS = json.loads(%SETTINGS%) +os.environ.update(SETTINGS["auth"]) + +from kaggle import api # noqa: E402 (needs the environment above) +from jupyter_client.manager import KernelManager # noqa: E402 + +def publish(seq, reply): + folder = Path(tempfile.mkdtemp(prefix="bus-")) + (folder / "dataset-metadata.json").write_text(json.dumps({ + "title": SETTINGS["k2c"].split("/")[-1], + "id": SETTINGS["k2c"], + "licenses": [{"name": "CC0-1.0"}], + })) + (folder / "message.json").write_text(json.dumps({"seq": seq, "reply": reply})) + api.dataset_create_version(str(folder), version_notes=f"seq {seq}", quiet=True) + shutil.rmtree(folder, ignore_errors=True) + +def fetch(): + folder = Path(tempfile.mkdtemp(prefix="bus-")) + try: + api.dataset_download_files(SETTINGS["c2k"], path=str(folder), force=True, unzip=True) + return json.loads((folder / "message.json").read_text()) + except Exception: + return None + finally: + shutil.rmtree(folder, ignore_errors=True) + +manager = KernelManager() +manager.start_kernel() +client = manager.client() +client.start_channels() +client.wait_for_ready(timeout=120) + +def run(code): + outputs = [] + state = {"status": "ok"} + def sink(msg): + kind = msg["msg_type"] + content = msg["content"] + if kind == "stream": + 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", {})}) + elif kind == "error": + state["status"] = "error" + 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" + return {"status": state["status"], "outputs": outputs} + +publish(0, {"status": "ok", "outputs": []}) # ready +last = 0 +alive_since = time.time() +# The kernel just booted: the first input follows right away, so start fast. +active_since = time.time() +while time.time() - alive_since < SETTINGS["idle"]: + message = fetch() + if message and message.get("seq", 0) > last: + last = message["seq"] + alive_since = time.time() + active_since = time.time() + if message.get("op") == "shutdown": + break + publish(last, run(message.get("code", ""))) + active_since = time.time() + fast = time.time() - active_since < SETTINGS["fast_window"] + time.sleep(SETTINGS["fast"] if fast else SETTINGS["poll"]) + +client.stop_channels() +manager.shutdown_kernel(now=True) +print("agent: session closed") +''' + return template.replace("%SETTINGS%", repr(settings)) + + +class KaggleLiveSession: + """A persistent kernel on Kaggle, spoken to over the dataset bus. + + Duck-types the kernel client the interactive sandbox path holds: + ``execute(code, timeout=...) -> reply`` and ``stop()``, so + :class:`KaggleSandbox` can hold one in place of a websocket client. + """ + + def __init__( + self, + executor: Any, + *, + api: Any = None, + session_id: Optional[str] = None, + poll_seconds: float = POLL_SECONDS, + ) -> None: + self._executor = executor + if api is None: + from kaggle import api as kaggle_api + + api = kaggle_api + 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._seq = 0 + self._slug: Optional[str] = None + self.id = f"kaggle-live-{self._session_id}" + + # -- the bus --------------------------------------------------------- + + def _write_bus(self, ref: str, payload: dict, *, create: bool) -> None: + folder = Path(tempfile.mkdtemp(prefix="bus-")) + try: + (folder / "dataset-metadata.json").write_text( + json.dumps( + { + "title": ref.split("/")[-1], + "id": ref, + "licenses": [{"name": "CC0-1.0"}], + } + ), + encoding="utf-8", + ) + (folder / "message.json").write_text(json.dumps(payload), encoding="utf-8") + # `quiet` silences the progress bar only — the client prints + # "Dataset URL: …" unconditionally, once per call, and the bus + # is polled every few seconds. + with contextlib.redirect_stdout(io.StringIO()): + if create: + self._api.dataset_create_new(str(folder), public=False, quiet=True) + else: + self._api.dataset_create_version( + str(folder), version_notes=f"seq {payload.get('seq')}", quiet=True + ) + finally: + import shutil + + shutil.rmtree(folder, ignore_errors=True) + + def _read_bus(self, ref: str) -> Optional[dict]: + 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 + ) + return json.loads((folder / "message.json").read_text(encoding="utf-8")) + except Exception: + # Not there yet, or a version still processing: the caller polls. + return None + finally: + import shutil + + shutil.rmtree(folder, ignore_errors=True) + + # -- the session ----------------------------------------------------- + + def _agent_log_tail(self, lines: int = 25) -> str: + """The last lines of the agent job's log, for a post-mortem.""" + if not self._slug: + 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 + finally: + import shutil + + shutil.rmtree(folder, ignore_errors=True) + return "" + + def start( + self, + *, + accelerator: Optional[str] = None, + ready_timeout: float = READY_TIMEOUT_SECONDS, + on_progress: Optional[Callable[[str], None]] = print, + ) -> None: + """Create the bus, submit the agent, wait until the kernel answers. + + Args: + accelerator: A Kaggle accelerator name, for a GPU session. + ready_timeout: How long to wait for the agent to boot. + on_progress: Told what is happening while the job boots — the + submitted job's URL, its status while queued, the moment the + kernel answers. ``print`` by default, since whoever starts a + live session is watching; a service passes ``None``. + """ + say = on_progress or (lambda message: None) + username, agent_env = resolve_kaggle_credentials() + self._username = username + base = f"code-sandbox-live-{self._session_id}" + self._c2k = f"{username}/{base}-c2k" + self._k2c = f"{username}/{base}-k2c" + # Both ends of the bus exist before the agent boots, so neither side + # ever has to create what the other is already polling. + say(f"[kaggle-live] creating the private dataset bus ({base})…") + self._write_bus(self._c2k, {"seq": 0}, create=True) + self._write_bus(self._k2c, {"seq": -1}, create=True) + + agent = build_agent_code(agent_env, self._c2k, self._k2c, poll_seconds=self._poll) + submitted = self._executor.execute( + agent, + slug=f"{base}-agent", + kernel_type="script", + wait=False, + enable_internet=True, + accelerator=accelerator, + download_output=False, + ) + self._slug = getattr(submitted, "slug", None) + say( + f"[kaggle-live] agent submitted: https://www.kaggle.com/code/{self._slug}\n" + f"[kaggle-live] waiting for the kernel — a queued job takes minutes…" + ) + + started = time.monotonic() + deadline = started + ready_timeout + last_status: Optional[str] = None + last_note = started + while time.monotonic() < deadline: + message = self._read_bus(self._k2c) + if message and message.get("seq", -1) >= 0: + say(f"[kaggle-live] the kernel is up ({time.monotonic() - started:.0f}s).") + 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 = 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 = "" + if "name resolution" in tail or "Failed to resolve" in tail: + # The job asked for internet and ran without it: Kaggle + # grants kernels internet only to phone-verified accounts. + hint = ( + "\nThe job ran WITHOUT internet although it asked for " + "it — Kaggle only grants kernels internet once the " + "account is phone-verified: kaggle.com/settings, " + "'Phone verification'." + ) + raise RuntimeError( + f"The Kaggle agent job ended ({status}) before its kernel " + f"answered — https://www.kaggle.com/code/{self._slug}" + + (f"\n--- job log (tail) ---\n{tail}" if tail else "") + + hint + ) + now = time.monotonic() + if status != last_status or now - last_note >= 30: + say( + f"[kaggle-live] still waiting — job {status or 'submitted'}, " + f"{now - started:.0f}s elapsed" + ) + last_status, last_note = status, now + time.sleep(self._poll) + raise TimeoutError( + f"The Kaggle live agent did not come up within {ready_timeout:.0f}s " + f"(job {self._slug!r}). It may still be queued — a GPU job queues " + "longest — or the account may not allow internet-enabled kernels." + ) + + def execute(self, code: str, timeout: Optional[float] = 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.") + self._seq += 1 + self._write_bus(self._c2k, {"seq": self._seq, "op": "execute", "code": code}, create=False) + submitted = time.monotonic() + deadline = submitted + (timeout or 600.0) + while time.monotonic() < deadline: + message = self._read_bus(self._k2c) + if message and message.get("seq") == self._seq: + return message.get("reply", {"status": "error", "outputs": []}) + # The wait between polls is latency the user feels on every turn: + # poll fast while the answer is due, settle down only when the + # turn has clearly become a long-running one. + fast = time.monotonic() - submitted < FAST_WINDOW_SECONDS + time.sleep(FAST_POLL_SECONDS if fast else self._poll) + raise TimeoutError(f"No reply from the live kernel within {timeout or 600.0:.0f}s.") + + 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: + 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( + "Variables of a live Kaggle kernel are read by running code on it." + ) diff --git a/code_sandboxes/kaggle_sandbox.py b/code_sandboxes/kaggle_sandbox.py index 1e8713d..b12aeb9 100644 --- a/code_sandboxes/kaggle_sandbox.py +++ b/code_sandboxes/kaggle_sandbox.py @@ -94,6 +94,9 @@ def __init__( self._batch_mode = False self._sandbox_id = str(uuid.uuid4()) self._extra_kwargs = kwargs + # The snippets this sandbox has run, replayed before each batch job so + # state appears to survive between them — see `_session_prelude`. + self._session_history: list[str] = [] @classmethod def list_environments(cls) -> list[SandboxEnvironment]: @@ -129,6 +132,42 @@ def start(self) -> None: if self._started: return + # 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 + ): + from .kaggle_live import KaggleLiveSession + + executor = KaggleKernelExecutor( + username=self._extra_kwargs.get("username"), + quiet=True, + ) + session = KaggleLiveSession(executor) + session.start( + accelerator=self._extra_kwargs.get("accelerator") + or self._extra_kwargs.get("gpu") + or self.config.gpu + ) + # The session ducks as the kernel client, so the interactive + # `run_code` path works on it untouched. + self._client = session + self._info = SandboxInfo( + id=self._sandbox_id, + variant="kaggle", + status=SandboxStatus.RUNNING, + created_at=time.time(), + name=self.config.name, + metadata={"mode": "live", "kernel_id": session.id}, + config=self.config, + ) + self._default_context = self.create_context("default") + self._started = True + return + # Transparent batch mode: when no interactive runtime connection details # are available, fall back to Kaggle's official job API. if not self._server_url and not self._channels_url: @@ -404,9 +443,11 @@ def run_code_streaming( # noqa: C901 poll_interval = float(self._extra_kwargs.get("poll_interval", 2.0)) timeout_seconds = float(timeout or self.config.timeout) + submitted_code, session_marker = self._session_prelude(code) + try: submitted = self._executor.execute( - code, + submitted_code, wait=False, timeout=timeout_seconds, download_output=False, @@ -479,6 +520,11 @@ def run_code_streaming( # noqa: C901 reply = submitted.to_kernel_reply() submitted.kernel_reply = reply + if isinstance(reply, dict) and session_marker: + # The outputs of the replay were shown on the turns that ran them. + reply = self._cut_replayed_outputs(reply, session_marker) + submitted.kernel_reply = reply + if isinstance(reply, dict): for output in reply.get("outputs", []): output_type = output.get("output_type") @@ -510,6 +556,10 @@ def run_code_streaming( # noqa: C901 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. + self._record_session(code) self._executing_event.clear() self._interrupt_requested.clear() @@ -532,6 +582,61 @@ async def run_code_streaming_async( ): yield item + def _session_prelude(self, code: str) -> tuple[str, str | None]: + """The code to submit for a batch, replaying the session so far. + + A batch job is a fresh machine: nothing survives from one to the next, + so `x = 1` in one job and `print(x)` in the following one is a + NameError. What CAN be carried is the code itself — every snippet that + succeeded before is replayed ahead of the new one, and a sentinel + printed between them marks where the replay ends, so only the new + snippet's output is surfaced. + + The price is honest: each turn re-executes the whole session (on a + machine where a job already takes a minute), and code with side + effects — downloads, writes, randomness — runs again each time. Turn + it off with ``session=False`` when creating the sandbox. + """ + if not self._extra_kwargs.get("session", True) or not self._session_history: + return code, None + marker = f"<>" + replay = "\n".join(self._session_history) + full = f"{replay}\nprint({marker!r}, flush=True)\n{code}" + return full, marker + + def _record_session(self, code: str) -> None: + """Keep a snippet that ran, for the replays of the turns after it.""" + if self._extra_kwargs.get("session", True): + self._session_history.append(code) + + @staticmethod + def _cut_replayed_outputs(reply: dict, marker: str) -> dict: + """Drop the outputs of the replay, keeping what follows the sentinel. + + The outputs of a run are ordered, so everything before the stream line + carrying the sentinel belongs to snippets already shown on the turns + that ran them. + """ + outputs = reply.get("outputs", []) + for index, output in enumerate(outputs): + if output.get("output_type") != "stream": + continue + text = str(output.get("text", "")) + if marker not in text: + continue + kept = outputs[index + 1 :] + after = text.split(marker, 1)[1].lstrip("\n") + if after: + head = dict(output) + head["text"] = after + kept = [head, *kept] + trimmed = dict(reply) + trimmed["outputs"] = kept + return trimmed + # No sentinel reached: the replay itself failed, and its outputs are + # the explanation — better shown than swallowed. + return reply + def _run_code_batch( # noqa: C901 self, code: str, @@ -564,9 +669,11 @@ def _run_code_batch( # noqa: C901 or self.config.gpu ) + submitted_code, session_marker = self._session_prelude(code) + try: result = self._executor.execute( - code, + submitted_code, wait=True, timeout=float(timeout or self.config.timeout), download_output=True, @@ -599,6 +706,9 @@ def _run_code_batch( # noqa: C901 if reply is None and hasattr(result, "to_kernel_reply"): reply = result.to_kernel_reply() + if isinstance(reply, dict) and session_marker: + reply = self._cut_replayed_outputs(reply, session_marker) + if isinstance(reply, dict): for output in reply.get("outputs", []): output_type = output.get("output_type") @@ -673,6 +783,11 @@ def _run_code_batch( # noqa: C901 was_interrupted = self._interrupt_requested.is_set() self._interrupt_requested.clear() + # Only a snippet that ran without error joins the session: replaying a + # failing one would fail every turn after it. + if result.succeeded and code_error is None and not was_interrupted: + self._record_session(code) + return ExecutionResult( results=results, logs=Logs(stdout=stdout_messages, stderr=stderr_messages), diff --git a/code_sandboxes/manage.py b/code_sandboxes/manage.py index f4f3ff7..f4d0ed9 100644 --- a/code_sandboxes/manage.py +++ b/code_sandboxes/manage.py @@ -240,7 +240,7 @@ def create(self, **kwargs: Any) -> SandboxInfo: return info -class JupyterSandboxManager(SandboxManager): +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 @@ -249,7 +249,7 @@ class JupyterSandboxManager(SandboxManager): environment, then to ``http://localhost:8888``. """ - variant = "jupyter" + variant = "jupyter-server" capabilities = frozenset({"create", "list", "get", "delete"}) def __init__( @@ -294,7 +294,7 @@ def _request(self, method: str, path: str, **kwargs: Any) -> Any: def _info(kernel: dict) -> SandboxInfo: return SandboxInfo( id=kernel.get("id", ""), - variant="jupyter", + variant="jupyter-server", status=( SandboxStatus.RUNNING if kernel.get("execution_state") != "dead" @@ -334,7 +334,7 @@ def create(self, kernel_name: str | None = None, **_: Any) -> SandboxInfo: return self._info(response.json()) -class GoogleColabSandboxManager(JupyterSandboxManager): +class GoogleColabSandboxManager(JupyterServerSandboxManager): """Kernels of a Colab runtime, over the same Jupyter REST API. The Colab proxy authenticates with its own headers instead of a Jupyter @@ -734,7 +734,7 @@ def create(self, **kwargs: Any) -> SandboxInfo: "eval": EvalSandboxManager, "monty": MontySandboxManager, "docker": DockerSandboxManager, - "jupyter": JupyterSandboxManager, + "jupyter-server": JupyterServerSandboxManager, "google_colab": GoogleColabSandboxManager, "kaggle": KaggleSandboxManager, "modal": ModalSandboxManager, @@ -765,7 +765,16 @@ def get_manager(variant: str, **kwargs: Any) -> SandboxManager: ValueError: For an unknown variant. """ normalized = variant.strip().lower().replace("-", "_") - manager_class = _MANAGERS.get(normalized) + # 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, + ) if manager_class is None: raise ValueError( f"Unknown sandbox variant: {variant}. " diff --git a/code_sandboxes/modal_sandbox.py b/code_sandboxes/modal_sandbox.py index 51ecd0b..9f83b23 100644 --- a/code_sandboxes/modal_sandbox.py +++ b/code_sandboxes/modal_sandbox.py @@ -80,6 +80,50 @@ def _resolve_modal_gpu(gpu_flavor: str, modal_module: Any) -> Any: return candidate +#: The process that holds the session inside the container. +#: +#: `sandbox.exec("python", "-c", code)` is a fresh interpreter per snippet, so +#: `x = 1` in one call and `x` in the next was a NameError: the container +#: persists, the namespace did not. This driver is started once and fed JSON +#: lines on stdin — one request, one reply — executing everything in a single +#: namespace, with stdout/stderr captured per request and the value of a +#: trailing expression repr'd the way a REPL would. +_DRIVER_SOURCE = """ +import ast, contextlib, io, json, sys, traceback + +namespace = {"__name__": "__main__"} +for line in sys.stdin: + line = line.strip() + if not line: + continue + request = json.loads(line) + out, err = io.StringIO(), io.StringIO() + reply = {"seq": request.get("seq"), "status": "ok"} + try: + tree = ast.parse(request.get("code", ""), mode="exec") + trailing = None + if tree.body and isinstance(tree.body[-1], ast.Expr): + trailing = ast.Expression(tree.body.pop(-1).value) + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + if tree.body: + exec(compile(tree, "", "exec"), namespace) + if trailing is not None: + value = eval(compile(trailing, "", "eval"), namespace) + if value is not None: + reply["result"] = repr(value) + except BaseException as error: + reply["status"] = "error" + reply["error"] = { + "name": type(error).__name__, + "value": str(error), + "traceback": traceback.format_exc(), + } + reply["stdout"] = out.getvalue() + reply["stderr"] = err.getvalue() + print(json.dumps(reply), flush=True) +""" + + 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 @@ -184,6 +228,7 @@ def start(self) -> None: create_kwargs["secrets"] = secrets self._sandbox = modal.Sandbox.create(**create_kwargs) + self._start_driver() self._default_context = self.create_context("default") self._info = SandboxInfo( @@ -200,6 +245,80 @@ def start(self) -> None: ) self._started = True + def _start_driver(self) -> None: + """Start the session process, and fall back to nothing on failure. + + A driver that cannot come up leaves `self._driver` unset, and + `run_code` then executes each snippet in its own process as before — + working, merely stateless. + """ + import queue + import threading + + try: + 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.", + exc_info=True, + ) + return + replies: queue.Queue = queue.Queue() + + def pump() -> None: + try: + 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 + # never gets its answer must time out rather than hang run_code. + thread = threading.Thread(target=pump, name="modal-driver-stdout", daemon=True) + thread.start() + self._driver = driver + self._driver_replies = replies + self._driver_seq = 0 + + def _driver_request(self, code: str, timeout: float) -> dict | None: + """One request to the session process, or None when it cannot serve.""" + import json as json_module + import queue + + if getattr(self, "_driver", None) is None: + return None + self._driver_seq += 1 + try: + self._driver.stdin.write( + json_module.dumps({"seq": self._driver_seq, "code": code}) + "\n" + ) + self._driver.stdin.drain() + except Exception: + logger.warning("The Modal session driver went away; restarting stateless.") + self._driver = None + return None + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"No reply from the Modal session within {timeout:.0f}s.") + try: + line = self._driver_replies.get(timeout=remaining) + except queue.Empty: + continue + if line is None: + # The reader reached EOF: the driver is gone. + self._driver = None + return None + try: + reply = json_module.loads(line) + except ValueError: + continue + if reply.get("seq") == self._driver_seq: + return reply + def stop(self) -> None: if not self._started: return @@ -247,6 +366,59 @@ def run_code( # noqa: C901 stderr_messages: list[OutputMessage] = [] code_error: CodeError | None = None + # One process for the session: state persists between snippets, and a + # trailing expression answers with its repr, as a REPL would. The + # fresh-process path below stays as the fallback when the driver is + # not there. + reply = None + try: + reply = self._driver_request(code, timeout or self.config.timeout) + except TimeoutError as error: + return ExecutionResult( + execution_ok=False, + execution_error=str(error), + started_at=started_at, + completed_at=time.time(), + context_id=context.id if context else "default", + ) + if reply is not None: + current_time = time.time() + results: list[Result] = [] + for line in (reply.get("stdout") or "").splitlines(): + msg = OutputMessage(line=line, timestamp=current_time, error=False) + stdout_messages.append(msg) + if on_stdout: + on_stdout(msg) + for line in (reply.get("stderr") or "").splitlines(): + msg = OutputMessage(line=line, timestamp=current_time, error=True) + stderr_messages.append(msg) + if on_stderr: + on_stderr(msg) + if reply.get("result") is not None: + value = Result(data={"text/plain": reply["result"]}, is_main_result=True) + results.append(value) + if on_result: + on_result(value) + if reply.get("status") == "error": + detail = reply.get("error") or {} + code_error = CodeError( + name=detail.get("name", "Error"), + value=detail.get("value", ""), + traceback=detail.get("traceback", ""), + ) + 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, + started_at=started_at, + completed_at=current_time, + execution_count=self._execution_count, + context_id=context.id if context else "default", + ) + try: process = self._sandbox.exec( self._python_executable, @@ -310,12 +482,12 @@ def _do_interrupt(self) -> bool: def _get_internal_variable(self, name: str, context: Context | None = None): raise NotImplementedError( - "ModalSandbox executes each snippet in a fresh process and does not " - "support cross-call variable access." + "ModalSandbox holds variables in its session process; read them by " + "running code, e.g. run_code(f'print({name})')." ) def _set_internal_variable(self, name: str, value, context: Context | None = None) -> None: raise NotImplementedError( - "ModalSandbox executes each snippet in a fresh process and does not " - "support cross-call variable access." + "ModalSandbox holds variables in its session process; set them by " + "running code, e.g. run_code(f'{name} = ...')." ) diff --git a/code_sandboxes/models.py b/code_sandboxes/models.py index 283b4ac..1b7fa08 100644 --- a/code_sandboxes/models.py +++ b/code_sandboxes/models.py @@ -67,7 +67,7 @@ class SandboxVariant(str, Enum): EVAL = "eval" DOCKER = "docker" - JUPYTER = "jupyter" + JUPYTER = "jupyter-server" DATALAYER = "datalayer" GOOGLE_COLAB = "google_colab" KAGGLE = "kaggle" diff --git a/code_sandboxes/providers.py b/code_sandboxes/providers.py index 5c74e59..330559d 100644 --- a/code_sandboxes/providers.py +++ b/code_sandboxes/providers.py @@ -24,7 +24,7 @@ import os from dataclasses import dataclass, field from pathlib import Path -from typing import Callable, Optional +from typing import Callable, Mapping, Optional from .models import SandboxEnvironment, SandboxVariant @@ -33,6 +33,7 @@ "SandboxProvider", "PROVIDERS", "available_providers", + "provider_catalog", "get_provider", ] @@ -53,11 +54,21 @@ class ProviderRequirement: #: What to tell someone who has none of it. hint: str = "" - def is_met(self) -> bool: - """Whether this way of providing the credentials is satisfied here.""" - if self.env_vars and all(os.environ.get(name) for name in self.env_vars): + def is_met(self, secrets: Optional[Mapping[str, str]] = None) -> bool: + """Whether this way of providing the credentials is satisfied. + + Args: + secrets: Where to look the variables up. The process environment + by default; a service passes the secrets of an ACCOUNT here, + so "enabled for this user" and "enabled on this machine" are + the same question asked of different stores. + """ + store: Mapping[str, str] = os.environ if secrets is None else secrets + if self.env_vars and all(store.get(name) for name in self.env_vars): return True - if self.file and Path(self.file).expanduser().is_file(): + # A file only answers for the local machine: a remote store of + # secrets has no files to offer. + if secrets is None and self.file and Path(self.file).expanduser().is_file(): return True return False @@ -85,11 +96,11 @@ def name(self) -> str: """The identifier of the provider, which is that of its variant.""" return self.variant.value - def is_available(self) -> bool: - """Whether this machine has what the provider requires.""" + def is_available(self, secrets: Optional[Mapping[str, str]] = None) -> bool: + """Whether the provider's requirements are met, here or in `secrets`.""" if not self.needs_credentials or not self.requirements: return True - return any(requirement.is_met() for requirement in self.requirements) + return any(requirement.is_met(secrets) for requirement in self.requirements) def missing(self) -> tuple[ProviderRequirement, ...]: """The ways of satisfying it, when none of them is satisfied.""" @@ -165,7 +176,11 @@ def read() -> list[SandboxEnvironment]: requirements=( ProviderRequirement( env_vars=("KAGGLE_API_TOKEN",), - hint="Set KAGGLE_API_TOKEN to the contents of your kaggle.json.", + hint=( + "Set KAGGLE_API_TOKEN to an access token from " + "kaggle.com/settings (KGAT_…), or to the contents of " + "your kaggle.json." + ), ), ProviderRequirement( env_vars=("KAGGLE_USERNAME", "KAGGLE_KEY"), @@ -223,11 +238,57 @@ def get_provider(name: str) -> Optional[SandboxProvider]: """ wanted = (name or "").replace("-", "_").lower() for provider in PROVIDERS: - if provider.name == wanted: + # 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: return provider return None -def available_providers() -> tuple[SandboxProvider, ...]: - """The providers this machine has the credentials for.""" - return tuple(provider for provider in PROVIDERS if provider.is_available()) +def available_providers( + secrets: Optional[Mapping[str, str]] = 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, +) -> list[dict]: + """Every provider as plain data, for a service to serve. + + The services of the platform — the operator first — answer "which + environments exist, and which can this account use" over HTTP; they need + the registry as JSON, not as dataclasses holding callables. Environments + are read only for providers that are enabled: asking an unusable provider + what it ships is a call that fails. + """ + catalog: list[dict] = [] + for provider in PROVIDERS: + enabled = provider.is_available(secrets) + catalog.append( + { + "name": provider.name, + "title": provider.title, + "description": provider.description, + "enabled": enabled, + "needs_credentials": provider.needs_credentials, + "requirements": [ + { + "env_vars": list(requirement.env_vars), + "file": requirement.file, + "hint": requirement.hint, + } + for requirement in provider.requirements + ], + "environments": [ + { + "name": environment.name, + "title": environment.title, + "language": environment.language, + } + for environment in (provider.environments() if enabled else []) + ], + } + ) + return catalog diff --git a/docs/docs/api-reference/index.mdx b/docs/docs/api-reference/index.mdx index 6df6a10..195ef26 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"`, `"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"`, `"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/index.mdx b/docs/docs/cli/index.mdx index 90cef30..4db6367 100644 --- a/docs/docs/cli/index.mdx +++ b/docs/docs/cli/index.mdx @@ -25,7 +25,7 @@ You can either pass `--variant` directly or omit it and choose interactively. Supported variants: -- `jupyter` +- `jupyter-server` - `docker` - `eval` - `monty` @@ -36,7 +36,7 @@ Supported variants: ## Variant-specific Behavior -- `jupyter`: starts a managed local Jupyter server on a random port. +- `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. diff --git a/docs/docs/cli/management.mdx b/docs/docs/cli/management.mdx index 2cba521..940191f 100644 --- a/docs/docs/cli/management.mdx +++ b/docs/docs/cli/management.mdx @@ -72,7 +72,7 @@ manager.delete(info.id) Connection settings go to `get_manager` and stay out of the verbs: ```python -get_manager("jupyter", server_url="http://localhost:8888", token="...") +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("kaggle", username="...") @@ -86,7 +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 | | `docker` | a container labelled `code-sandboxes` | the name | the container (forced) | -| `jupyter` | a kernel of the Jupyter Server | — nothing in place | the kernel | +| `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 | | `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 | diff --git a/docs/docs/comparison/index.mdx b/docs/docs/comparison/index.mdx index 401c7bb..30b3e45 100644 --- a/docs/docs/comparison/index.mdx +++ b/docs/docs/comparison/index.mdx @@ -110,7 +110,7 @@ Modal is a serverless platform for running Python code in the cloud. It's design ### Code Sandboxes -Code Sandboxes provides a unified API across all supported variants (`eval`, `monty`, `docker`, `jupyter`, `kaggle`, `google-colab`, `modal`, `datalayer`), with native Jupyter kernel support. +Code Sandboxes provides a unified API across all supported variants (`eval`, `monty`, `docker`, `jupyter-server`, `kaggle`, `google-colab`, `modal`, `datalayer`), with native Jupyter kernel support. **Pros:** - Open source and self-hostable diff --git a/docs/docs/examples/index.mdx b/docs/docs/examples/index.mdx index b55750e..d3499ad 100644 --- a/docs/docs/examples/index.mdx +++ b/docs/docs/examples/index.mdx @@ -29,7 +29,7 @@ make docker ## Jupyter -- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/jupyter_sandbox_example.py +- Source: https://github.com/datalayer/code-sandboxes/blob/main/examples/jupyter_server_sandbox_example.py ```bash make jupyter diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index 2a612ea..717a37a 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -66,7 +66,7 @@ Code Sandboxes supports these execution variants: | `eval` | None (Python exec) | Development, testing | | `monty` | In-process secure interpreter | Safe, fast LLM snippets | | `docker` | Container | Isolated execution | -| `jupyter` | Process (Jupyter kernel) | Persistent notebook-style state | +| `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 | diff --git a/docs/docs/sandboxes/index.mdx b/docs/docs/sandboxes/index.mdx index 1945f84..779efec 100644 --- a/docs/docs/sandboxes/index.mdx +++ b/docs/docs/sandboxes/index.mdx @@ -11,7 +11,7 @@ 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`, `docker`, `eval`, `monty`, `kaggle`, +Canonical variant names are `jupyter-server`, `docker`, `eval`, `monty`, `kaggle`, `google_colab`, `modal`, and `datalayer`. Older `local-*` names are no longer supported. The CLI also accepts `google-colab` as an alias for `google_colab`. @@ -52,7 +52,7 @@ sandbox = Sandbox.create( Concrete implementations are available from top-level modules: ```python -from code_sandboxes.jupyter_sandbox import JupyterSandbox +from code_sandboxes.jupyter_server_sandbox import JupyterServerSandbox from code_sandboxes.docker_sandbox import DockerSandbox from code_sandboxes.eval_sandbox import EvalSandbox from code_sandboxes.datalayer_sandbox import DatalayerSandbox @@ -62,7 +62,7 @@ Each page below explains how to configure each variant. | Variant | Summary | |---------|---------| -| [`jupyter`](./jupyter) | Jupyter kernel-backed execution with persistent state | +| [`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 | | [`docker`](./docker) | Jupyter execution in a Docker container | diff --git a/docs/docs/sandboxes/jupyter.mdx b/docs/docs/sandboxes/jupyter-server.mdx similarity index 85% rename from docs/docs/sandboxes/jupyter.mdx rename to docs/docs/sandboxes/jupyter-server.mdx index 20f5882..dbe3cbe 100644 --- a/docs/docs/sandboxes/jupyter.mdx +++ b/docs/docs/sandboxes/jupyter-server.mdx @@ -26,7 +26,7 @@ kernel and persistent state across requests. ``` You can also pass the full `http://host:port/?token=...` URL as `server_url`; the token is parsed automatically. -- If you omit `server_url`, `JupyterSandbox` **starts and manages its own local +- If you omit `server_url`, `JupyterServerSandbox` **starts and manages its own local Jupyter Server** and generates the token for you — no configuration needed. ## Usage @@ -36,7 +36,7 @@ from code_sandboxes import Sandbox # Connect to an existing server: with Sandbox.create( - variant="jupyter", + variant="jupyter-server", server_url="http://localhost:8888", token="MY_TOKEN", ) as sandbox: @@ -45,12 +45,12 @@ with Sandbox.create( print(result.text) # 42 # Or let the sandbox manage a local 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 ``` The concrete implementation is available from a top-level module: ```python -from code_sandboxes.jupyter_sandbox import JupyterSandbox +from code_sandboxes.jupyter_server_sandbox import JupyterServerSandbox ``` diff --git a/docs/docs/sandboxes/kaggle.mdx b/docs/docs/sandboxes/kaggle.mdx index 8175eed..e306ad4 100644 --- a/docs/docs/sandboxes/kaggle.mdx +++ b/docs/docs/sandboxes/kaggle.mdx @@ -17,6 +17,55 @@ Runs code on Kaggle with two transparent modes: - **Batch credentials:** `~/.kaggle/kaggle.json`, `KAGGLE_API_TOKEN`, or `KAGGLE_USERNAME` / `KAGGLE_KEY`. +## Batch Sessions + +A batch job is a fresh machine, so nothing survives from one `run_code` to the +next on its own. The sandbox carries state anyway, by **replaying the code**: +every snippet that ran without error is re-executed ahead of the next one, and +only the new snippet's output is surfaced. `x = 1` in one call and `print(x)` +in the following one therefore works — at an honest price: each turn re-runs +the whole session on a machine where a job already takes about a minute, and +code with side effects (downloads, writes, randomness) runs again every turn. + +Pass `session=False` to `Sandbox.create(variant="kaggle", ...)` to turn the +replay off. A snippet that fails is not added to the session, so one bad turn +does not poison the turns after it. + +## Live Mode + +`Sandbox.create(variant="kaggle", live=True)` goes further: one batch job runs +an **agent** that starts a real ipykernel and holds it for the whole session. +The code travels over two private datasets of your account — client-to-kernel +and kernel-to-client — which is the only rendezvous both ends can reach: +Kaggle exposes no API for its interactive sessions, and this mode depends on +no service other than Kaggle itself. + +What it costs, stated plainly: + +- **Boot**: the agent is a batch job, so it queues and boots like one — + minutes, longer for a GPU. After that, a turn costs a dataset round trip: + seconds, with true state and no replay. +- **Quota**: the job runs until you close the session or it sits idle for + 30 minutes, and that time counts against your Kaggle quota. +- **Credentials in the artifact**: the agent authenticates against the + datasets API with your token, embedded in the (private) kernel source — + Kaggle offers no other way to hand credentials to a job started via the + API. The mode is opt-in for exactly this reason. +- **Phone verification required**: the agent needs internet from inside the + job to reach the dataset bus, and Kaggle grants kernels internet **only to + phone-verified accounts** — the `enable_internet` flag is accepted and then + silently ignored for everyone else, and the job dies unable to resolve + `api.kaggle.com`. Verify once at + [kaggle.com/settings](https://www.kaggle.com/settings), under *Phone + verification*. Batch mode is not affected: it needs no network from inside + the job. + +```python +with Sandbox.create(variant="kaggle", live=True) as sandbox: + sandbox.run_code("x = 1") + print(sandbox.run_code("print(x)").stdout) # 1 — same kernel +``` + ## Sandbox Usage ```python diff --git a/examples/exec/Makefile b/examples/exec/Makefile index fa03342..ea21cc7 100644 --- a/examples/exec/Makefile +++ b/examples/exec/Makefile @@ -2,9 +2,9 @@ PYTHON ?= python -.PHONY: all eval docker jupyter 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 datalayer -all: eval docker jupyter monty google-colab modal datalayer +all: eval docker jupyter-server monty google-colab modal datalayer eval: $(PYTHON) eval_sandbox_example.py @@ -12,8 +12,8 @@ eval: docker: $(PYTHON) docker_sandbox_example.py -jupyter: - $(PYTHON) jupyter_sandbox_example.py +jupyter-server: + $(PYTHON) jupyter_server_sandbox_example.py monty: $(PYTHON) monty_sandbox_example.py diff --git a/examples/exec/datalayer_sandbox_example.py b/examples/exec/datalayer_sandbox_example.py index 7ead10b..60eb3f5 100644 --- a/examples/exec/datalayer_sandbox_example.py +++ b/examples/exec/datalayer_sandbox_example.py @@ -31,7 +31,6 @@ def main() -> None: environment=first_env.name, ) as sandbox: result = show_and_run(sandbox, "print('hello from datalayer runtime')") - print("stdout:", result.stdout) except Exception as exc: print("datalayer example failed:", exc) print("Exception type:", type(exc)) diff --git a/examples/exec/docker_sandbox_example.py b/examples/exec/docker_sandbox_example.py index af8e6f0..0daa5eb 100644 --- a/examples/exec/docker_sandbox_example.py +++ b/examples/exec/docker_sandbox_example.py @@ -23,7 +23,6 @@ def main() -> None: image="code-sandboxes-jupyter:latest", ) as sandbox: result = show_and_run(sandbox, "print('hello from docker')") - print("stdout:", result.stdout) error_result = show_and_run(sandbox, "raise RuntimeError('boom')") if error_result.code_error: print( diff --git a/examples/exec/eval_sandbox_example.py b/examples/exec/eval_sandbox_example.py index 0462ebf..bf200ae 100644 --- a/examples/exec/eval_sandbox_example.py +++ b/examples/exec/eval_sandbox_example.py @@ -16,7 +16,6 @@ def main() -> None: with Sandbox.create(variant="eval", timeout=30) as sandbox: # Basic execution result = show_and_run(sandbox, "x = 21 * 2\nprint(x)") - print("stdout:", result.stdout) print("success:", result.success) # Show execution timing diff --git a/examples/exec/exec_common.py b/examples/exec/exec_common.py index 8c8efdd..95563fb 100644 --- a/examples/exec/exec_common.py +++ b/examples/exec/exec_common.py @@ -3,8 +3,10 @@ """Shared helper for exec-style sandbox examples. -Prints the code that will be executed before running it, so the output -clearly shows the snippet associated with each result. +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 @@ -12,9 +14,34 @@ from code_sandboxes import Sandbox -def show_and_run(sandbox: Sandbox, code: str, **kwargs): - """Print the code snippet, execute it on the sandbox, and return the result.""" +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}") - return sandbox.run_code(code, **kwargs) + + +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 ceeeba9..5886be4 100644 --- a/examples/exec/google_colab_sandbox_example.py +++ b/examples/exec/google_colab_sandbox_example.py @@ -37,10 +37,8 @@ def main() -> None: ) as sandbox: show_and_run(sandbox, "x = 40") result = show_and_run(sandbox, "x + 2") - print("result:", result.text) result = show_and_run(sandbox, "print('hello from colab')") - print("stdout:", result.stdout) except Exception as exc: print("colab example failed:", exc) print( diff --git a/examples/exec/jupyter_sandbox_example.py b/examples/exec/jupyter_server_sandbox_example.py similarity index 83% rename from examples/exec/jupyter_sandbox_example.py rename to examples/exec/jupyter_server_sandbox_example.py index 6663fc6..7e28daf 100644 --- a/examples/exec/jupyter_sandbox_example.py +++ b/examples/exec/jupyter_server_sandbox_example.py @@ -4,7 +4,7 @@ """Example: jupyter sandbox (Jupyter kernel isolation with persistent state). Run with: - python examples/jupyter_sandbox_example.py + python examples/jupyter_server_sandbox_example.py Note: This requires jupyter_server and jupyter-kernel-client. """ @@ -16,15 +16,13 @@ def main() -> None: try: - with Sandbox.create(variant="jupyter", timeout=30) as sandbox: + 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") - print("result:", result.text) # Should print 42 # Test stdout result = show_and_run(sandbox, "print('hello from jupyter')") - print("stdout:", result.stdout) # 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 9a38241..6ac214e 100644 --- a/examples/exec/kaggle_sandbox_example.py +++ b/examples/exec/kaggle_sandbox_example.py @@ -28,7 +28,7 @@ import argparse import os -from exec_common import show_and_run +from exec_common import show_and_run, show_code from code_sandboxes import CodeError, Sandbox @@ -99,6 +99,9 @@ def _run_batch(gpu: str | None) -> None: if gpu: print(f"accelerator requested: {gpu}") code = BATCH_SNIPPET + (GPU_PROBE if gpu else "") + # The streamed lines below ARE the result; the code is stated first so the + # output reads as submission then outcome, like every other example. + show_code(code) # A batch job queues, boots and converts the notebook: minutes, not # seconds — and a GPU job queues longer than a CPU one. timeout = 900 if gpu else 600 @@ -150,10 +153,8 @@ def _run_interactive(gpu: str | None) -> None: with Sandbox.create(variant="kaggle", timeout=60, **kwargs) as sandbox: show_and_run(sandbox, "x = 40") result = show_and_run(sandbox, "x + 2") - print("result:", result.text) result = show_and_run(sandbox, "print('hello from kaggle')") - print("stdout:", result.stdout) if gpu: probe = show_and_run(sandbox, GPU_PROBE).stdout.strip() diff --git a/examples/exec/modal_sandbox_example.py b/examples/exec/modal_sandbox_example.py index 56f9e42..84c006e 100644 --- a/examples/exec/modal_sandbox_example.py +++ b/examples/exec/modal_sandbox_example.py @@ -84,8 +84,7 @@ def main() -> None: gpu=gpu, pip_packages=["numpy"], ) as sandbox: - result = show_and_run(sandbox, "import numpy as np; print(int(np.arange(5).sum()))") - print("stdout:", result.stdout.strip()) + show_and_run(sandbox, "import numpy as np; print(int(np.arange(5).sum()))") if gpu: gpu_result = show_and_run(sandbox, _gpu_probe_code()) @@ -103,12 +102,16 @@ def main() -> None: ) print(f"GPU verified: flavor {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('modal failure example')") - if error_result.code_error: - print( - "code_error:", - f"{error_result.code_error.name}: {error_result.code_error.value}", + if error_result.code_error is None: + 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) diff --git a/examples/exec/monty_sandbox_example.py b/examples/exec/monty_sandbox_example.py index 1420a92..8e0fc04 100644 --- a/examples/exec/monty_sandbox_example.py +++ b/examples/exec/monty_sandbox_example.py @@ -19,10 +19,8 @@ def main() -> None: with Sandbox.create(variant="monty", timeout=30) as sandbox: show_and_run(sandbox, "x = 21") result = show_and_run(sandbox, "x * 2") - print("result:", result.text) result = show_and_run(sandbox, "print('hello from monty')") - print("stdout:", result.stdout) error_result = show_and_run(sandbox, "raise ValueError('monty failure example')") if error_result.code_error: diff --git a/examples/repl/Makefile b/examples/repl/Makefile index 4f586ab..c42663f 100644 --- a/examples/repl/Makefile +++ b/examples/repl/Makefile @@ -2,9 +2,9 @@ PYTHON ?= python -.PHONY: all eval docker jupyter monty google-colab kaggle modal modal-gpu datalayer +.PHONY: all eval docker jupyter-server monty google-colab kaggle kaggle-gpu kaggle-live kaggle-gpu-live modal modal-gpu datalayer -all: eval docker jupyter monty google-colab modal datalayer +all: eval docker jupyter-server monty google-colab modal datalayer eval: $(PYTHON) eval_sandbox_example.py @@ -12,8 +12,8 @@ eval: docker: $(PYTHON) docker_sandbox_example.py -jupyter: - $(PYTHON) jupyter_sandbox_example.py +jupyter-server: + $(PYTHON) jupyter_server_sandbox_example.py monty: $(PYTHON) monty_sandbox_example.py @@ -21,6 +21,18 @@ monty: google-colab: $(PYTHON) google_colab_sandbox_example.py +kaggle-live: ## Kaggle REPL on one persistent kernel (agent job + dataset bus) + @echo "==> Kaggle REPL (live: one batch job holds the kernel; boot takes minutes)" + KAGGLE_LIVE=1 $(PYTHON) kaggle_sandbox_example.py + +kaggle-gpu-live: ## Kaggle live REPL on a GPU (KAGGLE_GPU flavor, default t4) + @echo "==> Kaggle REPL (live, GPU: $${KAGGLE_GPU:-t4}; a GPU job queues longer before it boots)" + KAGGLE_LIVE=1 KAGGLE_GPU=$${KAGGLE_GPU:-t4} $(PYTHON) kaggle_sandbox_example.py + +kaggle-gpu: ## Kaggle batch REPL on a GPU (KAGGLE_GPU flavor, default t4) + @echo "==> Kaggle REPL (batch, GPU: $${KAGGLE_GPU:-t4}; each input is a batch job, and a GPU one queues longer)" + KAGGLE_GPU=$${KAGGLE_GPU:-t4} $(PYTHON) kaggle_sandbox_example.py + kaggle: ## Kaggle REPL — batch job per input by default, live session when RUNTIME_URL is set @echo "==> Kaggle REPL" @if [ -n "$$RUNTIME_URL" ]; then echo " mode: interactive (RUNTIME_URL is set)"; else echo " mode: batch (each input is a kaggle.com batch job, about a minute each)"; fi diff --git a/examples/repl/jupyter_sandbox_example.py b/examples/repl/jupyter_server_sandbox_example.py similarity index 85% rename from examples/repl/jupyter_sandbox_example.py rename to examples/repl/jupyter_server_sandbox_example.py index fdda6ff..9d68be5 100644 --- a/examples/repl/jupyter_sandbox_example.py +++ b/examples/repl/jupyter_server_sandbox_example.py @@ -10,7 +10,7 @@ def main() -> None: try: - with Sandbox.create(variant="jupyter", timeout=30) as sandbox: + with Sandbox.create(variant="jupyter-server", timeout=30) as sandbox: run_repl(sandbox) except ModuleNotFoundError as exc: print("jupyter sandbox is not available:", exc) diff --git a/examples/repl/kaggle_sandbox_example.py b/examples/repl/kaggle_sandbox_example.py index cffce56..50b3843 100644 --- a/examples/repl/kaggle_sandbox_example.py +++ b/examples/repl/kaggle_sandbox_example.py @@ -33,10 +33,43 @@ def main() -> None: kwargs = {"server_url": runtime_url, "timeout": 60} if runtime_id: kwargs["kernel_id"] = runtime_id + elif os.environ.get("KAGGLE_LIVE"): + print("mode: live — one batch job holds a real kernel, the code") + print("travels over a private dataset bus of your account.") + print("Booting the agent takes a few minutes (the job queues);") + print("each turn then costs seconds, and state persists.") + print() + print("REQUIREMENT: a phone-verified Kaggle account. The agent") + print("needs internet from inside the job to reach the dataset") + print("bus, and Kaggle grants kernels internet only after phone") + print("verification (kaggle.com/settings). Without it the job") + print("dies unable to resolve api.kaggle.com.") + kwargs = {"live": True, "timeout": 600} + gpu = os.environ.get("KAGGLE_GPU") + 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 + ) + print() + print(f"accelerator: {kwargs['gpu']} — a GPU job queues") + print("longer than a CPU one before it boots.") else: print("mode: batch — each input is a Kaggle batch job (about a") - print("minute each, stateless). Set RUNTIME_URL for a live session.") + print("minute each). State carries over: each turn replays the") + print("session's code before the new input, so `x = 1` then") + print("`print(x)` works — at the price of re-running everything") + print("each turn. Set KAGGLE_LIVE=1 for one persistent kernel.") kwargs = {"timeout": 600} + gpu = os.environ.get("KAGGLE_GPU") + if 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.") with Sandbox.create(variant="kaggle", **kwargs) as sandbox: run_repl(sandbox) diff --git a/tests/test_cli_repl.py b/tests/test_cli_repl.py index 31fe261..eb2a2f5 100644 --- a/tests/test_cli_repl.py +++ b/tests/test_cli_repl.py @@ -43,10 +43,10 @@ def _fake_create(*args, **kwargs): monkeypatch.setattr(sandbox_cli.Sandbox, "create", staticmethod(_fake_create)) - result = runner.invoke(sandbox_cli.app, ["repl", "--variant", "jupyter"], 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" + assert captured["kwargs"]["variant"] == "jupyter-server" assert captured["kwargs"]["port"] == 0 assert fake_sandbox.exited is True @@ -133,7 +133,7 @@ def _fake_create(*args, **kwargs): result = runner.invoke(sandbox_cli.app, [], input=":exit\n") assert result.exit_code == 0 - assert captured["kwargs"]["variant"] == "jupyter" + assert captured["kwargs"]["variant"] == "jupyter-server" assert captured["kwargs"]["port"] == 0 diff --git a/tests/test_factory.py b/tests/test_factory.py index aaca4a0..6d5bfcd 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -11,7 +11,7 @@ from code_sandboxes.docker_sandbox import DockerSandbox from code_sandboxes.eval_sandbox import EvalSandbox from code_sandboxes.google_colab_sandbox import GoogleColabSandbox -from code_sandboxes.jupyter_sandbox import JupyterSandbox +from code_sandboxes.jupyter_server_sandbox import JupyterServerSandbox from code_sandboxes.kaggle_sandbox import KaggleSandbox from code_sandboxes.modal_sandbox import ModalSandbox from code_sandboxes.models import SandboxConfig @@ -33,7 +33,7 @@ def test_create_local_jupyter(self): sandbox = Sandbox.create(variant=SandboxVariant.JUPYTER) assert sandbox is not None - assert isinstance(sandbox, JupyterSandbox) + assert isinstance(sandbox, JupyterServerSandbox) def test_create_with_config(self): """Test creating sandbox with config.""" @@ -67,7 +67,7 @@ def test_create_invalid_variant(self): "variant,expected_type", [ ("eval", EvalSandbox), - ("jupyter", JupyterSandbox), + ("jupyter-server", JupyterServerSandbox), ("docker", DockerSandbox), ("datalayer", DatalayerSandbox), ("google_colab", GoogleColabSandbox), diff --git a/tests/test_jupyter.py b/tests/test_jupyter_server.py similarity index 92% rename from tests/test_jupyter.py rename to tests/test_jupyter_server.py index d8b9b6d..6c45161 100644 --- a/tests/test_jupyter.py +++ b/tests/test_jupyter_server.py @@ -12,7 +12,7 @@ import pytest -from code_sandboxes.jupyter_sandbox import JupyterSandbox +from code_sandboxes.jupyter_server_sandbox import JupyterServerSandbox from code_sandboxes.models import SandboxConfig @@ -41,7 +41,7 @@ def stop(self): types.SimpleNamespace(JupyterKernelClient=_KernelClientStub), ) - sandbox = JupyterSandbox( + sandbox = JupyterServerSandbox( server_url="http://localhost:8888", kernel_id="explicit-kernel", reuse_kernel=True, @@ -83,7 +83,7 @@ def stop(self): types.SimpleNamespace(JupyterKernelClient=_KernelClientStub), ) - sandbox = JupyterSandbox( + sandbox = JupyterServerSandbox( server_url="http://localhost:8888", kernel_id=None, reuse_kernel=False, @@ -104,7 +104,7 @@ def _should_not_be_called(): def test_kernel_client_forwards_client_kwargs(monkeypatch, tmp_path: Path): - """JupyterSandbox forwards client_kwargs to JupyterKernelClient.""" + """JupyterServerSandbox forwards client_kwargs to JupyterKernelClient.""" captured: dict[str, object] = {} @@ -130,7 +130,7 @@ def stop(self): notebook_path = str(tmp_path / "notebook.ipynb") - sandbox = JupyterSandbox( + sandbox = JupyterServerSandbox( server_url="http://localhost:8888", kernel_id="kernel-1", kernel_path=notebook_path, @@ -149,8 +149,8 @@ def stop(self): sandbox.stop() -class TestJupyterSandbox: - """Tests for JupyterSandbox.""" +class TestJupyterServerSandbox: + """Tests for JupyterServerSandbox.""" def test_local_jupyter_persistence(self, tmp_path: Path): """Test persistence across requests in jupyter sandbox.""" @@ -161,7 +161,7 @@ def test_local_jupyter_persistence(self, tmp_path: Path): except Exception: pytest.skip("jupyter_server is not available") - sandbox = JupyterSandbox(config=SandboxConfig(working_dir=str(tmp_path))) + sandbox = JupyterServerSandbox(config=SandboxConfig(working_dir=str(tmp_path))) try: sandbox.start() except Exception as exc: @@ -207,7 +207,7 @@ def test_headers_are_forwarded_to_the_kernel_client(monkeypatch): ) auth_headers = {"Cookie": "username-localhost=abc; _xsrf=tok", "X-XSRFToken": "tok"} - sandbox = JupyterSandbox( + sandbox = JupyterServerSandbox( server_url="http://localhost:8888", token=None, kernel_id="kernel-1", @@ -235,7 +235,7 @@ def test_no_headers_kwarg_when_none_supplied(monkeypatch): ) credential = uuid.uuid4().hex - sandbox = JupyterSandbox( + sandbox = JupyterServerSandbox( server_url="http://localhost:8888", token=credential, kernel_id="kernel-1", @@ -259,7 +259,7 @@ def test_external_server_keeps_token_none(): would put a credential the server never issued on every request. """ - sandbox = JupyterSandbox(server_url="http://localhost:8888", token=None) + sandbox = JupyterServerSandbox(server_url="http://localhost:8888", token=None) assert sandbox._token is None @@ -267,6 +267,6 @@ def test_external_server_keeps_token_none(): def test_owned_server_still_generates_a_token(): """A sandbox that starts its own server still needs a token to secure it.""" - sandbox = JupyterSandbox() + sandbox = JupyterServerSandbox() assert sandbox._token diff --git a/tests/test_kaggle_live.py b/tests/test_kaggle_live.py new file mode 100644 index 0000000..956ba0a --- /dev/null +++ b/tests/test_kaggle_live.py @@ -0,0 +1,132 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# Distributed under the terms of the Modified BSD License. + +"""The live Kaggle session: the dataset bus, faked end to end.""" + +import ast +import json +from pathlib import Path + +from code_sandboxes.kaggle_live import KaggleLiveSession, build_agent_code + + +class FakeApi: + """Kaggle's dataset API, as a pair of in-memory mailboxes.""" + + def __init__(self): + self.store = {} + + def _absorb(self, folder): + meta = json.loads((Path(folder) / "dataset-metadata.json").read_text()) + message = json.loads((Path(folder) / "message.json").read_text()) + self.store[meta["id"]] = message + + def dataset_create_new(self, folder, public=False, quiet=True): + assert public is False, "the bus must be private" + self._absorb(folder) + + def dataset_create_version(self, folder, version_notes="", quiet=True): + self._absorb(folder) + + def dataset_download_files(self, ref, path, force=True, unzip=True): + if ref not in self.store: + raise FileNotFoundError(ref) + (Path(path) / "message.json").write_text(json.dumps(self.store[ref])) + + +class FakeExecutor: + """The batch executor: records the submission, boots a fake agent.""" + + def __init__(self, api): + self.api = api + self.submissions = [] + + def execute(self, code, **kwargs): + self.submissions.append({"code": code, **kwargs}) + # The agent's first act is the ready handshake. + ref = [line for line in code.splitlines() if "k2c" in line] + assert ref, "the agent must know its outbox" + + class Submitted: + slug = "user/agent" + + return Submitted() + + +def _ready(session): + # Stand in for the booted agent: publish the handshake. + session._api.store[session._k2c] = {"seq": 0, "reply": {"status": "ok", "outputs": []}} + + +def test_start_creates_a_private_bus_and_waits_for_the_agent(monkeypatch): + # The credentials come from the environment of the TEST, never the host's. + monkeypatch.setenv("KAGGLE_USERNAME", "user") + monkeypatch.setenv("KAGGLE_KEY", "key") + monkeypatch.delenv("KAGGLE_API_TOKEN", raising=False) + api = FakeApi() + session = KaggleLiveSession(FakeExecutor(api), api=api, poll_seconds=0.01) + # The handshake arrives while start() polls: plant it up front by hooking + # the executor's submission, which happens before the wait. + original = session._executor.execute + + def execute(code, **kwargs): + result = original(code, **kwargs) + _ready(session) + return result + + session._executor.execute = execute + session.start(ready_timeout=1) + assert session._c2k.endswith("-c2k") and session._k2c.endswith("-k2c") + assert api.store[session._c2k] == {"seq": 0} + + +def test_execute_round_trips_over_the_bus(): + api = FakeApi() + session = KaggleLiveSession(FakeExecutor(api), api=api, poll_seconds=0.01) + session._c2k = "user/bus-c2k" + session._k2c = "user/bus-k2c" + api.store[session._c2k] = {"seq": 0} + + real_read = session._read_bus + + def read(ref): + # The fake agent answers the moment it sees the new sequence. + inbox = api.store.get(session._c2k, {}) + 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"} + ]}, + } + return real_read(ref) + + session._read_bus = read + reply = session.execute("print(42)", timeout=1) + assert reply["status"] == "ok" + assert reply["outputs"][0]["text"] == "42\n" + + +def test_the_generated_agent_is_valid_python(): + agent = build_agent_code( + {"KAGGLE_USERNAME": "user", "KAGGLE_KEY": "key"}, + "user/a-c2k", + "user/a-k2c", + ) + ast.parse(agent) + assert "user/a-c2k" in agent and "user/a-k2c" in agent + # The credentials ride in the settings blob, nowhere else. + assert "KAGGLE_KEY" in agent + + +def test_the_agent_carries_an_access_token_verbatim(): + # A KGAT access token authenticates the agent's kaggle client through + # the same variable it was given in — no username/key pair involved. + agent = build_agent_code( + {"KAGGLE_API_TOKEN": "KGAT_abc123"}, + "user/a-c2k", + "user/a-k2c", + ) + ast.parse(agent) + assert "KGAT_abc123" in agent + assert "KAGGLE_KEY" not in agent diff --git a/tests/test_kaggle_session.py b/tests/test_kaggle_session.py new file mode 100644 index 0000000..be751f2 --- /dev/null +++ b/tests/test_kaggle_session.py @@ -0,0 +1,49 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# Distributed under the terms of the Modified BSD License. + +"""The replay-based session of the Kaggle batch mode.""" + + +class TestKaggleBatchSession: + """The batch session: state carried by replaying the code that ran.""" + + def _sandbox(self, **kwargs): + from code_sandboxes.kaggle_sandbox import KaggleSandbox + + return KaggleSandbox(**kwargs) + + def test_prelude_replays_history_behind_a_sentinel(self): + sandbox = self._sandbox() + sandbox._session_history = ["x = 1"] + full, marker = sandbox._session_prelude("print(x)") + assert marker is not None + assert full.startswith("x = 1\n") + assert full.endswith("\nprint(x)") + assert repr(marker) in full + + def test_prelude_is_plain_on_the_first_turn_and_when_off(self): + sandbox = self._sandbox() + assert sandbox._session_prelude("x = 1") == ("x = 1", None) + off = self._sandbox(session=False) + off._session_history = ["x = 1"] + assert off._session_prelude("print(x)") == ("print(x)", None) + + def test_replayed_outputs_are_cut_at_the_sentinel(self): + sandbox = self._sandbox() + marker = "<>" + reply = { + "outputs": [ + {"output_type": "stream", "name": "stdout", "text": "old\n"}, + {"output_type": "stream", "name": "stdout", "text": f"{marker}\nnew\n"}, + {"output_type": "execute_result", "data": {"text/plain": "2"}}, + ] + } + cut = sandbox._cut_replayed_outputs(reply, marker) + texts = [o.get("text") for o in cut["outputs"] if o["output_type"] == "stream"] + assert texts == ["new\n"] + assert cut["outputs"][-1]["output_type"] == "execute_result" + + 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 diff --git a/tests/test_manage.py b/tests/test_manage.py index 0750051..b48df96 100644 --- a/tests/test_manage.py +++ b/tests/test_manage.py @@ -26,7 +26,7 @@ def test_every_variant_has_a_manager(): "docker", "eval", "google_colab", - "jupyter", + "jupyter-server", "kaggle", "modal", "monty", diff --git a/tests/test_modal_session.py b/tests/test_modal_session.py new file mode 100644 index 0000000..e42d12d --- /dev/null +++ b/tests/test_modal_session.py @@ -0,0 +1,56 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# Distributed under the terms of the Modified BSD License. + +"""The Modal session driver, run as a real subprocess.""" + +import json +import re +import subprocess +import sys + + +def _driver_source() -> str: + text = open("code_sandboxes/modal_sandbox.py").read() + match = re.search(r'_DRIVER_SOURCE = """(.*?)"""', text, re.S) + assert match + return match.group(1) + + +def _speak(requests): + stdin = "".join(json.dumps(r) + "\n" for r in requests) + completed = subprocess.run( + [sys.executable, "-u", "-c", _driver_source()], + input=stdin, + capture_output=True, + text=True, + timeout=30, + ) + return [json.loads(line) for line in completed.stdout.splitlines() if line.strip()] + + +def test_state_survives_between_requests(): + 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')"}, + ]) + assert replies[0]["stdout"] == "hello\n" + assert replies[1]["status"] == "error" + assert replies[1]["error"]["name"] == "ZeroDivisionError" + # One failing request does not take the session down. + assert replies[2]["stdout"] == "still alive\n" + + +def test_trailing_expression_answers_like_a_repl(): + replies = _speak([{"seq": 1, "code": "y = 20\ny * 2 + 2"}]) + assert replies[0]["result"] == "42" diff --git a/tests/test_models.py b/tests/test_models.py index 8c88372..35bcb26 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -43,7 +43,7 @@ def test_sandbox_variant_enum(self): """Test SandboxVariant enum values.""" assert SandboxVariantEnum.EVAL.value == "eval" assert SandboxVariantEnum.DOCKER.value == "docker" - assert SandboxVariantEnum.JUPYTER.value == "jupyter" + assert SandboxVariantEnum.JUPYTER.value == "jupyter-server" assert SandboxVariantEnum.DATALAYER.value == "datalayer" assert SandboxVariantEnum.GOOGLE_COLAB.value == "google_colab" assert SandboxVariantEnum.KAGGLE.value == "kaggle" diff --git a/tests/test_sandboxes.py b/tests/test_sandboxes.py index 6a3ca13..7875cbc 100644 --- a/tests/test_sandboxes.py +++ b/tests/test_sandboxes.py @@ -41,7 +41,7 @@ def test_sandbox_variant_enum(self): """Test SandboxVariant enum values.""" assert SandboxVariantEnum.EVAL.value == "eval" assert SandboxVariantEnum.DOCKER.value == "docker" - assert SandboxVariantEnum.JUPYTER.value == "jupyter" + assert SandboxVariantEnum.JUPYTER.value == "jupyter-server" assert SandboxVariantEnum.DATALAYER.value == "datalayer" def test_gpu_type_enum(self): @@ -679,7 +679,7 @@ def test_create_local_jupyter(self): sandbox = Sandbox.create(variant=SandboxVariant.JUPYTER) assert sandbox is not None - assert isinstance(sandbox, JupyterSandbox) + assert isinstance(sandbox, JupyterServerSandbox) def test_create_with_config(self): """Test creating sandbox with config.""" @@ -715,8 +715,8 @@ def test_create_invalid_variant(self): # ============================================================================= -class TestJupyterSandbox: - """Tests for JupyterSandbox.""" +class TestJupyterServerSandbox: + """Tests for JupyterServerSandbox.""" def test_local_jupyter_persistence(self, tmp_path: Path): """Test persistence across requests in jupyter sandbox.""" @@ -727,7 +727,7 @@ def test_local_jupyter_persistence(self, tmp_path: Path): except Exception: pytest.skip("jupyter_server is not available") - sandbox = JupyterSandbox(config=SandboxConfig(working_dir=str(tmp_path))) + sandbox = JupyterServerSandbox(config=SandboxConfig(working_dir=str(tmp_path))) try: sandbox.start() except Exception as exc: From 4aec13b2977fbb77f9f0a9662bf7aef93935b316 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 20 Aug 2026 04:30:04 +0200 Subject: [PATCH 3/5] jkc --- tests/test_kernel_client_compatibility.py | 174 ++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 tests/test_kernel_client_compatibility.py diff --git a/tests/test_kernel_client_compatibility.py b/tests/test_kernel_client_compatibility.py new file mode 100644 index 0000000..5ffb21e --- /dev/null +++ b/tests/test_kernel_client_compatibility.py @@ -0,0 +1,174 @@ +# Copyright (c) 2023-2025 Datalayer, Inc. +# Distributed under the terms of the Modified BSD License. + +"""A sandbox stays fully accessible from a jupyter kernel client. + +The kernel-backed sandboxes hold a ``jupyter_kernel_client.JupyterKernelClient`` +and expose it whole through ``Sandbox.kernel_client`` — a caller that needs +the low-level kernel API gets the same client the sandbox uses internally. +That only holds while the two packages agree, and they live in different +distributions: a rename or a signature change on either side breaks sandboxes +at RUNTIME unless something breaks EARLIER. These tests are that something. + +Three claims, one test class each: + +- The real client satisfies ``ISandboxClient``, the contract the sandboxes + program against — so it can back any kernel-backed variant. +- Every call a sandbox actually makes on its client binds against the real + client's signatures — the calls are checked, not just the names. +- The sandboxes expose the client (``kernel_client``), and the stand-ins that + duck-type it (the Kaggle live session) answer the same calls. +""" + +from __future__ import annotations + +import inspect + +import pytest + +jupyter_kernel_client = pytest.importorskip("jupyter_kernel_client") + +from jupyter_kernel_client import JupyterKernelClient # noqa: E402 +from jupyter_kernel_client.interfaces import IJupyterKernelClient # noqa: E402 + +from code_sandboxes.interfaces import ISandboxClient # noqa: E402 + + +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("_") + ) + + +def _binds(method, *args, **kwargs) -> None: + """Assert the real method accepts this exact call shape.""" + signature = inspect.signature(method) + signature.bind(*args, **kwargs) # raises TypeError when incompatible + + +class TestTheRealClientSatisfiesTheSandboxContract: + """`JupyterKernelClient` is an `ISandboxClient`, member by member.""" + + def test_every_member_of_the_contract_exists_on_the_client(self): + missing = [ + name + for name in _protocol_members(ISandboxClient) + if not hasattr(JupyterKernelClient, name) + ] + assert missing == [], ( + "The jupyter kernel client no longer offers what the sandboxes " + f"program against: {missing}" + ) + + def test_the_contract_demands_nothing_the_client_does_not_promise(self): + """`ISandboxClient` stays a subset of the client's PUBLIC protocol. + + The sandboxes must be drivable through the documented client + interface alone — never through something private the client happens + to have today. + """ + public = set(_protocol_members(IJupyterKernelClient)) + 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}" + ) + + def test_the_client_satisfies_its_own_public_protocol(self): + missing = [ + name + for name in _protocol_members(IJupyterKernelClient) + if not hasattr(JupyterKernelClient, name) + ] + assert missing == [] + + +class TestTheCallsTheSandboxesMakeBind: + """Each call a sandbox makes on its client fits the real signature. + + The shapes below are the ones found in the sandbox sources — change a + call there and this list is the reminder to keep them in step. + """ + + def test_execute_with_code_and_timeout(self): + _binds(JupyterKernelClient.execute, None, "print(1)", timeout=60.0) + + def test_execute_interactive_with_output_hook(self): + _binds( + JupyterKernelClient.execute_interactive, + None, + "print(1)", + output_hook=lambda msg: None, + timeout=None, + ) + + def test_start_bare_and_with_a_path(self): + _binds(JupyterKernelClient.start, None) + _binds(JupyterKernelClient.start, None, path="notebooks/analysis") + + def test_stop_bare_and_keeping_the_kernel(self): + _binds(JupyterKernelClient.stop, None) + _binds(JupyterKernelClient.stop, None, shutdown_kernel=False) + + def test_variables_by_name(self): + _binds(JupyterKernelClient.get_variable, None, "x") + _binds(JupyterKernelClient.set_variable, None, "x", 1) + + 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 + ) + + +class TestTheSandboxesExposeTheClient: + """`Sandbox.kernel_client` hands the client out, whole.""" + + def test_the_base_declares_the_accessor(self): + from code_sandboxes import Sandbox + + 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 + # promised here is the PUBLIC surface, not the module layout. + from code_sandboxes import ( + GoogleColabSandbox, + JupyterServerSandbox, + KaggleSandbox, + Sandbox, + ) + + base = inspect.getattr_static(Sandbox, "kernel_client") + for variant in (JupyterServerSandbox, KaggleSandbox, GoogleColabSandbox): + own = inspect.getattr_static(variant, "kernel_client") + assert own is not base, f"{variant.__name__} hides its client" + + def test_the_kaggle_live_session_answers_the_same_calls(self): + """The live session stands in for the client on the interactive path. + + It duck-types what `KaggleSandbox.run_code` uses — `execute`, `stop`, + `id`, `get_variable` — with call shapes the real client also accepts, + so the sandbox code cannot tell the two apart. + """ + from code_sandboxes.kaggle_live import KaggleLiveSession + + _binds(KaggleLiveSession.execute, None, "print(1)", timeout=60.0) + _binds(KaggleLiveSession.stop, None) + _binds(KaggleLiveSession.stop, None, shutdown_kernel=False) + _binds(KaggleLiveSession.get_variable, None, "x") + + session = KaggleLiveSession(executor=object()) + assert isinstance(session.id, str) and session.id From 0311b801048eeadde68c4e65965c9dcb672f8611 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 20 Aug 2026 15:28:21 +0200 Subject: [PATCH 4/5] 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 d3ceafb..4fde1de 100644 --- a/code_sandboxes/__version__.py +++ b/code_sandboxes/__version__.py @@ -3,4 +3,4 @@ """Code Sandboxes.""" -__version__ = "1.0.9" +__version__ = "1.0.10" From 0292e64c273688a44b536695e56bf5589d4d6a68 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Thu, 20 Aug 2026 18:23:59 +0200 Subject: [PATCH 5/5] lint --- code_sandboxes/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index 48bbeed..4a38cf2 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -327,8 +327,8 @@ def create( # noqa: C901 else: raise ValueError( f"Unknown sandbox variant: {variant}. " - "Supported variants: eval, docker, jupyter, " - "datalayer, google_colab, kaggle, monty, modal" + "Supported variants: " + + ", ".join(sorted(v.value for v in SandboxVariant)) ) # Set tags if provided