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
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,81 @@ export KAGGLE_API_KEY="<your-kaggle-api-key>"
sandbox repl --variant kaggle
```

### Kaggle

Kaggle supports both batch execution and interactive connections through the
`kaggle` sandbox. Install its optional dependency first:

```bash
pip install "code-sandboxes[kaggle]"
```

For batch execution, configure Kaggle credentials and create the sandbox
without a runtime URL:

```python
from code_sandboxes import Sandbox

with Sandbox.create(variant="kaggle") as sandbox:
result = sandbox.run_code("print('hello from kaggle')")
print(result.stdout)
```

The lower-level batch API is also available directly:

```python
from code_sandboxes import KaggleKernelExecutor

executor = KaggleKernelExecutor()
result = executor.execute(
"print('hello from kaggle')",
title="code-sandboxes-demo",
accelerator="NvidiaTeslaT4",
wait=True,
)
print(result.status, result.stdout)
print(result.to_kernel_reply())
```

For interactive execution, copy the WebSocket channels URL from an active
Kaggle notebook session and pass it to the sandbox or client:

```python
from code_sandboxes import KaggleKernelClient

with KaggleKernelClient.from_channels_url(channels_url, token=None) as kernel:
print(kernel.execute("x = 1 + 1; print(x)"))
```

See the [complete Kaggle guide](docs/docs/sandboxes/kaggle.mdx) for authentication,
accelerators, channels URL retrieval, and execution options.

### Google Colab

Google Colab exposes an already-running kernel through an authenticating proxy.
Copy its WebSocket channels URL from the browser's Network tools, then pass it
directly to the sandbox:

```python
from code_sandboxes import Sandbox

with Sandbox.create(variant="colab", channels_url=channels_url) as sandbox:
print(sandbox.run_code("x = 1 + 1; print(x)").stdout)
```

The lower-level client and parser are owned by Code Sandboxes as well:

```python
from code_sandboxes import ColabKernelClient, parse_colab_channels_url

