diff --git a/CHANGELOG.md b/CHANGELOG.md index 6acd2f0..c3c6c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ ## Unreleased +## 1.10.0 + +- **One subpackage per provider.** Every sandbox moved under + `code_sandboxes.sandboxes.` — the implementation in + `/.py`, re-exported by the package's `__init__`, and the + modules only it needs beside it (Kaggle's kernel client, + executors and live session; Marimo's reactive graph and cells driver; + Google Colab's kernel client). The public names are unchanged: import them + from `code_sandboxes` as before. **Breaking:** the old flat module paths + (`code_sandboxes.datalayer_sandbox`, `code_sandboxes.marimo_sandbox`, …) + are gone, with no aliases; import from `code_sandboxes` or the new paths. +- **The Marimo kernel helper is real code.** `sandboxes/marimo/reactive_kernel.py` + is a typed, importable, self-contained module; what a sandbox sends to the + kernel is that file's own text (`reactive.KERNEL_HELPER_SOURCE` reads it + from the installed package). The type-checker and a new test suite + (`tests/test_marimo_reactive_kernel.py`) now read exactly what the kernel + runs: replacement, conflicts, cycles, plans, removal, re-execution keeping + the graph, and the stdout wire encoding. +- Docs: every provider page imports from its subpackage; the Marimo page + describes the real-code helper and points at the hosted `marimo` toolset. + ## 1.9.39 - The GitHub workflow files are all `.yaml` now (`build`, `py-tests`, diff --git a/code_sandboxes/__init__.py b/code_sandboxes/__init__.py index a0093f6..32910c7 100644 --- a/code_sandboxes/__init__.py +++ b/code_sandboxes/__init__.py @@ -73,7 +73,6 @@ installed_environment_contents, ) from .client import CodeExecutionOutcome, CodeSandboxClient, execution_result_to_reply -from .cloudflare_sandbox import CloudflareSandbox from .commands import CommandResult, ProcessHandle, SandboxCommands from .console import ( EXIT_COMMANDS, @@ -95,12 +94,6 @@ MaterializeEntry, PreparedAttachment, ) -from .coreweave_sandbox import CoreWeaveSandbox -from .datalayer_sandbox import DatalayerSandbox -from .daytona_sandbox import DaytonaSandbox -from .docker_sandbox import DockerSandbox -from .e2b_sandbox import E2BSandbox -from .eval_sandbox import EvalSandbox from .exceptions import ( ContextNotFoundError, SandboxAuthenticationError, @@ -124,16 +117,7 @@ SandboxFileHandle, SandboxFilesystem, ) -from .google_colab import ( - GoogleColabKernelClient, - parse_google_colab_channels_url, -) -from .google_colab_sandbox import GoogleColabSandbox from .interfaces import ISandboxClient -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 from .lifecycle import ( INSTANCE_OPERATIONS, LIFECYCLE_OPERATIONS, @@ -157,9 +141,6 @@ get_manager, manageable_variants, ) -from .marimo_cells import CellReply, CellsRun, MarimoCells -from .marimo_sandbox import CellRun, MarimoRun, MarimoSandbox -from .modal_sandbox import ModalSandbox from .models import ( CodeError, Context, @@ -183,7 +164,6 @@ TunnelInfo, normalize_variant, ) -from .monty_sandbox import MontySandbox from .provider_ingress import provider_ingress_execution from .providers import ( PROVIDERS, @@ -192,6 +172,30 @@ available_providers, get_provider, ) +from .sandboxes.cloudflare import CloudflareSandbox +from .sandboxes.coreweave import CoreWeaveSandbox +from .sandboxes.datalayer import DatalayerSandbox +from .sandboxes.daytona import DaytonaSandbox +from .sandboxes.docker import DockerSandbox +from .sandboxes.e2b import E2BSandbox +from .sandboxes.eval import EvalSandbox +from .sandboxes.google_colab import GoogleColabSandbox +from .sandboxes.google_colab.client import ( + GoogleColabKernelClient, + parse_google_colab_channels_url, +) +from .sandboxes.jupyter_server import JupyterServerSandbox +from .sandboxes.kaggle import KaggleSandbox +from .sandboxes.kaggle.client import ( + KAGGLE_API_TOKEN_ENV, + KaggleKernelClient, + parse_kaggle_channels_url, +) +from .sandboxes.kaggle.execute import KaggleExecutionResult, KaggleKernelExecutor +from .sandboxes.marimo import CellRun, MarimoRun, MarimoSandbox +from .sandboxes.marimo.cells import CellReply, CellsRun, MarimoCells +from .sandboxes.modal import ModalSandbox +from .sandboxes.monty import MontySandbox #: Everything this package exports, in one sorted list — the groups it #: used to be split into stopped matching what they sat above. diff --git a/code_sandboxes/__version__.py b/code_sandboxes/__version__.py index 5e178af..fcfdf38 100644 --- a/code_sandboxes/__version__.py +++ b/code_sandboxes/__version__.py @@ -1,6 +1 @@ -# -# BSD 3-Clause License - -"""Code Sandboxes.""" - -__version__ = "1.9.39" +__version__ = "1.10.0" diff --git a/code_sandboxes/base.py b/code_sandboxes/base.py index fe4d437..b2f53d3 100644 --- a/code_sandboxes/base.py +++ b/code_sandboxes/base.py @@ -598,7 +598,7 @@ def create( # noqa: C901 # it, and silently dropping them would be the surprising reading. config = config.model_copy(update={"examples": list(examples)}) - from .eval_sandbox import EvalSandbox + from .sandboxes.eval import EvalSandbox variant_value = normalize_variant(variant) @@ -617,51 +617,51 @@ def create( # noqa: C901 sandbox = EvalSandbox(config=config, **kwargs) elif variant_value == "docker": # Import here to avoid circular imports - from .docker_sandbox import DockerSandbox + from .sandboxes.docker import DockerSandbox sandbox = DockerSandbox(config=config, **kwargs) elif variant_value == "jupyter-server": - from .jupyter_server_sandbox import JupyterServerSandbox + from .sandboxes.jupyter_server import JupyterServerSandbox sandbox = JupyterServerSandbox(config=config, **kwargs) elif variant_value == "marimo": - from .marimo_sandbox import MarimoSandbox + from .sandboxes.marimo import MarimoSandbox sandbox = MarimoSandbox(config=config, **kwargs) elif variant_value == "datalayer": - from .datalayer_sandbox import DatalayerSandbox + from .sandboxes.datalayer import DatalayerSandbox sandbox = DatalayerSandbox(config=config, **kwargs) elif variant_value == "google-colab": - from .google_colab_sandbox import GoogleColabSandbox + from .sandboxes.google_colab import GoogleColabSandbox sandbox = GoogleColabSandbox(config=config, **kwargs) elif variant_value == "kaggle": - from .kaggle_sandbox import KaggleSandbox + from .sandboxes.kaggle import KaggleSandbox sandbox = KaggleSandbox(config=config, **kwargs) elif variant_value == "monty": - from .monty_sandbox import MontySandbox + from .sandboxes.monty import MontySandbox sandbox = MontySandbox(config=config, **kwargs) elif variant_value == "modal": - from .modal_sandbox import ModalSandbox + from .sandboxes.modal import ModalSandbox sandbox = ModalSandbox(config=config, **kwargs) elif variant_value == "daytona": - from .daytona_sandbox import DaytonaSandbox + from .sandboxes.daytona import DaytonaSandbox sandbox = DaytonaSandbox(config=config, **kwargs) elif variant_value == "e2b": - from .e2b_sandbox import E2BSandbox + from .sandboxes.e2b import E2BSandbox sandbox = E2BSandbox(config=config, **kwargs) elif variant_value == "coreweave": - from .coreweave_sandbox import CoreWeaveSandbox + from .sandboxes.coreweave import CoreWeaveSandbox sandbox = CoreWeaveSandbox(config=config, **kwargs) elif variant_value == "cloudflare": - from .cloudflare_sandbox import CloudflareSandbox + from .sandboxes.cloudflare import CloudflareSandbox sandbox = CloudflareSandbox(config=config, **kwargs) else: @@ -693,7 +693,7 @@ def from_id(cls, sandbox_id: str, **kwargs) -> Sandbox: SandboxNotFoundError: If no sandbox with the given ID exists. """ # This is primarily for datalayer - from .datalayer_sandbox import DatalayerSandbox + from .sandboxes.datalayer import DatalayerSandbox return DatalayerSandbox.from_id(sandbox_id, **kwargs) @@ -715,55 +715,55 @@ def list_environments( # noqa: C901 variant_value = normalize_variant(variant) if variant_value == "eval": - from .eval_sandbox import EvalSandbox + from .sandboxes.eval import EvalSandbox return EvalSandbox.list_environments() if variant_value == "docker": - from .docker_sandbox import DockerSandbox + from .sandboxes.docker import DockerSandbox return DockerSandbox.list_environments() if variant_value == "jupyter-server": - from .jupyter_server_sandbox import JupyterServerSandbox + from .sandboxes.jupyter_server import JupyterServerSandbox return JupyterServerSandbox.list_environments() if variant_value == "marimo": - from .marimo_sandbox import MarimoSandbox + from .sandboxes.marimo import MarimoSandbox return MarimoSandbox.list_environments() if variant_value == "monty": - from .monty_sandbox import MontySandbox + from .sandboxes.monty import MontySandbox return MontySandbox.list_environments() if variant_value == "modal": - from .modal_sandbox import ModalSandbox + from .sandboxes.modal import ModalSandbox return ModalSandbox.list_environments() if variant_value == "daytona": - from .daytona_sandbox import DaytonaSandbox + from .sandboxes.daytona import DaytonaSandbox return DaytonaSandbox.list_environments() if variant_value == "e2b": - from .e2b_sandbox import E2BSandbox + from .sandboxes.e2b import E2BSandbox return E2BSandbox.list_environments() if variant_value == "coreweave": - from .coreweave_sandbox import CoreWeaveSandbox + from .sandboxes.coreweave import CoreWeaveSandbox return CoreWeaveSandbox.list_environments() if variant_value == "cloudflare": - from .cloudflare_sandbox import CloudflareSandbox + from .sandboxes.cloudflare import CloudflareSandbox return CloudflareSandbox.list_environments() if variant_value == "kaggle": - from .kaggle_sandbox import KaggleSandbox + from .sandboxes.kaggle import KaggleSandbox return KaggleSandbox.list_environments() if variant_value == "google-colab": - from .google_colab_sandbox import GoogleColabSandbox + from .sandboxes.google_colab import GoogleColabSandbox return GoogleColabSandbox.list_environments() if variant_value == "datalayer": - from .datalayer_sandbox import DatalayerSandbox + from .sandboxes.datalayer import DatalayerSandbox return DatalayerSandbox.list_environments(**kwargs) raise ValueError( @@ -788,7 +788,7 @@ def list( Yields: Sandbox instances. """ - from .datalayer_sandbox import DatalayerSandbox + from .sandboxes.datalayer import DatalayerSandbox yield from DatalayerSandbox.list_all(tags=tags, **kwargs) diff --git a/code_sandboxes/environments/adapters/daytona.py b/code_sandboxes/environments/adapters/daytona.py index 3a8a640..f733b2d 100644 --- a/code_sandboxes/environments/adapters/daytona.py +++ b/code_sandboxes/environments/adapters/daytona.py @@ -776,7 +776,7 @@ def smoke_test( def _smoke_test_sandbox(self, snapshot: str) -> Any: """A sandbox of this build's own snapshot, deleted when it stops.""" - from ...daytona_sandbox import DaytonaSandbox + from ...sandboxes.daytona import DaytonaSandbox secrets = self._provider_secrets() return DaytonaSandbox( diff --git a/code_sandboxes/environments/adapters/modal.py b/code_sandboxes/environments/adapters/modal.py index 1253269..bfbb86b 100644 --- a/code_sandboxes/environments/adapters/modal.py +++ b/code_sandboxes/environments/adapters/modal.py @@ -42,7 +42,7 @@ would report `uid: 0`, not the `1000:100` the contract asks for. Section 11.4 item 6 says a live launch is where this gets fixed — "every exec re-asserts the user" — and, since 2026-09-13, it does: -`code_sandboxes.modal_sandbox.ModalSandbox._start_driver` sets +`code_sandboxes.sandboxes.modal.ModalSandbox._start_driver` sets `os.setgid(100)`/`os.setuid(1000)` inside the driver it starts, in place of the `setpriv` wrapper section 11.4 names, but only when `self._image_id` is set — that is, only when the sandbox was launched from a built Environments @@ -86,7 +86,7 @@ script file, not an inline one-liner: `/opt/datalayer/bin/entrypoint.sh`, baked in with `exec "$@"` as real file *content*, needs no Dockerfile-string escaping, and forwards correctly. But **the actual launcher, -`code_sandboxes.modal_sandbox.ModalSandbox.start()`, creates the sandbox +`code_sandboxes.sandboxes.modal.ModalSandbox.start()`, creates the sandbox with no command args at all** — it execs into the running container separately, after creation (found live, 2026-09-13, running this exact builder's own artifact through it, not a hand-rolled `Sandbox.create` call @@ -206,7 +206,7 @@ def modal_gpu(accelerator_type: str, count: int = 1) -> str | None: #: module docstring for why. `entrypoint()` is given this one bare path, #: nothing in it needing a Dockerfile-string escape. _ENTRYPOINT_PATH = "/opt/datalayer/bin/entrypoint.sh" -#: `code_sandboxes.modal_sandbox.ModalSandbox.start()` — the actual launcher +#: `code_sandboxes.sandboxes.modal.ModalSandbox.start()` — the actual launcher #: — creates the sandbox with no command args at all, and execs into it #: separately afterward (found live, 2026-09-13: `exec "$@"` with nothing to #: expand is a no-op in `sh`, so the container's own PID 1 fell straight @@ -767,8 +767,8 @@ def smoke_test( "lock pinned, and an artifact carries neither", detail={"variant": self.variant}, ) - from ...modal_sandbox import ModalSandbox from ...models import SandboxConfig + from ...sandboxes.modal import ModalSandbox from ..conformance import expected_packages, run_accelerator_check, run_core_tier sdk = self._modal_sdk() diff --git a/code_sandboxes/manage.py b/code_sandboxes/manage.py index abcb9d9..810962a 100644 --- a/code_sandboxes/manage.py +++ b/code_sandboxes/manage.py @@ -285,7 +285,7 @@ def update(self, sandbox_id: str, name: str | None = None, **_: Any) -> SandboxI raise SandboxManagementError(f"No docker sandbox found: {sandbox_id}") def create(self, **kwargs: Any) -> SandboxInfo: - from .docker_sandbox import DockerSandbox + from .sandboxes.docker import DockerSandbox # auto_remove would erase the container the moment this process lets # go of it — the opposite of a detached create. @@ -417,7 +417,7 @@ def _request(self, method: str, path: str, **kwargs: Any) -> Any: raise SandboxManagementError( "A Colab runtime URL is required: pass server_url=... or set RUNTIME_URL." ) - from .google_colab import ( + from .sandboxes.google_colab.client import ( COLAB_CLIENT_AGENT_HEADER, COLAB_RUNTIME_PROXY_TOKEN_HEADER, DEFAULT_COLAB_CLIENT_AGENT, @@ -471,7 +471,7 @@ def __init__(self, username: str | None = None, **_: Any) -> None: def _get_executor(self) -> Any: if self._executor is None: - from .kaggle_execute import KaggleKernelExecutor + from .sandboxes.kaggle.execute import KaggleKernelExecutor self._executor = KaggleKernelExecutor(username=self._username) return self._executor @@ -605,7 +605,7 @@ class ModalSandboxManager(SandboxManager): capabilities = frozenset({"create", "list", "get", "update", "delete"}) def __init__(self, app_name: str | None = None, **_: Any) -> None: - from .modal_sandbox import DEFAULT_APP_NAME + from .sandboxes.modal import DEFAULT_APP_NAME self._app_name = app_name or DEFAULT_APP_NAME @@ -678,7 +678,7 @@ def update(self, sandbox_id: str, tags: dict[str, str] | None = None, **_: Any) return info def create(self, **kwargs: Any) -> SandboxInfo: - from .modal_sandbox import ModalSandbox + from .sandboxes.modal import ModalSandbox self._configure(kwargs) sandbox = ModalSandbox(app_name=self._app_name, **kwargs) @@ -801,7 +801,7 @@ def update(self, sandbox_id: str, tags: dict[str, str] | None = None, **_: Any) return info def create(self, **kwargs: Any) -> SandboxInfo: - from .daytona_sandbox import DaytonaSandbox + from .sandboxes.daytona import DaytonaSandbox given = {key: value for key, value in self._settings.items() if value} environment = self._configure(kwargs) @@ -850,7 +850,7 @@ def _get_client(self) -> Any: "agent_runtimes package is required: pip install code-sandboxes[datalayer]" ) from exc if self._run_url: - from .datalayer_sandbox import _urls_for_run + from .sandboxes.datalayer.datalayer import _urls_for_run self._client = AgentClient(urls=_urls_for_run(self._run_url), api_key=self._token) else: @@ -926,8 +926,8 @@ def create(self, **kwargs: Any) -> SandboxInfo: so both CLIs started every runtime in `ai-agents-env` under a generated name: asked for `python-cpu-env`, the platform claimed an agents pod. """ - from .datalayer_sandbox import DatalayerSandbox from .models import SandboxConfig + from .sandboxes.datalayer import DatalayerSandbox config = kwargs.pop("config", None) or SandboxConfig() chosen = { @@ -1034,7 +1034,7 @@ def delete(self, sandbox_id: str) -> bool: return False def create(self, **kwargs: Any) -> SandboxInfo: - from .e2b_sandbox import E2BSandbox + from .sandboxes.e2b import E2BSandbox self._configure(kwargs) sandbox = E2BSandbox(**self._opts(), **kwargs) @@ -1129,7 +1129,7 @@ def delete(self, sandbox_id: str) -> bool: return True def create(self, **kwargs: Any) -> SandboxInfo: - from .coreweave_sandbox import CoreWeaveSandbox + from .sandboxes.coreweave import CoreWeaveSandbox given = {key: value for key, value in self._settings.items() if value} # No session process: a sandbox nothing is holding open should not be @@ -1168,7 +1168,7 @@ def _client(self) -> Any: calls to the bridge; creating a container merely to have something to make the call with would leave that container running and billed. """ - from .cloudflare_sandbox import CloudflareSandbox + from .sandboxes.cloudflare import CloudflareSandbox given = {key: value for key, value in self._settings.items() if value} try: @@ -1211,7 +1211,7 @@ def delete(self, sandbox_id: str) -> bool: return response.status_code < 400 def create(self, **kwargs: Any) -> SandboxInfo: - from .cloudflare_sandbox import CloudflareSandbox + from .sandboxes.cloudflare import CloudflareSandbox given = {key: value for key, value in self._settings.items() if value} self._configure(kwargs) diff --git a/code_sandboxes/provider_ingress.py b/code_sandboxes/provider_ingress.py index a0be13e..7268c69 100644 --- a/code_sandboxes/provider_ingress.py +++ b/code_sandboxes/provider_ingress.py @@ -10,8 +10,8 @@ from collections.abc import Iterator from .base import Sandbox -from .jupyter_server_sandbox import JupyterServerSandbox from .models import JupyterServerOptions +from .sandboxes.jupyter_server import JupyterServerSandbox @contextlib.contextmanager diff --git a/code_sandboxes/sandboxes/__init__.py b/code_sandboxes/sandboxes/__init__.py new file mode 100644 index 0000000..78185fd --- /dev/null +++ b/code_sandboxes/sandboxes/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2023-2024 Datalayer, Inc. +# +# BSD 3-Clause License + +"""The sandboxes, one subpackage per provider. + +Each provider lives in its own package — its implementation in +``/.py``, re-exported by the package's ``__init__``, and +whatever else only it needs beside it (Kaggle's kernel client +and executors, Marimo's reactive graph and cells driver, Google Colab's +kernel client). The public names are re-exported from +:mod:`code_sandboxes`, which is the import path programs should use; these +modules are where the code lives. +""" diff --git a/code_sandboxes/sandboxes/cloudflare/__init__.py b/code_sandboxes/sandboxes/cloudflare/__init__.py new file mode 100644 index 0000000..e8e58b7 --- /dev/null +++ b/code_sandboxes/sandboxes/cloudflare/__init__.py @@ -0,0 +1,24 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Cloudflare sandbox implementation. + +The implementation lives in :mod:`.cloudflare`; this package re-exports it. +""" + +from .cloudflare import ( + API_KEY_ENV_VAR, + API_URL_ENV_VAR, + DEFAULT_PYTHON, + CloudflareSandbox, + logger, +) + +__all__ = [ + "API_KEY_ENV_VAR", + "API_URL_ENV_VAR", + "DEFAULT_PYTHON", + "CloudflareSandbox", + "logger", +] diff --git a/code_sandboxes/cloudflare_sandbox.py b/code_sandboxes/sandboxes/cloudflare/cloudflare.py similarity index 99% rename from code_sandboxes/cloudflare_sandbox.py rename to code_sandboxes/sandboxes/cloudflare/cloudflare.py index a707914..4215759 100644 --- a/code_sandboxes/cloudflare_sandbox.py +++ b/code_sandboxes/sandboxes/cloudflare/cloudflare.py @@ -43,15 +43,15 @@ from typing import Any from urllib.parse import quote -from .base import Sandbox, marks_execution -from .exceptions import ( +from ...base import Sandbox, marks_execution +from ...exceptions import ( SandboxConfigurationError, SandboxConnectionError, SandboxExecutionError, SandboxNotStartedError, ) -from .filesystem import SandboxFilesystem -from .models import ( +from ...filesystem import SandboxFilesystem +from ...models import ( CodeError, Context, ExecutionResult, diff --git a/code_sandboxes/sandboxes/coreweave/__init__.py b/code_sandboxes/sandboxes/coreweave/__init__.py new file mode 100644 index 0000000..e91bd1f --- /dev/null +++ b/code_sandboxes/sandboxes/coreweave/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""CoreWeave sandbox implementation. + +The implementation lives in :mod:`.coreweave`; this package re-exports it. +""" + +from .coreweave import ( + CREATED_BY_LABEL, + DEFAULT_CONTAINER_IMAGE, + CoreWeaveSandbox, + logger, +) + +__all__ = [ + "CREATED_BY_LABEL", + "DEFAULT_CONTAINER_IMAGE", + "CoreWeaveSandbox", + "logger", +] diff --git a/code_sandboxes/coreweave_sandbox.py b/code_sandboxes/sandboxes/coreweave/coreweave.py similarity index 99% rename from code_sandboxes/coreweave_sandbox.py rename to code_sandboxes/sandboxes/coreweave/coreweave.py index 11235f5..784c2ac 100644 --- a/code_sandboxes/coreweave_sandbox.py +++ b/code_sandboxes/sandboxes/coreweave/coreweave.py @@ -31,14 +31,14 @@ import time from typing import Any -from .base import Sandbox, marks_execution -from .exceptions import ( +from ...base import Sandbox, marks_execution +from ...exceptions import ( SandboxConfigurationError, SandboxExecutionError, SandboxNotStartedError, VariableNotFoundError, ) -from .models import ( +from ...models import ( CodeError, Context, ExecutionResult, diff --git a/code_sandboxes/sandboxes/datalayer/__init__.py b/code_sandboxes/sandboxes/datalayer/__init__.py new file mode 100644 index 0000000..ecb1958 --- /dev/null +++ b/code_sandboxes/sandboxes/datalayer/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Datalayer Runtime-based sandbox implementation. + +The implementation lives in :mod:`.datalayer`; this package re-exports it. +""" + +from .datalayer import ( + DatalayerSandbox, + logger, +) + +__all__ = [ + "DatalayerSandbox", + "logger", +] diff --git a/code_sandboxes/datalayer_sandbox.py b/code_sandboxes/sandboxes/datalayer/datalayer.py similarity index 99% rename from code_sandboxes/datalayer_sandbox.py rename to code_sandboxes/sandboxes/datalayer/datalayer.py index 6090a9d..6c4e440 100644 --- a/code_sandboxes/datalayer_sandbox.py +++ b/code_sandboxes/sandboxes/datalayer/datalayer.py @@ -15,10 +15,10 @@ from typing import TYPE_CHECKING, Any, Optional if TYPE_CHECKING: - from .filesystem import SandboxFileHandle + from ...filesystem import SandboxFileHandle -from .base import Sandbox, marks_execution -from .contents import ( +from ...base import Sandbox, marks_execution +from ...contents import ( FILESYSTEM_PRIMITIVES, LOCAL_BRIDGE_MOUNT, MOUNT_MISSING, @@ -32,14 +32,14 @@ path_is_mountpoint, ready, ) -from .exceptions import ( +from ...exceptions import ( SandboxConfigurationError, SandboxConnectionError, SandboxNotFoundError, SandboxNotStartedError, SandboxSnapshotError, ) -from .models import ( +from ...models import ( CodeError, Context, ExecutionResult, @@ -1118,6 +1118,6 @@ def open_file(self, path: str, mode: str = "r") -> "SandboxFileHandle": Returns: SandboxFileHandle for file operations. """ - from .filesystem import SandboxFileHandle + from ...filesystem import SandboxFileHandle return SandboxFileHandle(self, path, mode) diff --git a/code_sandboxes/sandboxes/daytona/__init__.py b/code_sandboxes/sandboxes/daytona/__init__.py new file mode 100644 index 0000000..6da4ae3 --- /dev/null +++ b/code_sandboxes/sandboxes/daytona/__init__.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Daytona sandbox implementation. + +The implementation lives in :mod:`.daytona`; this package re-exports it. +""" + +from .daytona import ( + CREATED_BY_LABEL, + DaytonaSandbox, + logger, +) + +__all__ = [ + "CREATED_BY_LABEL", + "DaytonaSandbox", + "logger", +] diff --git a/code_sandboxes/daytona_sandbox.py b/code_sandboxes/sandboxes/daytona/daytona.py similarity index 99% rename from code_sandboxes/daytona_sandbox.py rename to code_sandboxes/sandboxes/daytona/daytona.py index 78ec77a..26be28e 100644 --- a/code_sandboxes/daytona_sandbox.py +++ b/code_sandboxes/sandboxes/daytona/daytona.py @@ -30,8 +30,8 @@ import time from typing import Any -from .base import Sandbox, marks_execution -from .contents import ( +from ...base import Sandbox, marks_execution +from ...contents import ( FILESYSTEM_PRIMITIVES, ContentAttachmentSpec, ContentCapabilities, @@ -43,14 +43,14 @@ prepare_local_bridge, stop_bridge_mount, ) -from .exceptions import ( +from ...exceptions import ( SandboxConfigurationError, SandboxExecutionError, SandboxNotStartedError, VariableNotFoundError, ) -from .jupyter_ingress import preparation_command, resolved_options, websocket_url -from .models import ( +from ...jupyter_ingress import preparation_command, resolved_options, websocket_url +from ...models import ( CodeError, Context, ExecutionResult, diff --git a/code_sandboxes/sandboxes/docker/__init__.py b/code_sandboxes/sandboxes/docker/__init__.py new file mode 100644 index 0000000..aa5d089 --- /dev/null +++ b/code_sandboxes/sandboxes/docker/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Docker-based sandbox implementation. + +The implementation lives in :mod:`.docker`; this package re-exports it. +""" + +from .docker import ( + DEFAULT_IMAGE, + DEFAULT_PORT, + DockerSandbox, + logger, +) + +__all__ = [ + "DEFAULT_IMAGE", + "DEFAULT_PORT", + "DockerSandbox", + "logger", +] diff --git a/code_sandboxes/docker_sandbox.py b/code_sandboxes/sandboxes/docker/docker.py similarity index 98% rename from code_sandboxes/docker_sandbox.py rename to code_sandboxes/sandboxes/docker/docker.py index 57984d1..dd9bdbd 100644 --- a/code_sandboxes/docker_sandbox.py +++ b/code_sandboxes/sandboxes/docker/docker.py @@ -18,11 +18,11 @@ import requests -from .base import Sandbox, marks_execution -from .exceptions import SandboxConfigurationError, SandboxNotStartedError -from .interfaces import ISandboxClient -from .jupyter_ingress import interrupt_kernel_client -from .models import ( +from ...base import Sandbox, marks_execution +from ...exceptions import SandboxConfigurationError, SandboxNotStartedError +from ...interfaces import ISandboxClient +from ...jupyter_ingress import interrupt_kernel_client +from ...models import ( CodeError, Context, ExecutionResult, diff --git a/code_sandboxes/sandboxes/e2b/__init__.py b/code_sandboxes/sandboxes/e2b/__init__.py new file mode 100644 index 0000000..3331875 --- /dev/null +++ b/code_sandboxes/sandboxes/e2b/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""E2B sandbox implementation. + +The implementation lives in :mod:`.e2b`; this package re-exports it. +""" + +from .e2b import ( + CREATED_BY_LABEL, + DEFAULT_TEMPLATE, + E2BSandbox, + logger, +) + +__all__ = [ + "CREATED_BY_LABEL", + "DEFAULT_TEMPLATE", + "E2BSandbox", + "logger", +] diff --git a/code_sandboxes/e2b_sandbox.py b/code_sandboxes/sandboxes/e2b/e2b.py similarity index 99% rename from code_sandboxes/e2b_sandbox.py rename to code_sandboxes/sandboxes/e2b/e2b.py index 29335ce..c851934 100644 --- a/code_sandboxes/e2b_sandbox.py +++ b/code_sandboxes/sandboxes/e2b/e2b.py @@ -28,8 +28,8 @@ import time from typing import Any -from .base import Sandbox, marks_execution -from .contents import ( +from ...base import Sandbox, marks_execution +from ...contents import ( FILESYSTEM_PRIMITIVES, ContentAttachmentSpec, ContentCapabilities, @@ -41,14 +41,14 @@ prepare_local_bridge, stop_bridge_mount, ) -from .exceptions import ( +from ...exceptions import ( SandboxConfigurationError, SandboxExecutionError, SandboxNotStartedError, VariableNotFoundError, ) -from .jupyter_ingress import preparation_command, resolved_options, websocket_url -from .models import ( +from ...jupyter_ingress import preparation_command, resolved_options, websocket_url +from ...models import ( CodeError, Context, ExecutionResult, diff --git a/code_sandboxes/sandboxes/eval/__init__.py b/code_sandboxes/sandboxes/eval/__init__.py new file mode 100644 index 0000000..42c4a06 --- /dev/null +++ b/code_sandboxes/sandboxes/eval/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""eval-based sandbox implementation. + +The implementation lives in :mod:`.eval`; this package re-exports it. +""" + +from .eval import ( + EvalSandbox, +) + +__all__ = [ + "EvalSandbox", +] diff --git a/code_sandboxes/eval_sandbox.py b/code_sandboxes/sandboxes/eval/eval.py similarity index 98% rename from code_sandboxes/eval_sandbox.py rename to code_sandboxes/sandboxes/eval/eval.py index 79fac86..d5f1df2 100644 --- a/code_sandboxes/eval_sandbox.py +++ b/code_sandboxes/sandboxes/eval/eval.py @@ -24,9 +24,9 @@ from contextlib import contextmanager, redirect_stderr, redirect_stdout from typing import Any, Optional -from .base import Sandbox, marks_execution -from .exceptions import SandboxNotStartedError -from .models import ( +from ...base import Sandbox, marks_execution +from ...exceptions import SandboxNotStartedError +from ...models import ( CodeError, Context, ExecutionResult, @@ -469,13 +469,13 @@ def _get_internal_variable(self, name: str, context: Optional[Context] = None) - """ ctx = context or self._default_context if ctx.id not in self._namespaces: - from .exceptions import VariableNotFoundError + from ...exceptions import VariableNotFoundError raise VariableNotFoundError(name) namespace = self._namespaces[ctx.id] if name not in namespace: - from .exceptions import VariableNotFoundError + from ...exceptions import VariableNotFoundError raise VariableNotFoundError(name) diff --git a/code_sandboxes/sandboxes/google_colab/__init__.py b/code_sandboxes/sandboxes/google_colab/__init__.py new file mode 100644 index 0000000..becdc98 --- /dev/null +++ b/code_sandboxes/sandboxes/google_colab/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Google Colab sandbox implementation. + +The implementation lives in :mod:`.google_colab`; this package re-exports it. +""" + +from .google_colab import ( + GoogleColabSandbox, + logger, +) + +__all__ = [ + "GoogleColabSandbox", + "logger", +] diff --git a/code_sandboxes/google_colab.py b/code_sandboxes/sandboxes/google_colab/client.py similarity index 100% rename from code_sandboxes/google_colab.py rename to code_sandboxes/sandboxes/google_colab/client.py diff --git a/code_sandboxes/google_colab_sandbox.py b/code_sandboxes/sandboxes/google_colab/google_colab.py similarity index 95% rename from code_sandboxes/google_colab_sandbox.py rename to code_sandboxes/sandboxes/google_colab/google_colab.py index 1e34988..a82a153 100644 --- a/code_sandboxes/google_colab_sandbox.py +++ b/code_sandboxes/sandboxes/google_colab/google_colab.py @@ -5,7 +5,7 @@ """Google Colab sandbox implementation. This sandbox connects to an existing Google Colab runtime and executes code in -its kernel using :class:`code_sandboxes.google_colab.GoogleColabKernelClient`. +its kernel using :class:`code_sandboxes.sandboxes.google_colab.client.GoogleColabKernelClient`. Unlike the Jupyter/Docker sandboxes, this sandbox does **not** provision a runtime: a Colab runtime must already be running in a browser session. Reuse it @@ -19,12 +19,16 @@ import time import uuid -from .base import Sandbox, marks_execution -from .exceptions import SandboxConfigurationError, SandboxNotStartedError -from .google_colab import GoogleColabKernelClient, parse_google_colab_channels_url -from .interfaces import ISandboxClient -from .jupyter_ingress import interrupt_kernel_client -from .models import ( +from code_sandboxes.sandboxes.google_colab.client import ( + GoogleColabKernelClient, + parse_google_colab_channels_url, +) + +from ...base import Sandbox, marks_execution +from ...exceptions import SandboxConfigurationError, SandboxNotStartedError +from ...interfaces import ISandboxClient +from ...jupyter_ingress import interrupt_kernel_client +from ...models import ( CodeError, Context, ExecutionResult, diff --git a/code_sandboxes/sandboxes/jupyter_server/__init__.py b/code_sandboxes/sandboxes/jupyter_server/__init__.py new file mode 100644 index 0000000..90c782d --- /dev/null +++ b/code_sandboxes/sandboxes/jupyter_server/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Jupyter-based sandbox implementation. + +The implementation lives in :mod:`.jupyter_server`; this package re-exports it. +""" + +from .jupyter_server import ( + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_STARTUP_TIMEOUT, + SERVER_OUTPUT_LINES, + JupyterServerSandbox, + logger, +) + +__all__ = [ + "DEFAULT_HOST", + "DEFAULT_PORT", + "DEFAULT_STARTUP_TIMEOUT", + "SERVER_OUTPUT_LINES", + "JupyterServerSandbox", + "logger", +] diff --git a/code_sandboxes/jupyter_server_sandbox.py b/code_sandboxes/sandboxes/jupyter_server/jupyter_server.py similarity index 99% rename from code_sandboxes/jupyter_server_sandbox.py rename to code_sandboxes/sandboxes/jupyter_server/jupyter_server.py index 8465a98..ad7baf3 100644 --- a/code_sandboxes/jupyter_server_sandbox.py +++ b/code_sandboxes/sandboxes/jupyter_server/jupyter_server.py @@ -29,10 +29,10 @@ import requests -from .base import Sandbox, marks_execution -from .exceptions import SandboxConfigurationError, SandboxNotStartedError -from .interfaces import ISandboxClient -from .models import ( +from ...base import Sandbox, marks_execution +from ...exceptions import SandboxConfigurationError, SandboxNotStartedError +from ...interfaces import ISandboxClient +from ...models import ( CodeError, Context, ExecutionResult, diff --git a/code_sandboxes/sandboxes/kaggle/__init__.py b/code_sandboxes/sandboxes/kaggle/__init__.py new file mode 100644 index 0000000..abe2569 --- /dev/null +++ b/code_sandboxes/sandboxes/kaggle/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Kaggle sandbox implementation. + +The implementation lives in :mod:`.kaggle`; this package re-exports it. +""" + +from .kaggle import ( + KaggleSandbox, + logger, +) + +__all__ = [ + "KaggleSandbox", + "logger", +] diff --git a/code_sandboxes/kaggle.py b/code_sandboxes/sandboxes/kaggle/client.py similarity index 100% rename from code_sandboxes/kaggle.py rename to code_sandboxes/sandboxes/kaggle/client.py diff --git a/code_sandboxes/kaggle_execute.py b/code_sandboxes/sandboxes/kaggle/execute.py similarity index 99% rename from code_sandboxes/kaggle_execute.py rename to code_sandboxes/sandboxes/kaggle/execute.py index 5c5c7ee..5c33d71 100644 --- a/code_sandboxes/kaggle_execute.py +++ b/code_sandboxes/sandboxes/kaggle/execute.py @@ -4,7 +4,7 @@ """Run code on Kaggle through the official *kernels* (notebooks) API. -Unlike :class:`~code_sandboxes.kaggle.KaggleKernelClient`, which connects +Unlike :class:`~code_sandboxes.sandboxes.kaggle.client.KaggleKernelClient`, which connects to an **already-running** interactive Kaggle notebook session over websocket, this module drives Kaggle's **batch** kernels API: it creates (or updates) a Kaggle notebook, runs it end to end on Kaggle's infrastructure, waits for diff --git a/code_sandboxes/kaggle_sandbox.py b/code_sandboxes/sandboxes/kaggle/kaggle.py similarity index 98% rename from code_sandboxes/kaggle_sandbox.py rename to code_sandboxes/sandboxes/kaggle/kaggle.py index b57b1db..045a397 100644 --- a/code_sandboxes/kaggle_sandbox.py +++ b/code_sandboxes/sandboxes/kaggle/kaggle.py @@ -5,7 +5,7 @@ """Kaggle sandbox implementation. This sandbox connects to a Kaggle interactive notebook runtime and executes code -in its kernel using :class:`code_sandboxes.kaggle.KaggleKernelClient`. +in its kernel using :class:`code_sandboxes.sandboxes.kaggle.client.KaggleKernelClient`. When runtime connection details are not provided, it transparently falls back to Kaggle's batch execution API via ``KaggleKernelExecutor``. This mode is useful @@ -33,13 +33,14 @@ from pathlib import Path from typing import Any -from .base import Sandbox, marks_execution -from .exceptions import SandboxConfigurationError, SandboxNotStartedError -from .interfaces import ISandboxClient -from .jupyter_ingress import interrupt_kernel_client -from .kaggle import KaggleKernelClient, parse_kaggle_channels_url -from .kaggle_execute import KaggleKernelExecutor -from .models import ( +from code_sandboxes.sandboxes.kaggle.client import KaggleKernelClient, parse_kaggle_channels_url +from code_sandboxes.sandboxes.kaggle.execute import KaggleKernelExecutor + +from ...base import Sandbox, marks_execution +from ...exceptions import SandboxConfigurationError, SandboxNotStartedError +from ...interfaces import ISandboxClient +from ...jupyter_ingress import interrupt_kernel_client +from ...models import ( CodeError, Context, ExecutionResult, @@ -141,7 +142,7 @@ def start(self) -> None: # 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 + from code_sandboxes.sandboxes.kaggle.live import KaggleLiveSession executor = KaggleKernelExecutor( username=self._extra_kwargs.get("username"), diff --git a/code_sandboxes/kaggle_live.py b/code_sandboxes/sandboxes/kaggle/live.py similarity index 100% rename from code_sandboxes/kaggle_live.py rename to code_sandboxes/sandboxes/kaggle/live.py diff --git a/code_sandboxes/sandboxes/marimo/__init__.py b/code_sandboxes/sandboxes/marimo/__init__.py new file mode 100644 index 0000000..292bae6 --- /dev/null +++ b/code_sandboxes/sandboxes/marimo/__init__.py @@ -0,0 +1,24 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""A Marimo sandbox: a Jupyter kernel with Marimo's reactivity in it. + +The implementation lives in :mod:`.marimo`; this package re-exports it. +""" + +from .marimo import ( + VARIANT, + CellRun, + MarimoRun, + MarimoSandbox, + logger, +) + +__all__ = [ + "VARIANT", + "CellRun", + "MarimoRun", + "MarimoSandbox", + "logger", +] diff --git a/code_sandboxes/marimo_cells.py b/code_sandboxes/sandboxes/marimo/cells.py similarity index 99% rename from code_sandboxes/marimo_cells.py rename to code_sandboxes/sandboxes/marimo/cells.py index a74c515..f970178 100644 --- a/code_sandboxes/marimo_cells.py +++ b/code_sandboxes/sandboxes/marimo/cells.py @@ -30,7 +30,7 @@ from dataclasses import dataclass, field from typing import Any, Protocol -from .marimo_reactive import ( +from code_sandboxes.sandboxes.marimo.reactive import ( HELPER_NAME, KERNEL_HELPER_SOURCE, decode_answer, diff --git a/code_sandboxes/marimo_sandbox.py b/code_sandboxes/sandboxes/marimo/marimo.py similarity index 98% rename from code_sandboxes/marimo_sandbox.py rename to code_sandboxes/sandboxes/marimo/marimo.py index 89b5b22..4a3dbaa 100644 --- a/code_sandboxes/marimo_sandbox.py +++ b/code_sandboxes/sandboxes/marimo/marimo.py @@ -40,15 +40,16 @@ from dataclasses import dataclass, field from typing import Any -from .base import marks_execution -from .jupyter_server_sandbox import JupyterServerSandbox -from .marimo_reactive import ( +from code_sandboxes.sandboxes.jupyter_server import JupyterServerSandbox +from code_sandboxes.sandboxes.marimo.reactive import ( HELPER_NAME, KERNEL_HELPER_SOURCE, decode_answer, question, ) -from .models import ( + +from ...base import marks_execution +from ...models import ( CodeError, Context, ExecutionResult, diff --git a/code_sandboxes/sandboxes/marimo/reactive.py b/code_sandboxes/sandboxes/marimo/reactive.py new file mode 100644 index 0000000..5943201 --- /dev/null +++ b/code_sandboxes/sandboxes/marimo/reactive.py @@ -0,0 +1,77 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""The reactive cell graph a Marimo sandbox keeps inside its kernel. + +Marimo's reactivity is a dataflow graph: a cell *defines* names and *refers* +to names, and running a cell re-runs every cell that, transitively, refers to +what it defined. Marimo computes that graph statically from the source +(`marimo._ast.compiler`) and orders the re-runs topologically +(`marimo._runtime.dataflow`). Nothing about it needs Marimo's own server: the +graph can live in an ordinary IPython kernel and be driven over the Jupyter +protocol, which is what code-sandboxes#34 asks for and what keeps +`jupyter-kernel-client` the client API. + +The helper is real code now: :mod:`.reactive_kernel`, a typed module of its +own, whose *file text* is what `KERNEL_HELPER_SOURCE` holds and what a sandbox +sends to the kernel as one execute request the first time it needs the graph. +Importing that module, executing its text (`local_helper`) and running it in a +kernel are the same code, so the type-checker and the tests read exactly what +the kernel runs. The same source, verbatim, is carried by +`@datalayer/jupyter-react` (`jupyter/marimo/reactive.ts`) for the browser-side +components; keep the two copies identical. + +Answers cross the protocol as one stdout line, `__MARIMO__`, so a +caller needs nothing but the stream messages every Jupyter client already +reads. +""" + +from __future__ import annotations + +from importlib import resources +from typing import Any + +#: The name the helper is bound to in the kernel's user namespace. +HELPER_NAME = "__marimo_reactive__" + +#: What an answer line starts with on stdout. +ANSWER_MARKER = "__MARIMO__" + +#: The code the kernel runs: `reactive_kernel.py`'s own text, read from the +#: installed package rather than written out twice. Reading it — instead of +#: importing the module here — keeps `code_sandboxes` importable in an +#: environment without marimo; the module's tail builds the graph, which +#: imports marimo. +KERNEL_HELPER_SOURCE = ( + resources.files(__package__).joinpath("reactive_kernel.py").read_text(encoding="utf-8") +) + + +def decode_answer(stdout_lines: list[str]) -> Any: + """The helper's answer, from the stdout lines an execution produced. + + Raises `LookupError` when no answer line is there: the helper is not + installed, or the kernel wrote an error instead. + """ + import base64 + import json + + for line in reversed(stdout_lines): + line = line.strip() + if line.startswith(ANSWER_MARKER): + return json.loads(base64.b64decode(line[len(ANSWER_MARKER) :]).decode("utf-8")) + raise LookupError("The kernel returned no Marimo graph answer.") + + +def question(method: str, *args: Any) -> str: + """The code that asks the kernel's helper one thing.""" + arguments = ", ".join(repr(argument) for argument in args) + return f"{HELPER_NAME}.answer({method!r}{', ' if arguments else ''}{arguments})" + + +def local_helper() -> Any: + """The helper, executed here: the graph without a kernel (needs marimo).""" + namespace: dict[str, Any] = {} + exec(KERNEL_HELPER_SOURCE, namespace) # noqa: S102 - our own source, above + return namespace[HELPER_NAME] diff --git a/code_sandboxes/marimo_reactive.py b/code_sandboxes/sandboxes/marimo/reactive_kernel.py similarity index 50% rename from code_sandboxes/marimo_reactive.py rename to code_sandboxes/sandboxes/marimo/reactive_kernel.py index 2be3f3d..ea5513f 100644 --- a/code_sandboxes/marimo_reactive.py +++ b/code_sandboxes/sandboxes/marimo/reactive_kernel.py @@ -4,54 +4,57 @@ """The reactive cell graph a Marimo sandbox keeps inside its kernel. -Marimo's reactivity is a dataflow graph: a cell *defines* names and *refers* -to names, and running a cell re-runs every cell that, transitively, refers to -what it defined. Marimo computes that graph statically from the source -(`marimo._ast.compiler`) and orders the re-runs topologically -(`marimo._runtime.dataflow`). Nothing about it needs Marimo's own server: the -graph can live in an ordinary IPython kernel and be driven over the Jupyter -protocol, which is what code-sandboxes#34 asks for and what keeps -`jupyter-kernel-client` the client API. - -The helper below is the source that is sent to the kernel, as one execute -request, the first time a Marimo sandbox needs it. It is also executed here at -import time, so the tests exercise exactly the code the kernel runs and this -package can answer graph questions without a kernel. The same source, verbatim, -is carried by `@datalayer/jupyter-react` (`jupyter/marimo/reactive.ts`) for the -browser-side components; keep the two copies identical. - -Answers cross the protocol as one stdout line, `__MARIMO__`, so a -caller needs nothing but the stream messages every Jupyter client already -reads. +This module *is* the code the kernel runs. :mod:`.reactive` reads this file's +text and sends it to the kernel as one execute request, so what a test +imports, a type-checker reads and the kernel executes are one and the same — +it used to be a string constant, which no tool could check. + +Because its text is executed in a bare kernel namespace, the module keeps two +rules the rest of the package does not have to: + +- **Self-contained.** Only the standard library at import time, and marimo — + imported lazily, inside the methods, so the helper installs on a kernel + that does not have marimo yet and fails with marimo's own error only when + a cell is first registered. Never an import from ``code_sandboxes``: the + kernel does not have it. +- **Idempotent under re-execution.** The tail binds one instance to + ``__marimo_reactive__`` only when the name is absent, so a second driver + executing this source on the same kernel keeps the graph the first one + built. The same guard makes a plain ``import`` behave: the module object + carries its own singleton. + +Answers cross the protocol as one stdout line, ``__MARIMO__``, +so a caller needs nothing but the stream messages every Jupyter client +already reads. """ from __future__ import annotations -from typing import Any +import base64 as _marimo_b64 +import json as _marimo_json +from typing import TYPE_CHECKING, Any, cast -#: The name the helper is bound to in the kernel's user namespace. -HELPER_NAME = "__marimo_reactive__" +if TYPE_CHECKING: # only for the annotations: the kernel never imports these + from marimo._ast.cell import CellImpl + from marimo._runtime.dataflow import DirectedGraph + from marimo._types.ids import CellId_t #: What an answer line starts with on stdout. ANSWER_MARKER = "__MARIMO__" -KERNEL_HELPER_SOURCE = r''' -import base64 as _marimo_b64 -import json as _marimo_json - class _MarimoReactive: """A reactive cell graph, Marimo's, kept beside the kernel's namespace.""" - def __init__(self): + def __init__(self) -> None: from marimo._runtime.dataflow import DirectedGraph - self._graph = DirectedGraph() - self._code = {} + self._graph: DirectedGraph = DirectedGraph() + self._code: dict[str, str] = {} # -- cells ----------------------------------------------------------- - def register(self, cell_id, code): + def register(self, cell_id: str, code: str) -> dict[str, Any]: """Compile one cell and put it in the graph, replacing its old self.""" from marimo._ast.compiler import compile_cell @@ -59,11 +62,11 @@ def register(self, cell_id, code): self._graph.delete_cell(cell_id) del self._code[cell_id] try: - cell = compile_cell(code, cell_id=cell_id) + cell: CellImpl = compile_cell(code, cell_id=cell_id) except SyntaxError as error: return { "cell": cell_id, - "error": "SyntaxError: %s (line %s)" % (error.msg, error.lineno), + "error": f"SyntaxError: {error.msg} (line {error.lineno})", } self._graph.register_cell(cell_id, cell) self._code[cell_id] = code @@ -75,23 +78,23 @@ def register(self, cell_id, code): "cycle": self._in_cycle(cell_id), } - def remove(self, cell_id): + def remove(self, cell_id: str) -> dict[str, Any]: if cell_id in self._code: self._graph.delete_cell(cell_id) del self._code[cell_id] return {"cell": cell_id, "removed": True} - def code(self, cell_id): + def code(self, cell_id: str) -> str | None: return self._code.get(cell_id) - def codes(self): + def codes(self) -> dict[str, str]: """Every registered cell's source, by id: what a second driver on the same kernel reads back.""" return dict(self._code) # -- what to run ----------------------------------------------------- - def plan(self, cell_id): + def plan(self, cell_id: str) -> list[str]: """The cells to re-run after `cell_id` ran, in dependency order.""" from marimo._runtime.dataflow import topological_sort @@ -99,22 +102,25 @@ def plan(self, cell_id): return [] return list(topological_sort(self._graph, list(self._graph.descendants(cell_id)))) - def plan_all(self): + def plan_all(self) -> list[str]: """Every registered cell, in dependency order: a run-all.""" from marimo._runtime.dataflow import topological_sort return list(topological_sort(self._graph, list(self._code))) - def snapshot(self): + def snapshot(self) -> dict[str, Any]: """The graph as data: each cell's names and neighbours, and what is wrong.""" - cells = {} + cells: dict[str, Any] = {} for cell_id in self._code: - cell = self._graph.cells[cell_id] + # marimo types its ids as `CellId_t`, a `NewType` over `str`: the + # ids arrive as plain strings over the wire and are the same values. + key = cast("CellId_t", cell_id) + cell = self._graph.cells[key] cells[cell_id] = { "defs": sorted(cell.defs), "refs": sorted(cell.refs), - "parents": sorted(self._graph.parents.get(cell_id, ())), - "children": sorted(self._graph.children.get(cell_id, ())), + "parents": sorted(self._graph.parents.get(key, set())), + "children": sorted(self._graph.children.get(key, set())), } return { "cells": cells, @@ -124,12 +130,12 @@ def snapshot(self): # -- diagnostics ----------------------------------------------------- - def _conflicts(self, cell_id): - defs = self._graph.cells[cell_id].defs + def _conflicts(self, cell_id: str) -> list[str]: + defs = self._graph.cells[cast("CellId_t", cell_id)].defs return sorted(name for name in self._graph.get_multiply_defined() if name in defs) - def _cycle_cells(self): - cells = set() + def _cycle_cells(self) -> set[str]: + cells: set[str] = set() for cycle in self._graph.cycles: for edge in cycle: if isinstance(edge, (tuple, list)): @@ -138,46 +144,16 @@ def _cycle_cells(self): cells.add(edge) return cells - def _in_cycle(self, cell_id): + def _in_cycle(self, cell_id: str) -> bool: return cell_id in self._cycle_cells() # -- the wire -------------------------------------------------------- - def answer(self, method, *args): + def answer(self, method: str, *args: Any) -> None: """Print one method's result as a marked base64 JSON line.""" payload = _marimo_json.dumps(getattr(self, method)(*args)) - print("__MARIMO__" + _marimo_b64.b64encode(payload.encode("utf-8")).decode("ascii")) + print(ANSWER_MARKER + _marimo_b64.b64encode(payload.encode("utf-8")).decode("ascii")) if "__marimo_reactive__" not in globals(): __marimo_reactive__ = _MarimoReactive() -''' - - -def decode_answer(stdout_lines: list[str]) -> Any: - """The helper's answer, from the stdout lines an execution produced. - - Raises `LookupError` when no answer line is there: the helper is not - installed, or the kernel wrote an error instead. - """ - import base64 - import json - - for line in reversed(stdout_lines): - line = line.strip() - if line.startswith(ANSWER_MARKER): - return json.loads(base64.b64decode(line[len(ANSWER_MARKER) :]).decode("utf-8")) - raise LookupError("The kernel returned no Marimo graph answer.") - - -def question(method: str, *args: Any) -> str: - """The code that asks the kernel's helper one thing.""" - arguments = ", ".join(repr(argument) for argument in args) - return f"{HELPER_NAME}.answer({method!r}{', ' if arguments else ''}{arguments})" - - -def local_helper() -> Any: - """The helper, executed here: the graph without a kernel (needs marimo).""" - namespace: dict[str, Any] = {} - exec(KERNEL_HELPER_SOURCE, namespace) # noqa: S102 - our own source, above - return namespace[HELPER_NAME] diff --git a/code_sandboxes/sandboxes/modal/__init__.py b/code_sandboxes/sandboxes/modal/__init__.py new file mode 100644 index 0000000..480d8b5 --- /dev/null +++ b/code_sandboxes/sandboxes/modal/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Modal sandbox implementation. + +The implementation lives in :mod:`.modal`; this package re-exports it. +""" + +from .modal import ( + DEFAULT_APP_NAME, + DEFAULT_MODAL_PYTHON_VERSION, + ModalSandbox, + logger, +) + +__all__ = [ + "DEFAULT_APP_NAME", + "DEFAULT_MODAL_PYTHON_VERSION", + "ModalSandbox", + "logger", +] diff --git a/code_sandboxes/modal_sandbox.py b/code_sandboxes/sandboxes/modal/modal.py similarity index 99% rename from code_sandboxes/modal_sandbox.py rename to code_sandboxes/sandboxes/modal/modal.py index f2cf697..6798576 100644 --- a/code_sandboxes/modal_sandbox.py +++ b/code_sandboxes/sandboxes/modal/modal.py @@ -26,8 +26,8 @@ from dataclasses import dataclass from typing import Any -from .base import Sandbox, marks_execution -from .contents import ( +from ...base import Sandbox, marks_execution +from ...contents import ( CREDENTIAL_DELIVERY_UNSUPPORTED, FILESYSTEM_PRIMITIVES, ContentAttachmentSpec, @@ -40,9 +40,9 @@ prepare_local_bridge, stop_bridge_mount, ) -from .exceptions import SandboxConfigurationError, SandboxNotStartedError -from .jupyter_ingress import preparation_command, resolved_options, websocket_url -from .models import ( +from ...exceptions import SandboxConfigurationError, SandboxNotStartedError +from ...jupyter_ingress import preparation_command, resolved_options, websocket_url +from ...models import ( CodeError, Context, ExecutionResult, diff --git a/code_sandboxes/sandboxes/monty/__init__.py b/code_sandboxes/sandboxes/monty/__init__.py new file mode 100644 index 0000000..61dda70 --- /dev/null +++ b/code_sandboxes/sandboxes/monty/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""Monty sandbox implementation. + +The implementation lives in :mod:`.monty`; this package re-exports it. +""" + +from .monty import ( + MontySandbox, + logger, +) + +__all__ = [ + "MontySandbox", + "logger", +] diff --git a/code_sandboxes/monty_sandbox.py b/code_sandboxes/sandboxes/monty/monty.py similarity index 98% rename from code_sandboxes/monty_sandbox.py rename to code_sandboxes/sandboxes/monty/monty.py index aa8b0d5..07d4e66 100644 --- a/code_sandboxes/monty_sandbox.py +++ b/code_sandboxes/sandboxes/monty/monty.py @@ -25,9 +25,9 @@ import uuid from typing import Any -from .base import Sandbox, marks_execution -from .exceptions import SandboxConfigurationError, SandboxNotStartedError -from .models import ( +from ...base import Sandbox, marks_execution +from ...exceptions import SandboxConfigurationError, SandboxNotStartedError +from ...models import ( CodeError, Context, ExecutionResult, diff --git a/docs/docs/providers/datalayer.mdx b/docs/docs/providers/datalayer.mdx index 06da1a1..c18769b 100644 --- a/docs/docs/providers/datalayer.mdx +++ b/docs/docs/providers/datalayer.mdx @@ -38,10 +38,11 @@ with Sandbox.create( sandbox.run_code("import torch; print(torch.cuda.is_available())") ``` -The concrete implementation is available from a top-level module: +The concrete implementation lives in its own subpackage (and is re-exported +from `code_sandboxes`): ```python -from code_sandboxes.datalayer_sandbox import DatalayerSandbox +from code_sandboxes.sandboxes.datalayer import DatalayerSandbox ``` ## Snapshots diff --git a/docs/docs/providers/docker.mdx b/docs/docs/providers/docker.mdx index 07a6929..fb50758 100644 --- a/docs/docs/providers/docker.mdx +++ b/docs/docs/providers/docker.mdx @@ -32,8 +32,9 @@ with Sandbox.create(variant="docker", image="code-sandboxes-jupyter:latest") as print(result.stdout) ``` -The concrete implementation is available from a top-level module: +The concrete implementation lives in its own subpackage (and is re-exported +from `code_sandboxes`): ```python -from code_sandboxes.docker_sandbox import DockerSandbox +from code_sandboxes.sandboxes.docker import DockerSandbox ``` diff --git a/docs/docs/providers/eval.mdx b/docs/docs/providers/eval.mdx index e02b2d7..d1fee34 100644 --- a/docs/docs/providers/eval.mdx +++ b/docs/docs/providers/eval.mdx @@ -22,8 +22,9 @@ with Sandbox.create(variant="eval") as sandbox: result = sandbox.run_code("print(x)") # prints 2 ``` -The concrete implementation is available from a top-level module: +The concrete implementation lives in its own subpackage (and is re-exported +from `code_sandboxes`): ```python -from code_sandboxes.eval_sandbox import EvalSandbox +from code_sandboxes.sandboxes.eval import EvalSandbox ``` diff --git a/docs/docs/providers/index.mdx b/docs/docs/providers/index.mdx index 4f3c74c..b6d23fc 100644 --- a/docs/docs/providers/index.mdx +++ b/docs/docs/providers/index.mdx @@ -56,13 +56,14 @@ sandbox = Sandbox.create( ## Sandbox Variants -Concrete implementations are available from top-level modules: +Every provider lives in its own subpackage under `code_sandboxes.sandboxes`; +the classes are also re-exported from `code_sandboxes` itself: ```python -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 +from code_sandboxes.sandboxes.jupyter_server import JupyterServerSandbox +from code_sandboxes.sandboxes.docker import DockerSandbox +from code_sandboxes.sandboxes.eval import EvalSandbox +from code_sandboxes.sandboxes.datalayer import DatalayerSandbox ``` Each page below explains how to configure each variant. diff --git a/docs/docs/providers/jupyter-server.mdx b/docs/docs/providers/jupyter-server.mdx index c25902a..5de801c 100644 --- a/docs/docs/providers/jupyter-server.mdx +++ b/docs/docs/providers/jupyter-server.mdx @@ -49,8 +49,9 @@ 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: +The concrete implementation lives in its own subpackage (and is re-exported +from `code_sandboxes`): ```python -from code_sandboxes.jupyter_server_sandbox import JupyterServerSandbox +from code_sandboxes.sandboxes.jupyter_server import JupyterServerSandbox ``` diff --git a/docs/docs/providers/marimo.mdx b/docs/docs/providers/marimo.mdx index c32da56..7a15c12 100644 --- a/docs/docs/providers/marimo.mdx +++ b/docs/docs/providers/marimo.mdx @@ -147,10 +147,11 @@ reply["marimo"]["reactions"][0]["outputs"] # [{"output_type": "stream", .. `register_cell`, `remove_cell`, `plan`, `graph` and `cells` pass through to it; on any other variant they raise `TypeError`. -The concrete implementation is available from a top-level module: +The concrete implementation lives in its own subpackage (and is re-exported +from `code_sandboxes`): ```python -from code_sandboxes.marimo_sandbox import MarimoSandbox +from code_sandboxes.sandboxes.marimo import MarimoSandbox ``` ## Reactive cells on any sandbox: `MarimoCells` @@ -192,18 +193,24 @@ cells.run_cell("a", "x = 2").reactions[0].cell_id # "b", re-run client whose sandbox *is* a `MarimoSandbox`, both see one graph. This is what an MCP toolset uses to give an agent reactive cells on the -sandbox its session already holds (the Datalayer MCP Server's `marimo` -toolset, code-sandboxes#37). +sandbox its session already holds: the Datalayer MCP Server serves it as the +`marimo` toolset (`https://mcp.datalayer.run/mcp?marimo` — five `marimo_*` +tools, priced as `execute_code`), and the +[toolsets page](https://datalayer.ai/docs/mcp/toolsets) shows the a/b +exchange as run against the live gateway (code-sandboxes#37). ## How it works -- **One helper, sent as source.** `code_sandboxes.marimo_reactive` holds the - source of a small class, `_MarimoReactive`, that keeps a Marimo dataflow - graph (`marimo._runtime.dataflow.DirectedGraph`) beside the kernel's - namespace. A `marimo` sandbox sends it as one execute request at `start()`; - a driver sends it at `install()`. It binds itself to `__marimo_reactive__` - and is not replaced when it is already there, which is what keeps the - graph across drivers. +- **One helper, sent as source.** `code_sandboxes.sandboxes.marimo.reactive_kernel` + is a real, typed module — not code in a string — defining `_MarimoReactive`, + the class that keeps a Marimo dataflow graph + (`marimo._runtime.dataflow.DirectedGraph`) beside the kernel's namespace. + Its *file text* is what `reactive.KERNEL_HELPER_SOURCE` holds: a `marimo` + sandbox sends it as one execute request at `start()`, a driver at + `install()`. The module is self-contained (marimo imported lazily, nothing + from `code_sandboxes`), binds itself to `__marimo_reactive__`, and is not + replaced when it is already there — which is what keeps the graph across + drivers. - **Questions are execute requests; answers are one stdout line.** Every graph call — `register`, `remove`, `plan`, `snapshot`, `codes` — is `__marimo_reactive__.answer("plan", "a")` sent as code; the helper prints @@ -214,12 +221,12 @@ toolset, code-sandboxes#37). the `jupyter-server` sandbox — the same kernel, the same namespace, the same output messages — and a reaction is another one. The order comes from the graph; the results come from the kernel. -- **The same source runs in three places.** The package executes the helper - source at import time too, so its tests exercise exactly the code the - kernel runs and `local_helper()` answers graph questions without a kernel; - and `@datalayer/jupyter-react` carries the source verbatim - (`jupyter/marimo/reactive.ts`) for the browser-side components. Keep the - copies identical. +- **The same code runs in three places.** The module imports for the tests + and the type-checker, its text executes in the kernel, and + `local_helper()` executes the same text here, so graph questions can be + answered without a kernel; `@datalayer/jupyter-react` carries the source + verbatim (`jupyter/marimo/reactive.ts`) for the browser-side components. + Keep the copies identical. ## Management diff --git a/docs/docs/providers/monty.mdx b/docs/docs/providers/monty.mdx index b13f75d..e52548a 100644 --- a/docs/docs/providers/monty.mdx +++ b/docs/docs/providers/monty.mdx @@ -37,7 +37,7 @@ with Sandbox.create(variant="monty") as sandbox: You can expose host callables to the sandboxed code and enable type checking: ```python -from code_sandboxes.monty_sandbox import MontySandbox +from code_sandboxes.sandboxes.monty import MontySandbox sandbox = MontySandbox( type_check=True, diff --git a/pyproject.toml b/pyproject.toml index a36c1b4..22053fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,11 +174,12 @@ ignore = [ "tests/test_sandboxes.py" = ["F821"] "examples/*" = ["S108", "T201"] "code_sandboxes/base.py" = ["UP007"] -"code_sandboxes/datalayer_sandbox.py" = ["C901", "S110"] -"code_sandboxes/docker_sandbox.py" = ["C901", "S110", "UP007"] -"code_sandboxes/eval_sandbox.py" = ["C901", "S102", "S307"] -"code_sandboxes/jupyter_sandbox.py" = ["C901", "S110", "S603", "UP007"] -"code_sandboxes/e2b_sandbox.py" = ["C901"] +"code_sandboxes/sandboxes/datalayer/datalayer.py" = ["C901", "S110"] +"code_sandboxes/sandboxes/docker/docker.py" = ["C901", "S110", "UP007"] +"code_sandboxes/sandboxes/eval/eval.py" = ["C901", "S102", "S307"] +"code_sandboxes/sandboxes/e2b/e2b.py" = ["C901"] +# The module the kernel executes: its answers ARE stdout (T201), by design. +"code_sandboxes/sandboxes/marimo/reactive_kernel.py" = ["T201"] # A serializer, the Dockerfile parser and the spec's rules read top to bottom; # splitting them to please a complexity count would hide the rules. "code_sandboxes/environments/canonical.py" = ["C901"] diff --git a/tests/conftest.py b/tests/conftest.py index 6a6b0f3..f97ee5b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,8 +8,8 @@ import pytest -from code_sandboxes.eval_sandbox import EvalSandbox from code_sandboxes.models import SandboxConfig +from code_sandboxes.sandboxes.eval import EvalSandbox @pytest.fixture diff --git a/tests/test_a_datalayer_sandbox_can_be_interrupted.py b/tests/test_a_datalayer_sandbox_can_be_interrupted.py index 0667220..0493dc1 100644 --- a/tests/test_a_datalayer_sandbox_can_be_interrupted.py +++ b/tests/test_a_datalayer_sandbox_can_be_interrupted.py @@ -29,7 +29,7 @@ from __future__ import annotations -from code_sandboxes.datalayer_sandbox import DatalayerSandbox +from code_sandboxes.sandboxes.datalayer import DatalayerSandbox class _Client: @@ -121,7 +121,7 @@ def interrupt(self): def test_the_far_end_of_the_delegation_implements_it(self): """`_do_interrupt` delegating to a variant that has none would be the same silence one level down.""" - from code_sandboxes.jupyter_server_sandbox import JupyterServerSandbox + from code_sandboxes.sandboxes.jupyter_server import JupyterServerSandbox assert "_do_interrupt" in JupyterServerSandbox.__dict__ @@ -146,7 +146,7 @@ class TestTheInterruptIsReached: def test_run_code_says_that_code_is_running(self): import threading - from code_sandboxes.datalayer_sandbox import DatalayerSandbox + from code_sandboxes.sandboxes.datalayer import DatalayerSandbox seen = [] diff --git a/tests/test_cli_exec.py b/tests/test_cli_exec.py index 89f99cd..ae7d516 100644 --- a/tests/test_cli_exec.py +++ b/tests/test_cli_exec.py @@ -88,7 +88,7 @@ def spy(self): stopped.append(True) return real_stop(self) - monkeypatch.setattr("code_sandboxes.eval_sandbox.EvalSandbox.stop", spy) + monkeypatch.setattr("code_sandboxes.sandboxes.eval.EvalSandbox.stop", spy) _exec("print('ok')") _exec("raise ValueError('boom')") @@ -101,7 +101,7 @@ def test_the_variant_and_its_settings_are_forwarded(monkeypatch): def fake_create(*_args, **kwargs): captured.update(kwargs) - from code_sandboxes.eval_sandbox import EvalSandbox + from code_sandboxes.sandboxes.eval import EvalSandbox return EvalSandbox() diff --git a/tests/test_client_contents.py b/tests/test_client_contents.py index 1e2f6fe..a2cebde 100644 --- a/tests/test_client_contents.py +++ b/tests/test_client_contents.py @@ -33,8 +33,8 @@ ContentManifest, PreparedAttachment, ) -from code_sandboxes.eval_sandbox import EvalSandbox from code_sandboxes.exceptions import SandboxNotStartedError +from code_sandboxes.sandboxes.eval import EvalSandbox TOKEN = "short-lived-sandbox-token" diff --git a/tests/test_client_files.py b/tests/test_client_files.py index bf29ccc..dae057c 100644 --- a/tests/test_client_files.py +++ b/tests/test_client_files.py @@ -19,8 +19,8 @@ import pytest from code_sandboxes.client import CodeSandboxClient -from code_sandboxes.eval_sandbox import EvalSandbox from code_sandboxes.filesystem import FileInfo +from code_sandboxes.sandboxes.eval import EvalSandbox @pytest.fixture diff --git a/tests/test_cloudflare.py b/tests/test_cloudflare.py index 9c6d3e7..3eeade7 100644 --- a/tests/test_cloudflare.py +++ b/tests/test_cloudflare.py @@ -21,14 +21,6 @@ import pytest from code_sandboxes.base import Sandbox -from code_sandboxes.cloudflare_sandbox import ( - _RUNNER_SOURCE, - API_KEY_ENV_VAR, - API_URL_ENV_VAR, - CloudflareSandbox, - _reply_of, - _sse_events, -) from code_sandboxes.exceptions import ( SandboxConfigurationError, SandboxConnectionError, @@ -36,6 +28,14 @@ from code_sandboxes.manage import get_manager, manageable_variants from code_sandboxes.models import SandboxConfig, SandboxVariant from code_sandboxes.providers import get_provider +from code_sandboxes.sandboxes.cloudflare.cloudflare import ( + _RUNNER_SOURCE, + API_KEY_ENV_VAR, + API_URL_ENV_VAR, + CloudflareSandbox, + _reply_of, + _sse_events, +) BRIDGE_URL = "https://bridge.example.workers.dev" @@ -437,7 +437,7 @@ def test_the_manager_deletes_by_id_without_creating_a_container_to_do_it(): """A container made merely to hold a client is a container left billed.""" import httpx - from code_sandboxes import cloudflare_sandbox + from code_sandboxes.sandboxes.cloudflare import cloudflare as cloudflare_sandbox bridge = _FakeBridge() bridge.sandboxes.append("cf-sbx-existing") diff --git a/tests/test_contents_contract.py b/tests/test_contents_contract.py index fc791ec..926dc1d 100644 --- a/tests/test_contents_contract.py +++ b/tests/test_contents_contract.py @@ -63,11 +63,11 @@ ContentManifest, ready, ) -from code_sandboxes.datalayer_sandbox import DatalayerSandbox -from code_sandboxes.daytona_sandbox import DaytonaSandbox -from code_sandboxes.e2b_sandbox import E2BSandbox -from code_sandboxes.modal_sandbox import ModalSandbox from code_sandboxes.models import SandboxConfig +from code_sandboxes.sandboxes.datalayer import DatalayerSandbox +from code_sandboxes.sandboxes.daytona import DaytonaSandbox +from code_sandboxes.sandboxes.e2b import E2BSandbox +from code_sandboxes.sandboxes.modal import ModalSandbox PROVIDERS = ("datalayer", "daytona", "e2b", "modal") #: The providers whose SDK mounts a volume of its own, and only at creation. diff --git a/tests/test_coreweave.py b/tests/test_coreweave.py index 0859927..4ed4ccd 100644 --- a/tests/test_coreweave.py +++ b/tests/test_coreweave.py @@ -21,16 +21,16 @@ import pytest from code_sandboxes.base import Sandbox -from code_sandboxes.coreweave_sandbox import ( +from code_sandboxes.exceptions import SandboxConfigurationError +from code_sandboxes.manage import get_manager, manageable_variants +from code_sandboxes.models import SandboxConfig, SandboxVariant +from code_sandboxes.providers import get_provider +from code_sandboxes.sandboxes.coreweave.coreweave import ( _DRIVER_SOURCE, DEFAULT_CONTAINER_IMAGE, CoreWeaveSandbox, _with_envs, ) -from code_sandboxes.exceptions import SandboxConfigurationError -from code_sandboxes.manage import get_manager, manageable_variants -from code_sandboxes.models import SandboxConfig, SandboxVariant -from code_sandboxes.providers import get_provider class _FakeStdin: diff --git a/tests/test_datalayer_sandbox.py b/tests/test_datalayer_sandbox.py index 55fdf19..775908a 100644 --- a/tests/test_datalayer_sandbox.py +++ b/tests/test_datalayer_sandbox.py @@ -16,13 +16,13 @@ import pytest -from code_sandboxes.datalayer_sandbox import DatalayerSandbox, _urls_for_run from code_sandboxes.exceptions import ( SandboxConfigurationError, SandboxConnectionError, SandboxNotFoundError, ) from code_sandboxes.models import SandboxConfig +from code_sandboxes.sandboxes.datalayer.datalayer import DatalayerSandbox, _urls_for_run #: Importing the SDK warns — about its coming move to platformdirs, about #: pydantic's class-based config. Neither is what these tests are about, and diff --git a/tests/test_daytona.py b/tests/test_daytona.py index f3c06dd..ebbeba4 100644 --- a/tests/test_daytona.py +++ b/tests/test_daytona.py @@ -21,17 +21,17 @@ import pytest from code_sandboxes.base import Sandbox -from code_sandboxes.daytona_sandbox import ( +from code_sandboxes.exceptions import SandboxConfigurationError +from code_sandboxes.manage import get_manager, manageable_variants +from code_sandboxes.models import SandboxConfig, SandboxVariant +from code_sandboxes.providers import get_provider +from code_sandboxes.sandboxes.daytona.daytona import ( _VALUE_MARKER, DaytonaSandbox, _capture_trailing_value, _Lines, _split_marker, ) -from code_sandboxes.exceptions import SandboxConfigurationError -from code_sandboxes.manage import get_manager, manageable_variants -from code_sandboxes.models import SandboxConfig, SandboxVariant -from code_sandboxes.providers import get_provider def _chunks(text: str, size: int = 7) -> list[str]: @@ -463,7 +463,7 @@ def test_the_name_travels_as_a_label_not_as_daytonas_name(): def test_a_gpu_daytona_does_not_have_is_refused_by_name(): daytona = pytest.importorskip("daytona") - from code_sandboxes.daytona_sandbox import _gpu_types + from code_sandboxes.sandboxes.daytona.daytona import _gpu_types with pytest.raises(SandboxConfigurationError, match="no GPU called 'T4'"): _gpu_types("T4", daytona) @@ -474,7 +474,7 @@ def test_a_gpu_daytona_does_not_have_is_refused_by_name(): def test_several_gpus_are_an_ordered_list_of_preferences(): """Daytona takes the first of them it can find, which is the point.""" daytona = pytest.importorskip("daytona") - from code_sandboxes.daytona_sandbox import _gpu_types + from code_sandboxes.sandboxes.daytona.daytona import _gpu_types assert _gpu_types("H100, rtx_4090 ,H200", daytona) == [ daytona.GpuType.H100, diff --git a/tests/test_e2b.py b/tests/test_e2b.py index f70b70b..d38db2e 100644 --- a/tests/test_e2b.py +++ b/tests/test_e2b.py @@ -22,11 +22,11 @@ import pytest from code_sandboxes.base import Sandbox -from code_sandboxes.e2b_sandbox import E2BSandbox, _result_data, _timestamp from code_sandboxes.exceptions import SandboxConfigurationError from code_sandboxes.manage import get_manager, manageable_variants from code_sandboxes.models import SandboxConfig, SandboxVariant from code_sandboxes.providers import get_provider +from code_sandboxes.sandboxes.e2b.e2b import E2BSandbox, _result_data, _timestamp class _FakeResult: diff --git a/tests/test_environment_conformance.py b/tests/test_environment_conformance.py index 49655d2..3c0a4ce 100644 --- a/tests/test_environment_conformance.py +++ b/tests/test_environment_conformance.py @@ -372,7 +372,7 @@ def test_the_probes_run_for_real_in_a_local_sandbox( from code_sandboxes.environments.contract import SANDBOX_CONTRACT_V1 from code_sandboxes.environments.doctor.build import build_zipapp - from code_sandboxes.eval_sandbox import EvalSandbox + from code_sandboxes.sandboxes.eval import EvalSandbox if os.geteuid() == 0: pytest.skip("a read-only directory is writable by root") diff --git a/tests/test_environment_daytona_builder.py b/tests/test_environment_daytona_builder.py index a5cbb53..73bc414 100644 --- a/tests/test_environment_daytona_builder.py +++ b/tests/test_environment_daytona_builder.py @@ -1055,7 +1055,7 @@ def stop(self): builder = a_builder() monkeypatch.setattr( - "code_sandboxes.daytona_sandbox.DaytonaSandbox", FakeSandbox, raising=False + "code_sandboxes.sandboxes.daytona.DaytonaSandbox", FakeSandbox, raising=False ) monkeypatch.setattr( "code_sandboxes.environments.conformance.run_core_tier", @@ -1095,7 +1095,7 @@ def stop(self): pass monkeypatch.setattr( - "code_sandboxes.daytona_sandbox.DaytonaSandbox", FakeSandbox, raising=False + "code_sandboxes.sandboxes.daytona.DaytonaSandbox", FakeSandbox, raising=False ) monkeypatch.setattr( "code_sandboxes.environments.conformance.run_core_tier", @@ -1132,7 +1132,7 @@ def stop(self): events.append("stop") monkeypatch.setattr( - "code_sandboxes.daytona_sandbox.DaytonaSandbox", FakeSandbox, raising=False + "code_sandboxes.sandboxes.daytona.DaytonaSandbox", FakeSandbox, raising=False ) monkeypatch.setattr( "code_sandboxes.environments.conformance.run_core_tier", diff --git a/tests/test_environment_modal_builder.py b/tests/test_environment_modal_builder.py index 4493991..416b997 100644 --- a/tests/test_environment_modal_builder.py +++ b/tests/test_environment_modal_builder.py @@ -819,7 +819,7 @@ def start(self): def stop(self): pass - monkeypatch.setattr("code_sandboxes.modal_sandbox.ModalSandbox", FakeSandbox) + monkeypatch.setattr("code_sandboxes.sandboxes.modal.ModalSandbox", FakeSandbox) monkeypatch.setattr( "code_sandboxes.environments.conformance.run_core_tier", lambda sandbox, **kwargs: ValidationResult(contract_version="sandbox-contract/v1"), @@ -1158,7 +1158,7 @@ def start(self): def stop(self): pass - monkeypatch.setattr("code_sandboxes.modal_sandbox.ModalSandbox", FakeSandbox) + monkeypatch.setattr("code_sandboxes.sandboxes.modal.ModalSandbox", FakeSandbox) monkeypatch.setattr( "code_sandboxes.environments.conformance.run_core_tier", lambda sandbox, **kwargs: ran.update(kwargs) or "the-result", @@ -1187,7 +1187,7 @@ def start(self): def stop(self): events.append("stop") - monkeypatch.setattr("code_sandboxes.modal_sandbox.ModalSandbox", FakeSandbox) + monkeypatch.setattr("code_sandboxes.sandboxes.modal.ModalSandbox", FakeSandbox) monkeypatch.setattr( "code_sandboxes.environments.conformance.run_core_tier", lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("the tier blew up")), diff --git a/tests/test_eval.py b/tests/test_eval.py index b17fe3d..1f9489a 100644 --- a/tests/test_eval.py +++ b/tests/test_eval.py @@ -6,9 +6,9 @@ import pytest -from code_sandboxes.eval_sandbox import EvalSandbox from code_sandboxes.exceptions import SandboxNotStartedError from code_sandboxes.models import SandboxConfig +from code_sandboxes.sandboxes.eval import EvalSandbox class TestEvalSandbox: diff --git a/tests/test_every_variant_has_a_real_interrupt.py b/tests/test_every_variant_has_a_real_interrupt.py index 11f144f..889c0a0 100644 --- a/tests/test_every_variant_has_a_real_interrupt.py +++ b/tests/test_every_variant_has_a_real_interrupt.py @@ -48,11 +48,14 @@ def variants(): import pkgutil import code_sandboxes + import code_sandboxes.sandboxes - for module in pkgutil.iter_modules(code_sandboxes.__path__): - if not module.name.endswith("_sandbox"): + # One package per provider since 1.10.0, its implementation in + # `/.py`. + for package in pkgutil.iter_modules(code_sandboxes.sandboxes.__path__): + if not package.ispkg: continue - loaded = importlib.import_module(f"code_sandboxes.{module.name}") + loaded = importlib.import_module(f"code_sandboxes.sandboxes.{package.name}.{package.name}") for name in dir(loaded): value = getattr(loaded, name) if ( @@ -61,7 +64,7 @@ def variants(): and value is not Sandbox and value.__module__ == loaded.__name__ ): - yield module.name, name, value + yield package.name, name, value class TestTheWholePackage: @@ -197,15 +200,15 @@ def interrupt(self): @pytest.mark.parametrize( ("module", "name"), [ - ("docker_sandbox", "DockerSandbox"), - ("google_colab_sandbox", "GoogleColabSandbox"), - ("kaggle_sandbox", "KaggleSandbox"), + ("docker", "DockerSandbox"), + ("google_colab", "GoogleColabSandbox"), + ("kaggle", "KaggleSandbox"), ], ) def test_it_interrupts_through_its_client(self, module, name): import importlib - cls = getattr(importlib.import_module(f"code_sandboxes.{module}"), name) + cls = getattr(importlib.import_module(f"code_sandboxes.sandboxes.{module}"), name) sandbox = cls.__new__(cls) client = self._Client() sandbox._client = client @@ -217,15 +220,15 @@ def test_it_interrupts_through_its_client(self, module, name): @pytest.mark.parametrize( ("module", "name"), [ - ("docker_sandbox", "DockerSandbox"), - ("google_colab_sandbox", "GoogleColabSandbox"), - ("kaggle_sandbox", "KaggleSandbox"), + ("docker", "DockerSandbox"), + ("google_colab", "GoogleColabSandbox"), + ("kaggle", "KaggleSandbox"), ], ) def test_without_a_client_it_says_no(self, module, name): import importlib - cls = getattr(importlib.import_module(f"code_sandboxes.{module}"), name) + cls = getattr(importlib.import_module(f"code_sandboxes.sandboxes.{module}"), name) sandbox = cls.__new__(cls) sandbox._client = None sandbox._server_url = None @@ -247,16 +250,16 @@ class TestThePorvidersThatCannotBeInterrupted: @pytest.mark.parametrize( ("module", "name"), [ - ("cloudflare_sandbox", "CloudflareSandbox"), - ("coreweave_sandbox", "CoreWeaveSandbox"), - ("daytona_sandbox", "DaytonaSandbox"), - ("e2b_sandbox", "E2BSandbox"), - ("modal_sandbox", "ModalSandbox"), - ("monty_sandbox", "MontySandbox"), + ("cloudflare", "CloudflareSandbox"), + ("coreweave", "CoreWeaveSandbox"), + ("daytona", "DaytonaSandbox"), + ("e2b", "E2BSandbox"), + ("modal", "ModalSandbox"), + ("monty", "MontySandbox"), ], ) def test_it_refuses_rather_than_pretending(self, module, name): import importlib - cls = getattr(importlib.import_module(f"code_sandboxes.{module}"), name) + cls = getattr(importlib.import_module(f"code_sandboxes.sandboxes.{module}"), name) assert cls._do_interrupt(cls.__new__(cls)) is False diff --git a/tests/test_factory.py b/tests/test_factory.py index 3c5db42..9739a36 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -9,15 +9,15 @@ import pytest from code_sandboxes.base import Sandbox, SandboxVariant -from code_sandboxes.datalayer_sandbox import DatalayerSandbox -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_server_sandbox import JupyterServerSandbox -from code_sandboxes.kaggle_sandbox import KaggleSandbox -from code_sandboxes.modal_sandbox import ModalSandbox from code_sandboxes.models import SandboxConfig -from code_sandboxes.monty_sandbox import MontySandbox +from code_sandboxes.sandboxes.datalayer import DatalayerSandbox +from code_sandboxes.sandboxes.docker import DockerSandbox +from code_sandboxes.sandboxes.eval import EvalSandbox +from code_sandboxes.sandboxes.google_colab import GoogleColabSandbox +from code_sandboxes.sandboxes.jupyter_server import JupyterServerSandbox +from code_sandboxes.sandboxes.kaggle import KaggleSandbox +from code_sandboxes.sandboxes.modal import ModalSandbox +from code_sandboxes.sandboxes.monty import MontySandbox class TestSandboxFactory: diff --git a/tests/test_google_colab.py b/tests/test_google_colab.py index 362bdbf..3334cc1 100644 --- a/tests/test_google_colab.py +++ b/tests/test_google_colab.py @@ -8,7 +8,7 @@ import pytest -from code_sandboxes.google_colab import ( +from code_sandboxes.sandboxes.google_colab.client import ( COLAB_CLIENT_AGENT_HEADER, COLAB_RUNTIME_PROXY_TOKEN_HEADER, COLAB_RUNTIME_PROXY_TOKEN_PARAM, @@ -35,7 +35,8 @@ def fake_kernel_client_init(self, *args, **kwargs): captured.update(kwargs) monkeypatch.setattr( - "code_sandboxes.google_colab.JupyterKernelClient.__init__", fake_kernel_client_init + "code_sandboxes.sandboxes.google_colab.client.JupyterKernelClient.__init__", + fake_kernel_client_init, ) GoogleColabKernelClient( @@ -68,7 +69,8 @@ def fake_kernel_client_init(self, *args, **kwargs): captured.update(kwargs) monkeypatch.setattr( - "code_sandboxes.google_colab.JupyterKernelClient.__init__", fake_kernel_client_init + "code_sandboxes.sandboxes.google_colab.client.JupyterKernelClient.__init__", + fake_kernel_client_init, ) GoogleColabKernelClient( @@ -112,7 +114,8 @@ def fake_kernel_client_init(self, *args, **kwargs): captured.update(kwargs) monkeypatch.setattr( - "code_sandboxes.google_colab.JupyterKernelClient.__init__", fake_kernel_client_init + "code_sandboxes.sandboxes.google_colab.client.JupyterKernelClient.__init__", + fake_kernel_client_init, ) GoogleColabKernelClient.from_channels_url(CHANNELS_URL) diff --git a/tests/test_integration.py b/tests/test_integration.py index 6724a8f..3f68697 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -6,7 +6,7 @@ from pathlib import Path -from code_sandboxes.eval_sandbox import EvalSandbox +from code_sandboxes.sandboxes.eval import EvalSandbox class TestIntegration: diff --git a/tests/test_jupyter_ingress.py b/tests/test_jupyter_ingress.py index da88962..94b01a8 100644 --- a/tests/test_jupyter_ingress.py +++ b/tests/test_jupyter_ingress.py @@ -4,15 +4,13 @@ """Provider-independent Jupyter ingress preparation and credentials.""" -# ruff: noqa: S106 - from types import SimpleNamespace from code_sandboxes import JupyterServerOptions -from code_sandboxes.daytona_sandbox import DaytonaSandbox -from code_sandboxes.e2b_sandbox import E2BSandbox from code_sandboxes.jupyter_ingress import preparation_command -from code_sandboxes.modal_sandbox import ModalSandbox +from code_sandboxes.sandboxes.daytona import DaytonaSandbox +from code_sandboxes.sandboxes.e2b import E2BSandbox +from code_sandboxes.sandboxes.modal import ModalSandbox class _Result: diff --git a/tests/test_jupyter_server.py b/tests/test_jupyter_server.py index e582ee2..d880ed0 100644 --- a/tests/test_jupyter_server.py +++ b/tests/test_jupyter_server.py @@ -16,8 +16,11 @@ from code_sandboxes.client import CodeSandboxClient from code_sandboxes.exceptions import SandboxConfigurationError -from code_sandboxes.jupyter_server_sandbox import JupyterServerSandbox from code_sandboxes.models import SandboxConfig +from code_sandboxes.sandboxes.jupyter_server import JupyterServerSandbox + +#: Where the Jupyter Server sandbox posts, patched by the tests below. +_POST = "code_sandboxes.sandboxes.jupyter_server.jupyter_server.requests.post" def test_explicit_kernel_id_wins_over_reuse(monkeypatch): @@ -552,7 +555,7 @@ def _post(url, params=None, headers=None, timeout=None): asked["params"] = params return _Response() - monkeypatch.setattr("code_sandboxes.jupyter_server_sandbox.requests.post", _post) + monkeypatch.setattr(_POST, _post) try: assert sandbox.restart_kernel() is True assert asked["url"].endswith("/api/kernels/kernel-1/restart") @@ -577,7 +580,7 @@ class _Response: status_code = 200 monkeypatch.setattr( - "code_sandboxes.jupyter_server_sandbox.requests.post", + _POST, lambda *args, **kwargs: _Response(), ) try: @@ -595,7 +598,7 @@ class _Response: status_code = 503 monkeypatch.setattr( - "code_sandboxes.jupyter_server_sandbox.requests.post", + _POST, lambda *args, **kwargs: _Response(), ) try: @@ -609,7 +612,7 @@ def test_a_server_that_cannot_be_reached_is_reported_not_raised(self, monkeypatc def _explode(*args, **kwargs): raise OSError("no route to host") - monkeypatch.setattr("code_sandboxes.jupyter_server_sandbox.requests.post", _explode) + monkeypatch.setattr(_POST, _explode) try: assert sandbox.restart_kernel() is False finally: @@ -621,9 +624,7 @@ def test_a_sandbox_with_no_kernel_id_asks_nothing(self, monkeypatch): def _should_not_be_called(*args, **kwargs): raise AssertionError("the server must not be asked without a kernel id") - monkeypatch.setattr( - "code_sandboxes.jupyter_server_sandbox.requests.post", _should_not_be_called - ) + monkeypatch.setattr(_POST, _should_not_be_called) try: assert sandbox.restart_kernel() is False finally: diff --git a/tests/test_jupyter_server_streaming.py b/tests/test_jupyter_server_streaming.py index 11fdabe..e1b764d 100644 --- a/tests/test_jupyter_server_streaming.py +++ b/tests/test_jupyter_server_streaming.py @@ -20,8 +20,8 @@ import pytest -from code_sandboxes.jupyter_server_sandbox import JupyterServerSandbox from code_sandboxes.models import CodeError, ExecutionResult, Logs, OutputMessage, Result +from code_sandboxes.sandboxes.jupyter_server import JupyterServerSandbox class _SlowSandbox(JupyterServerSandbox): diff --git a/tests/test_kaggle.py b/tests/test_kaggle.py index 3dc3ec4..e39486e 100644 --- a/tests/test_kaggle.py +++ b/tests/test_kaggle.py @@ -8,7 +8,7 @@ import pytest -from code_sandboxes.kaggle import ( +from code_sandboxes.sandboxes.kaggle.client import ( KaggleKernelClient, parse_kaggle_channels_url, ) @@ -46,7 +46,8 @@ def fake_kernel_client_init(self, *args, **kwargs): monkeypatch.delenv("KAGGLE_API_TOKEN", raising=False) monkeypatch.setattr( - "code_sandboxes.kaggle.JupyterKernelClient.__init__", fake_kernel_client_init + "code_sandboxes.sandboxes.kaggle.client.JupyterKernelClient.__init__", + fake_kernel_client_init, ) KaggleKernelClient( @@ -69,7 +70,8 @@ def fake_kernel_client_init(self, *args, **kwargs): monkeypatch.setenv("KAGGLE_API_TOKEN", "env-token") monkeypatch.setattr( - "code_sandboxes.kaggle.JupyterKernelClient.__init__", fake_kernel_client_init + "code_sandboxes.sandboxes.kaggle.client.JupyterKernelClient.__init__", + fake_kernel_client_init, ) KaggleKernelClient(server_url=SERVER_URL) @@ -85,7 +87,8 @@ def fake_kernel_client_init(self, *args, **kwargs): monkeypatch.delenv("KAGGLE_API_TOKEN", raising=False) monkeypatch.setattr( - "code_sandboxes.kaggle.JupyterKernelClient.__init__", fake_kernel_client_init + "code_sandboxes.sandboxes.kaggle.client.JupyterKernelClient.__init__", + fake_kernel_client_init, ) KaggleKernelClient(server_url=SERVER_URL) @@ -101,7 +104,8 @@ def fake_kernel_client_init(self, *args, **kwargs): monkeypatch.delenv("KAGGLE_API_TOKEN", raising=False) monkeypatch.setattr( - "code_sandboxes.kaggle.JupyterKernelClient.__init__", fake_kernel_client_init + "code_sandboxes.sandboxes.kaggle.client.JupyterKernelClient.__init__", + fake_kernel_client_init, ) KaggleKernelClient.from_channels_url(CHANNELS_URL) @@ -118,7 +122,8 @@ def fake_kernel_client_init(self, *args, **kwargs): monkeypatch.setenv("KAGGLE_API_TOKEN", "env-token") monkeypatch.setattr( - "code_sandboxes.kaggle.JupyterKernelClient.__init__", fake_kernel_client_init + "code_sandboxes.sandboxes.kaggle.client.JupyterKernelClient.__init__", + fake_kernel_client_init, ) KaggleKernelClient(server_url=SERVER_URL) diff --git a/tests/test_kaggle_execute.py b/tests/test_kaggle_execute.py index 8e562c2..a4d513b 100644 --- a/tests/test_kaggle_execute.py +++ b/tests/test_kaggle_execute.py @@ -9,7 +9,7 @@ import pytest -from code_sandboxes.kaggle_execute import ( +from code_sandboxes.sandboxes.kaggle.execute import ( KaggleExecutionResult, KaggleKernelExecutor, _normalize_accelerator, diff --git a/tests/test_kaggle_live.py b/tests/test_kaggle_live.py index b4dd065..cca7d0c 100644 --- a/tests/test_kaggle_live.py +++ b/tests/test_kaggle_live.py @@ -7,7 +7,7 @@ import json from pathlib import Path -from code_sandboxes.kaggle_live import KaggleLiveSession, build_agent_code +from code_sandboxes.sandboxes.kaggle.live import KaggleLiveSession, build_agent_code class FakeApi: diff --git a/tests/test_kaggle_session.py b/tests/test_kaggle_session.py index 313f562..4d91cff 100644 --- a/tests/test_kaggle_session.py +++ b/tests/test_kaggle_session.py @@ -10,7 +10,7 @@ class TestKaggleBatchSession: """The batch session: state carried by replaying the code that ran.""" def _sandbox(self, **kwargs): - from code_sandboxes.kaggle_sandbox import KaggleSandbox + from code_sandboxes.sandboxes.kaggle import KaggleSandbox return KaggleSandbox(**kwargs) @@ -63,7 +63,7 @@ class TestKaggleStreamingSession: def _sandbox(self, outputs, status="COMPLETE"): from types import SimpleNamespace - from code_sandboxes.kaggle_sandbox import KaggleSandbox + from code_sandboxes.sandboxes.kaggle import KaggleSandbox class _Executor: """No `api` and no `output`: no polling, no artifact download.""" diff --git a/tests/test_kernel_client_compatibility.py b/tests/test_kernel_client_compatibility.py index 35d674b..429aa65 100644 --- a/tests/test_kernel_client_compatibility.py +++ b/tests/test_kernel_client_compatibility.py @@ -153,7 +153,7 @@ def test_the_kaggle_live_session_answers_the_same_calls(self): `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 + from code_sandboxes.sandboxes.kaggle.live import KaggleLiveSession _binds(KaggleLiveSession.execute, None, "print(1)", timeout=60.0) _binds(KaggleLiveSession.stop, None) diff --git a/tests/test_lifecycle_vocabulary.py b/tests/test_lifecycle_vocabulary.py index fdd1bc8..235ee8d 100644 --- a/tests/test_lifecycle_vocabulary.py +++ b/tests/test_lifecycle_vocabulary.py @@ -54,10 +54,10 @@ class TestVariants: @pytest.mark.parametrize( "module_name, class_name", [ - ("code_sandboxes.eval_sandbox", "EvalSandbox"), - ("code_sandboxes.docker_sandbox", "DockerSandbox"), - ("code_sandboxes.datalayer_sandbox", "DatalayerSandbox"), - ("code_sandboxes.jupyter_server_sandbox", "JupyterServerSandbox"), + ("code_sandboxes.sandboxes.eval", "EvalSandbox"), + ("code_sandboxes.sandboxes.docker", "DockerSandbox"), + ("code_sandboxes.sandboxes.datalayer", "DatalayerSandbox"), + ("code_sandboxes.sandboxes.jupyter_server", "JupyterServerSandbox"), ], ) def test_every_variant_conforms(self, module_name: str, class_name: str) -> None: @@ -74,14 +74,14 @@ def test_every_variant_conforms(self, module_name: str, class_name: str) -> None assert hasattr(variant, verb), f"{class_name} is missing {verb}" def test_a_conforming_instance_satisfies_the_protocol(self) -> None: - from code_sandboxes.eval_sandbox import EvalSandbox + from code_sandboxes.sandboxes.eval import EvalSandbox assert isinstance(EvalSandbox(), SandboxLifecycle) class TestRefusals: def test_a_provider_says_what_it_cannot_do_before_being_asked(self) -> None: - from code_sandboxes.eval_sandbox import EvalSandbox + from code_sandboxes.sandboxes.eval import EvalSandbox sandbox = EvalSandbox() @@ -92,7 +92,7 @@ def test_a_provider_says_what_it_cannot_do_before_being_asked(self) -> None: assert sandbox.supports("pause") is False def test_and_refuses_in_the_same_words(self) -> None: - from code_sandboxes.eval_sandbox import EvalSandbox + from code_sandboxes.sandboxes.eval import EvalSandbox sandbox = EvalSandbox() with pytest.raises(SandboxOperationNotSupported) as caught: @@ -108,7 +108,7 @@ def test_the_refusal_names_the_variant_when_it_knows_it(self) -> None: assert error.operation == "snapshot" def test_an_unknown_verb_is_simply_unsupported(self) -> None: - from code_sandboxes.eval_sandbox import EvalSandbox + from code_sandboxes.sandboxes.eval import EvalSandbox assert EvalSandbox().supports("teleport") is False diff --git a/tests/test_manage_datalayer.py b/tests/test_manage_datalayer.py index 7992b3a..9e05760 100644 --- a/tests/test_manage_datalayer.py +++ b/tests/test_manage_datalayer.py @@ -17,10 +17,12 @@ import pytest -from code_sandboxes import datalayer_sandbox from code_sandboxes.manage import DatalayerSandboxManager, SandboxManagementError from code_sandboxes.models import SandboxConfig, SandboxInfo, SandboxStatus +# The package `manage` takes the class from, which is where it is patched. +from code_sandboxes.sandboxes import datalayer as datalayer_sandbox + class _Sandbox: started: ClassVar[list[_Sandbox]] = [] diff --git a/tests/test_marimo_cells.py b/tests/test_marimo_cells.py index 658abd5..0d0a822 100644 --- a/tests/test_marimo_cells.py +++ b/tests/test_marimo_cells.py @@ -20,7 +20,7 @@ marimo = pytest.importorskip("marimo") -from code_sandboxes.marimo_cells import MarimoCells # noqa: E402 +from code_sandboxes.sandboxes.marimo.cells import MarimoCells # noqa: E402 class ReplyingClient: diff --git a/tests/test_marimo_reactive_kernel.py b/tests/test_marimo_reactive_kernel.py new file mode 100644 index 0000000..00a0f1c --- /dev/null +++ b/tests/test_marimo_reactive_kernel.py @@ -0,0 +1,141 @@ +# Copyright (c) 2025-2026 Datalayer, Inc. +# +# BSD 3-Clause License + +"""The kernel helper is real code, and the shipped text is that code. + +`reactive_kernel.py` is imported here — typed, coverable — and `reactive` +ships its file text to the kernel. The tests hold the two ends together and +cover the graph behaviours the drivers rely on: replacement, conflicts, +cycles, plans, and the wire encoding. +""" + +from __future__ import annotations + +import pytest + +marimo = pytest.importorskip("marimo") + +from code_sandboxes.sandboxes.marimo import reactive, reactive_kernel # noqa: E402 + + +@pytest.fixture +def graph(): + return reactive_kernel._MarimoReactive() + + +def test_the_shipped_source_is_the_modules_own_file(): + from importlib import resources + + text = ( + resources.files("code_sandboxes.sandboxes.marimo") + .joinpath("reactive_kernel.py") + .read_text(encoding="utf-8") + ) + assert reactive.KERNEL_HELPER_SOURCE == text + assert "class _MarimoReactive" in reactive.KERNEL_HELPER_SOURCE + + +def test_the_markers_agree_between_the_two_sides(): + assert reactive.ANSWER_MARKER == reactive_kernel.ANSWER_MARKER + assert reactive.HELPER_NAME == "__marimo_reactive__" + assert hasattr(reactive_kernel, reactive.HELPER_NAME) + + +def test_the_module_and_the_executed_text_are_the_same_code(graph): + executed = reactive.local_helper() + assert type(executed).__name__ == type(graph).__name__ + for helper in (graph, executed): + answer = helper.register("a", "x = 1") + assert answer == {"cell": "a", "defs": ["x"], "refs": [], "conflicts": [], "cycle": False} + + +def test_reexecuting_the_source_keeps_the_graph(): + """A second driver on the same kernel must not wipe the first one's cells.""" + namespace: dict = {} + exec(reactive.KERNEL_HELPER_SOURCE, namespace) # noqa: S102 - our own module text + namespace[reactive.HELPER_NAME].register("a", "x = 1") + exec(reactive.KERNEL_HELPER_SOURCE, namespace) # noqa: S102 + assert namespace[reactive.HELPER_NAME].codes() == {"a": "x = 1"} + + +def test_registering_a_name_again_replaces_the_cell(graph): + graph.register("a", "x = 1") + answer = graph.register("a", "x = 2\ny = x") + assert answer["defs"] == ["x", "y"] + assert graph.codes() == {"a": "x = 2\ny = x"} + + +def test_two_definers_are_a_conflict_and_both_parent_the_reader(graph): + graph.register("a", "x = 1") + conflicted = graph.register("b", "x = 2") + graph.register("c", "print(x)") + assert conflicted["conflicts"] == ["x"] + snapshot = graph.snapshot() + assert snapshot["conflicts"] == ["x"] + assert snapshot["cells"]["c"]["parents"] == ["a", "b"] + assert graph.plan("a") == ["c"] + assert graph.plan("b") == ["c"] + + +def test_a_cycle_is_reported_on_the_closing_cell_and_in_the_snapshot(graph): + first = graph.register("d", "p = q") + closing = graph.register("e", "q = p") + assert first["cycle"] is False + assert closing["cycle"] is True + assert graph.snapshot()["cycles"] == ["d", "e"] + + +def test_a_chain_plans_in_dependency_order(graph): + graph.register("c", "z = y + 1") + graph.register("b", "y = x + 1") + graph.register("a", "x = 1") + assert graph.plan("a") == ["b", "c"] + assert graph.plan_all() == ["a", "b", "c"] + assert graph.plan("c") == [] + + +def test_a_removed_cell_leaves_the_plan_and_the_snapshot(graph): + graph.register("a", "x = 1") + graph.register("b", "print(x)") + assert graph.remove("b") == {"cell": "b", "removed": True} + assert graph.plan("a") == [] + assert "b" not in graph.snapshot()["cells"] + # Removing what is not there answers the same way, so a driver retrying + # after a lost reply is not punished. + assert graph.remove("b") == {"cell": "b", "removed": True} + + +def test_a_plan_for_an_unregistered_cell_is_empty(graph): + assert graph.plan("nope") == [] + + +def test_the_wire_round_trip(graph, capsys): + graph.register("a", "x = 1") + graph.answer("plan", "a") + lines = capsys.readouterr().out.splitlines() + assert reactive.decode_answer(lines) == [] + graph.answer("codes") + assert reactive.decode_answer(capsys.readouterr().out.splitlines()) == {"a": "x = 1"} + + +def test_decode_answer_reads_the_last_answer_and_ignores_noise(graph, capsys): + graph.answer("codes") + graph.register("a", "x = 1") + graph.answer("codes") + lines = ["some print output", *capsys.readouterr().out.splitlines(), "trailing noise"] + assert reactive.decode_answer(lines) == {"a": "x = 1"} + + +def test_no_answer_is_a_lookup_error(): + with pytest.raises(LookupError): + reactive.decode_answer(["nothing here"]) + + +def test_question_quotes_its_arguments(): + assert reactive.question("plan", "a") == "__marimo_reactive__.answer('plan', 'a')" + assert reactive.question("codes") == "__marimo_reactive__.answer('codes')" + tricky = reactive.question("register", "a", "x = 'quoted'") + namespace: dict = {} + exec(reactive.KERNEL_HELPER_SOURCE, namespace) # noqa: S102 + exec(tricky, namespace) # noqa: S102 - the question we built one line up diff --git a/tests/test_marimo_sandbox.py b/tests/test_marimo_sandbox.py index 68c2d32..66f576a 100644 --- a/tests/test_marimo_sandbox.py +++ b/tests/test_marimo_sandbox.py @@ -22,8 +22,8 @@ marimo = pytest.importorskip("marimo") -from code_sandboxes import marimo_reactive # noqa: E402 -from code_sandboxes.marimo_sandbox import MarimoSandbox # noqa: E402 +from code_sandboxes.sandboxes.marimo import MarimoSandbox # noqa: E402 +from code_sandboxes.sandboxes.marimo import reactive as marimo_reactive # noqa: E402 class InProcessKernel: @@ -281,7 +281,7 @@ def test_a_failing_reaction_is_reported_not_hidden(sandbox): def test_the_client_offers_the_graph_to_a_reactive_sandbox_only(sandbox): from code_sandboxes import CodeSandboxClient - from code_sandboxes.eval_sandbox import EvalSandbox + from code_sandboxes.sandboxes.eval import EvalSandbox client = CodeSandboxClient(sandbox) assert client.reactive is True diff --git a/tests/test_modal_google_colab_sandbox.py b/tests/test_modal_google_colab_sandbox.py index 3f2859e..ce4a0be 100644 --- a/tests/test_modal_google_colab_sandbox.py +++ b/tests/test_modal_google_colab_sandbox.py @@ -12,10 +12,10 @@ import pytest -from code_sandboxes.google_colab_sandbox import GoogleColabSandbox -from code_sandboxes.kaggle_sandbox import KaggleSandbox -from code_sandboxes.modal_sandbox import ModalSandbox from code_sandboxes.models import SandboxConfig +from code_sandboxes.sandboxes.google_colab import GoogleColabSandbox +from code_sandboxes.sandboxes.kaggle import KaggleSandbox +from code_sandboxes.sandboxes.modal import ModalSandbox class _FakeStream: @@ -148,7 +148,7 @@ def execute(self, code, wait=True, timeout=0.0, download_output=True, accelerato ) monkeypatch.setattr( - "code_sandboxes.kaggle_sandbox.KaggleKernelExecutor", + "code_sandboxes.sandboxes.kaggle.kaggle.KaggleKernelExecutor", _FakeKaggleExecutor, ) @@ -190,7 +190,7 @@ def execute(self, code, wait=True, timeout=0.0, download_output=True, accelerato ) monkeypatch.setattr( - "code_sandboxes.kaggle_sandbox.KaggleKernelExecutor", + "code_sandboxes.sandboxes.kaggle.kaggle.KaggleKernelExecutor", _FakeKaggleExecutor, ) @@ -233,7 +233,7 @@ def execute(self, code, wait=True, timeout=0.0, download_output=True, accelerato ) monkeypatch.setattr( - "code_sandboxes.kaggle_sandbox.KaggleKernelExecutor", + "code_sandboxes.sandboxes.kaggle.kaggle.KaggleKernelExecutor", _FakeKaggleExecutor, ) @@ -284,7 +284,7 @@ def execute(self, code, wait=True, timeout=0.0, download_output=True, accelerato return _FakeKaggleResult() monkeypatch.setattr( - "code_sandboxes.kaggle_sandbox.KaggleKernelExecutor", + "code_sandboxes.sandboxes.kaggle.kaggle.KaggleKernelExecutor", _FakeKaggleExecutor, ) @@ -355,7 +355,7 @@ def output(self, slug, dest, force=True, quiet=None): return [str(path)] monkeypatch.setattr( - "code_sandboxes.kaggle_sandbox.KaggleKernelExecutor", + "code_sandboxes.sandboxes.kaggle.kaggle.KaggleKernelExecutor", _FakeKaggleExecutor, ) diff --git a/tests/test_modal_session.py b/tests/test_modal_session.py index a87f159..68f8817 100644 --- a/tests/test_modal_session.py +++ b/tests/test_modal_session.py @@ -10,7 +10,7 @@ def _driver_source() -> str: - text = open("code_sandboxes/modal_sandbox.py").read() + text = open("code_sandboxes/sandboxes/modal/modal.py").read() match = re.search(r'_DRIVER_SOURCE = """(.*?)"""', text, re.S) assert match return match.group(1) @@ -122,8 +122,8 @@ def _modal_with_drivers(): """A ModalSandbox whose `exec` hands out one fresh driver per call.""" from types import SimpleNamespace - from code_sandboxes.modal_sandbox import ModalSandbox from code_sandboxes.models import SandboxConfig + from code_sandboxes.sandboxes.modal import ModalSandbox drivers: list[_Driver] = []