diff --git a/README.md b/README.md index 7f4c835..5caa9e0 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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 diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index 834802b..2113a3f 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -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 @@ -129,6 +130,8 @@ "FileWatchEvent", "FileWatchEventType", "GPUType", + "IJupyterKernelClient", + "ISandboxClient", "JupyterSandbox", "KaggleSandbox", "Logs", diff --git a/code_sandboxes/__version__.py b/code_sandboxes/__version__.py index d9bd733..94543f4 100644 --- a/code_sandboxes/__version__.py +++ b/code_sandboxes/__version__.py @@ -3,4 +3,4 @@ """Code Sandboxes.""" -__version__ = "0.0.21" +__version__ = "0.16.0" diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index dda4b2d..0a99a9d 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -14,6 +14,7 @@ from .commands import SandboxCommands from .filesystem import SandboxFilesystem +from .interfaces import ISandboxClient from .models import ( CodeError, Context, @@ -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. diff --git a/code_sandboxes/colab_sandbox.py b/code_sandboxes/colab_sandbox.py index d885658..64adaa2 100644 --- a/code_sandboxes/colab_sandbox.py +++ b/code_sandboxes/colab_sandbox.py @@ -21,6 +21,7 @@ from .base import Sandbox from .exceptions import SandboxConfigurationError, SandboxNotStartedError +from .interfaces import ISandboxClient from .models import ( CodeError, Context, @@ -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 diff --git a/code_sandboxes/docker_sandbox.py b/code_sandboxes/docker_sandbox.py index 54cb09d..be03f15 100644 --- a/code_sandboxes/docker_sandbox.py +++ b/code_sandboxes/docker_sandbox.py @@ -19,6 +19,7 @@ from .base import Sandbox from .exceptions import SandboxConfigurationError, SandboxNotStartedError +from .interfaces import ISandboxClient from .models import ( CodeError, Context, @@ -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. " @@ -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") @@ -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 diff --git a/code_sandboxes/interfaces.py b/code_sandboxes/interfaces.py new file mode 100644 index 0000000..bc3a1db --- /dev/null +++ b/code_sandboxes/interfaces.py @@ -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. + """ diff --git a/code_sandboxes/jupyter_sandbox.py b/code_sandboxes/jupyter_sandbox.py index ad9ea36..09e998a 100644 --- a/code_sandboxes/jupyter_sandbox.py +++ b/code_sandboxes/jupyter_sandbox.py @@ -27,6 +27,7 @@ from .base import Sandbox from .exceptions import SandboxConfigurationError, SandboxNotStartedError +from .interfaces import ISandboxClient from .models import ( CodeError, Context, @@ -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 @@ -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. " @@ -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, @@ -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 @@ -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( diff --git a/code_sandboxes/kaggle_sandbox.py b/code_sandboxes/kaggle_sandbox.py index f33fb90..a470734 100644 --- a/code_sandboxes/kaggle_sandbox.py +++ b/code_sandboxes/kaggle_sandbox.py @@ -35,6 +35,7 @@ from .base import Sandbox from .exceptions import SandboxConfigurationError, SandboxNotStartedError +from .interfaces import ISandboxClient from .models import ( CodeError, Context, @@ -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 diff --git a/tests/test_jupyter.py b/tests/test_jupyter.py index e7d0e4d..3fba7c1 100644 --- a/tests/test_jupyter.py +++ b/tests/test_jupyter.py @@ -36,7 +36,7 @@ def stop(self): monkeypatch.setitem( sys.modules, "jupyter_kernel_client", - types.SimpleNamespace(KernelClient=_KernelClientStub), + types.SimpleNamespace(JupyterKernelClient=_KernelClientStub), ) sandbox = JupyterSandbox( @@ -77,7 +77,7 @@ def stop(self): monkeypatch.setitem( sys.modules, "jupyter_kernel_client", - types.SimpleNamespace(KernelClient=_KernelClientStub), + types.SimpleNamespace(JupyterKernelClient=_KernelClientStub), ) sandbox = JupyterSandbox( @@ -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] = {} @@ -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")