server_url, kernel_id, proxy_token = parse_colab_channels_url(channels_url)
with ColabKernelClient.from_channels_url(channels_url) as kernel:
print(kernel.execute("print('hello from colab')"))
```

See the [complete Google Colab guide](docs/docs/sandboxes/google-colab.mdx) for
proxy authentication, explicit connection values, and channels URL retrieval.

For full setup and parameters for all variants, see:

- [https://code-sandboxes.datalayer.tech/sandboxes](https://code-sandboxes.datalayer.tech/sandboxes)
Expand Down
17 changes: 13 additions & 4 deletions code_sandboxes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
"""

from .base import Sandbox
from .client import CodeExecutionOutcome, CodeSandboxClient
from .client import CodeExecutionOutcome, CodeSandboxClient, execution_result_to_reply
from .colab import ColabKernelClient, parse_colab_channels_url
from .colab_sandbox import ColabSandbox
from .commands import CommandResult, ProcessHandle, SandboxCommands
from .datalayer_sandbox import DatalayerSandbox
Expand Down Expand Up @@ -86,8 +87,10 @@
SandboxFileHandle,
SandboxFilesystem,
)
from .interfaces import IJupyterKernelClient, ISandboxClient
from .interfaces import ISandboxClient
from .jupyter_sandbox import JupyterSandbox
from .kaggle import KAGGLE_API_TOKEN_ENV, KaggleKernelClient, parse_kaggle_channels_url
from .kaggle_execute import KaggleExecutionResult, KaggleKernelExecutor
from .kaggle_sandbox import KaggleSandbox
from .modal_sandbox import ModalSandbox
from .models import (
Expand All @@ -112,10 +115,12 @@
from .monty_sandbox import MontySandbox

__all__ = [
"KAGGLE_API_TOKEN_ENV",
# Models
"CodeError",
"CodeExecutionOutcome",
"CodeSandboxClient",
"ColabKernelClient",
"ColabSandbox",
"CommandResult",
"Context",
Expand All @@ -130,9 +135,11 @@
"FileWatchEvent",
"FileWatchEventType",
"GPUType",
"IJupyterKernelClient",
"ISandboxClient",
"JupyterSandbox",
"KaggleExecutionResult",
"KaggleKernelClient",
"KaggleKernelExecutor",
"KaggleSandbox",
"Logs",
"MIMEType",
Expand Down Expand Up @@ -166,8 +173,10 @@
"SandboxStatus",
"SandboxTimeoutError",
"SandboxVariant",
"SandboxVariant",
"SnapshotInfo",
"TunnelInfo",
"VariableNotFoundError",
"execution_result_to_reply",
"parse_colab_channels_url",
"parse_kaggle_channels_url",
]
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.17.0"
__version__ = "1.0.0"
13 changes: 13 additions & 0 deletions code_sandboxes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
SandboxConfig,
SandboxEnvironment,
SandboxInfo,
SandboxStatus,
SandboxVariant,
)

Expand Down Expand Up @@ -413,6 +414,18 @@ def stop(self) -> None:
"""
pass

def mark_stopped(self) -> None:
"""Mark the sandbox as stopped after its backend was disconnected externally.

Callers that bypass :meth:`stop` — for example to disconnect from a
borrowed remote kernel without shutting it down — use this to keep
:attr:`is_started` consistent, so a later :meth:`start` reconnects
instead of silently reusing a closed backend.
"""
self._started = False
if self._info:
self._info.status = SandboxStatus.STOPPED

async def start_async(self) -> None:
"""Async version of start(). Default implementation calls sync version."""
self.start()
Expand Down
179 changes: 175 additions & 4 deletions code_sandboxes/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,73 @@

from collections.abc import AsyncIterator, Iterator
from dataclasses import dataclass, field
from typing import Union
from typing import Any, Callable, Union

from .base import Sandbox
from .commands import CommandResult
from .models import CodeError, ExecutionResult, OutputMessage, Result, SandboxConfig, SandboxVariant

__all__ = ["CodeExecutionOutcome", "CodeSandboxClient"]
from .models import (
CodeError,
ExecutionResult,
OutputMessage,
Result,
SandboxConfig,
SandboxInfo,
SandboxVariant,
)

__all__ = ["CodeExecutionOutcome", "CodeSandboxClient", "execution_result_to_reply"]

StreamingItem = Union[OutputMessage, Result, CodeError]


def execution_result_to_reply(execution: ExecutionResult) -> dict[str, Any]:
"""Convert a variant-neutral execution result to a Jupyter-shaped reply."""
outputs: list[dict[str, Any]] = []

if execution.logs.stdout:
outputs.append(
{
"output_type": "stream",
"name": "stdout",
"text": "\n".join(message.line for message in execution.logs.stdout) + "\n",
}
)
if execution.logs.stderr:
outputs.append(
{
"output_type": "stream",
"name": "stderr",
"text": "\n".join(message.line for message in execution.logs.stderr) + "\n",
}
)

for result in execution.results:
outputs.append(
{
"output_type": "execute_result" if result.is_main_result else "display_data",
"data": result.data,
"metadata": result.extra,
}
)

if execution.code_error is not None:
traceback = execution.code_error.traceback or ""
outputs.append(
{
"output_type": "error",
"ename": execution.code_error.name,
"evalue": execution.code_error.value,
"traceback": traceback.splitlines(),
}
)

return {
"execution_count": execution.execution_count,
"outputs": outputs,
"status": "ok" if execution.success else "error",
}


@dataclass
class CodeExecutionOutcome:
"""Normalized result of a code execution, independent of sandbox variant.
Expand Down Expand Up @@ -183,6 +239,39 @@ def sandbox(self) -> Sandbox:
"""The wrapped sandbox instance."""
return self._sandbox

@property
def config(self) -> SandboxConfig:
"""Variant-neutral configuration for the wrapped sandbox."""
return self._sandbox.config

@property
def info(self) -> SandboxInfo | None:
"""Runtime information for the wrapped sandbox, when started."""
return self._sandbox.info

@property
def id(self) -> str | None:
"""Stable execution-backend identifier when the variant exposes one."""
info = getattr(self._sandbox, "info", None)
metadata = getattr(info, "metadata", None) or {}
kernel_id = metadata.get("kernel_id")
if kernel_id:
return str(kernel_id)
backend = getattr(self._sandbox, "kernel_client", None)
backend_id = getattr(backend, "id", None)
return str(backend_id) if backend_id else self._sandbox.sandbox_id

@property
def kernel_info(self) -> dict[str, Any]:
"""Language metadata without exposing a variant's underlying client."""
backend = getattr(self._sandbox, "kernel_client", None)
info = getattr(backend, "kernel_info", None)
if isinstance(info, dict):
return info
environments = self._sandbox.list_environments()
language = environments[0].language if environments else "python"
return {"language_info": {"name": language}}

@property
def variant(self) -> SandboxVariant | None:
"""The variant of the wrapped sandbox, if known."""
Expand Down Expand Up @@ -219,6 +308,22 @@ def close(self) -> None:
if self._owns_sandbox and callable(stop_fn) and self.is_started:
stop_fn()

def stop(self, shutdown_kernel: bool = True) -> None:
"""Release the client, optionally preserving a borrowed remote backend."""
if shutdown_kernel:
self.close()
return
backend = getattr(self._sandbox, "kernel_client", None)
backend_stop = getattr(backend, "stop", None)
if callable(backend_stop):
backend_stop(shutdown_kernel=False)
# The backend connection is now closed, so the sandbox must no longer
# report itself as started; otherwise start() would no-op and later
# executions would run against a closed backend.
mark_stopped = getattr(self._sandbox, "mark_stopped", None)
if callable(mark_stopped):
mark_stopped()

async def close_async(self) -> None:
"""Async variant of :meth:`close`."""
if not (self._owns_sandbox and self.is_started):
Expand All @@ -244,6 +349,72 @@ def execute_code(
execution = self._sandbox.run_code(code, language=language, timeout=timeout, envs=envs)
return CodeExecutionOutcome.from_execution_result(execution)

def execute(
self,
code: str,
silent: bool = False,
timeout: float | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""Execute code and return a backend-neutral Jupyter-shaped reply."""
del silent, kwargs
self.start()
execution = self._sandbox.run_code(code, timeout=timeout)
return execution_result_to_reply(execution)

def execute_interactive(
self,
code: str,
silent: bool = False,
timeout: float | None = None,
output_hook: Callable[[dict[str, Any]], None] | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""Execute code and optionally emit each normalized output to a callback."""
reply = self.execute(code, silent=silent, timeout=timeout, **kwargs)
if output_hook is not None:
for output in reply["outputs"]:
output_hook(
{
"msg_type": output.get("output_type", "display_data"),
"content": output,
}
)
return reply

def get_variable(self, name: str) -> Any:
"""Read a variable through the wrapped sandbox."""
self.start()
return self._sandbox.get_variable(name)

def set_variable(self, name: str, value: Any) -> None:
"""Set a variable through the wrapped sandbox."""
self.start()
self._sandbox.set_variable(name, value)

def set_variables(self, variables: dict[str, Any]) -> None:
"""Set multiple variables through the wrapped sandbox."""
self.start()
self._sandbox.set_variables(variables)

def register_tool_caller(self, caller: Callable[..., Any]) -> None:
"""Register the callable used by generated tools inside the sandbox."""
self.start()
self._sandbox.register_tool_caller(caller)

def interrupt(self) -> bool:
"""Interrupt the active execution when supported by the variant."""
return self._sandbox.interrupt()

def is_alive(self) -> bool:
"""Whether the sandbox is started and available for execution."""
return self.is_started

def restart(self) -> None:
"""Restart the wrapped sandbox through its public lifecycle."""
self._sandbox.stop()
self._sandbox.start()

async def execute_code_async(
self,
code: str,
Expand Down
Loading