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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Code Sandboxes (`code_sandboxes`) is a Python package for running code in isolated sandbox variants through a unified API.

Canonical variant names:

- `jupyter`
- `docker`
- `eval`
Expand All @@ -37,6 +38,7 @@ The full documentation is the single source of truth:
- Comparison: [https://code-sandboxes.datalayer.tech/comparison](https://code-sandboxes.datalayer.tech/comparison)

Published site:

- [https://code-sandboxes.datalayer.tech](https://code-sandboxes.datalayer.tech)

## Install
Expand Down
3 changes: 3 additions & 0 deletions code_sandboxes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
SandboxFileHandle,
SandboxFilesystem,
)
from .interfaces import IJupyterKernelClient, ISandboxClient
from .jupyter_sandbox import JupyterSandbox
from .kaggle_sandbox import KaggleSandbox
from .modal_sandbox import ModalSandbox
Expand Down Expand Up @@ -129,6 +130,8 @@
"FileWatchEvent",
"FileWatchEventType",
"GPUType",
"IJupyterKernelClient",
"ISandboxClient",
"JupyterSandbox",
"KaggleSandbox",
"Logs",
Expand Down
2 changes: 1 addition & 1 deletion code_sandboxes/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@

"""Code Sandboxes."""

__version__ = "0.0.21"
__version__ = "0.16.0"
6 changes: 6 additions & 0 deletions code_sandboxes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from .commands import SandboxCommands
from .filesystem import SandboxFilesystem
from .interfaces import ISandboxClient
from .models import (
CodeError,
Context,
Expand Down Expand Up @@ -100,6 +101,11 @@ def is_executing(self) -> bool:
"""Check if the sandbox is currently executing code."""
return self._executing_event.is_set()

@property
def kernel_client(self) -> ISandboxClient | None:
"""Expose an optional kernel client interface for kernel-backed variants."""
return None

def interrupt(self) -> bool:
"""Request interruption of the currently running code.

Expand Down
6 changes: 6 additions & 0 deletions code_sandboxes/colab_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from .base import Sandbox
from .exceptions import SandboxConfigurationError, SandboxNotStartedError
from .interfaces import ISandboxClient
from .models import (
CodeError,
Context,
Expand Down Expand Up @@ -143,6 +144,11 @@ def start(self) -> None:
)
self._started = True

@property
def kernel_client(self) -> ISandboxClient | None:
"""The underlying Colab kernel client, if started."""
return self._client

def _setup_tool_caller(self) -> None:
"""Keep tool calling on the client side for Colab sandboxes."""
return
Expand Down
10 changes: 8 additions & 2 deletions code_sandboxes/docker_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from .base import Sandbox
from .exceptions import SandboxConfigurationError, SandboxNotStartedError
from .interfaces import ISandboxClient
from .models import (
CodeError,
Context,
Expand Down Expand Up @@ -124,7 +125,7 @@ def start(self) -> None:

self._ensure_docker()
try:
from jupyter_kernel_client import KernelClient
from jupyter_kernel_client import JupyterKernelClient
except ImportError as exc: # pragma: no cover - optional dependency
raise SandboxConfigurationError(
"jupyter-kernel-client is required for DockerSandbox. "
Expand Down Expand Up @@ -167,7 +168,7 @@ def start(self) -> None:

self._wait_for_server(timeout=self.config.timeout or 30.0)

self._client = KernelClient(server_url=self._server_url, token=self._token)
self._client = JupyterKernelClient(server_url=self._server_url, token=self._token)
self._client.start()

self._default_context = self.create_context("default")
Expand All @@ -186,6 +187,11 @@ def start(self) -> None:
)
self._started = True

@property
def kernel_client(self) -> ISandboxClient | None:
"""The underlying kernel client for this sandbox, if started."""
return self._client

def stop(self) -> None:
if not self._started:
return
Expand Down
20 changes: 20 additions & 0 deletions code_sandboxes/interfaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright (c) 2025-2026 Datalayer, Inc.
#
# BSD 3-Clause License

"""Typing protocols for sandbox clients."""

from __future__ import annotations

from typing import Protocol, runtime_checkable

from jupyter_kernel_client.interfaces import IJupyterKernelClient


@runtime_checkable
class ISandboxClient(IJupyterKernelClient, Protocol):
"""Kernel client protocol exposed by sandbox variants.

This currently matches ``IJupyterKernelClient`` exactly and acts as an extension
point for sandbox-specific client capabilities.
"""
13 changes: 7 additions & 6 deletions code_sandboxes/jupyter_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

from .base import Sandbox
from .exceptions import SandboxConfigurationError, SandboxNotStartedError
from .interfaces import ISandboxClient
from .models import (
CodeError,
Context,
Expand Down Expand Up @@ -88,7 +89,7 @@ def __init__(
self._server_app = None
self._server_thread: threading.Thread | None = None
self._server_process: subprocess.Popen | None = None
self._client = None
self._client: ISandboxClient | None = None
self._sandbox_id = str(uuid.uuid4())
self._workdir: str | None = None
self._workdir_tmp: str | None = None
Expand Down Expand Up @@ -339,7 +340,7 @@ def start(self) -> None:
return

try:
from jupyter_kernel_client import KernelClient
from jupyter_kernel_client import JupyterKernelClient
except ImportError as exc:
raise SandboxConfigurationError(
"jupyter-kernel-client is required for JupyterSandbox. "
Expand All @@ -363,7 +364,7 @@ def start(self) -> None:
else:
kernel_id = None

self._client = KernelClient(
self._client = JupyterKernelClient(
server_url=self._server_url,
token=self._token,
kernel_id=kernel_id,
Expand All @@ -385,8 +386,8 @@ def start(self) -> None:
self._started = True

@property
def kernel_client(self):
"""The underlying ``jupyter_kernel_client.KernelClient``.
def kernel_client(self) -> ISandboxClient | None:
"""The underlying ``jupyter_kernel_client.JupyterKernelClient``.

Exposed so callers that need the full low-level kernel API (for
example streaming execution via ``execute_interactive``) can delegate
Expand Down Expand Up @@ -462,7 +463,7 @@ def _do_interrupt(self) -> bool:
if not self._server_url or not self._client:
return False
try:
# KernelClient exposes the kernel ID as the `.id` property
# JupyterKernelClient exposes the kernel ID as the `.id` property
kernel_id = getattr(self._client, "id", None)
if kernel_id:
resp = requests.post(
Expand Down
6 changes: 6 additions & 0 deletions code_sandboxes/kaggle_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

from .base import Sandbox
from .exceptions import SandboxConfigurationError, SandboxNotStartedError
from .interfaces import ISandboxClient
from .models import (
CodeError,
Context,
Expand Down Expand Up @@ -182,6 +183,11 @@ def start(self) -> None:
)
self._started = True

@property
def kernel_client(self) -> ISandboxClient | None:
"""The underlying Kaggle kernel client, if started."""
return self._client

def _setup_tool_caller(self) -> None:
"""Keep tool calling on the client side for Kaggle sandboxes."""
return
Expand Down
8 changes: 4 additions & 4 deletions tests/test_jupyter.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def stop(self):
monkeypatch.setitem(
sys.modules,
"jupyter_kernel_client",
types.SimpleNamespace(KernelClient=_KernelClientStub),
types.SimpleNamespace(JupyterKernelClient=_KernelClientStub),
)

sandbox = JupyterSandbox(
Expand Down Expand Up @@ -77,7 +77,7 @@ def stop(self):
monkeypatch.setitem(
sys.modules,
"jupyter_kernel_client",
types.SimpleNamespace(KernelClient=_KernelClientStub),
types.SimpleNamespace(JupyterKernelClient=_KernelClientStub),
)

sandbox = JupyterSandbox(
Expand All @@ -101,7 +101,7 @@ def _should_not_be_called():


def test_kernel_client_forwards_client_kwargs(monkeypatch, tmp_path: Path):
"""JupyterSandbox forwards client_kwargs to KernelClient."""
"""JupyterSandbox forwards client_kwargs to JupyterKernelClient."""

captured: dict[str, object] = {}

Expand All @@ -121,7 +121,7 @@ def stop(self):
monkeypatch.setitem(
sys.modules,
"jupyter_kernel_client",
types.SimpleNamespace(KernelClient=_KernelClientStub),
types.SimpleNamespace(JupyterKernelClient=_KernelClientStub),
)

notebook_path = str(tmp_path / "notebook.ipynb")
Expand Down