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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
python-version: ['3.11', '3.12']
uses: ./.github/workflows/reusable-python.yml
uses: ./.github/workflows/reusable-python.yaml
with:
python-version: ${{ matrix.python-version }}
install-extras: test,typing,datalayer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ on:
- '.pre-commit-config.yaml'
- 'ruff.toml'
- '.ruff.toml'
- '.github/workflows/py-code-style.yml'
- '.github/workflows/reusable-python.yml'
- '.github/workflows/py-code-style.yaml'
- '.github/workflows/reusable-python.yaml'
pull_request:
branches:
- main
Expand All @@ -27,8 +27,8 @@ on:
- '.pre-commit-config.yaml'
- 'ruff.toml'
- '.ruff.toml'
- '.github/workflows/py-code-style.yml'
- '.github/workflows/reusable-python.yml'
- '.github/workflows/py-code-style.yaml'
- '.github/workflows/reusable-python.yaml'
workflow_dispatch:

concurrency:
Expand All @@ -37,7 +37,7 @@ concurrency:

jobs:
check-code-style:
uses: ./.github/workflows/reusable-python.yml
uses: ./.github/workflows/reusable-python.yaml
with:
python-version: '3.11'
extra-packages: pre-commit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ on:
- 'setup.py'
- 'requirements*.txt'
- '**/*.py'
- '.github/workflows/py-tests.yml'
- '.github/workflows/reusable-python.yml'
- '.github/workflows/py-tests.yaml'
- '.github/workflows/reusable-python.yaml'
pull_request:
branches:
- main
Expand All @@ -23,8 +23,8 @@ on:
- 'setup.py'
- 'requirements*.txt'
- '**/*.py'
- '.github/workflows/py-tests.yml'
- '.github/workflows/reusable-python.yml'
- '.github/workflows/py-tests.yaml'
- '.github/workflows/reusable-python.yaml'

workflow_dispatch:

Expand All @@ -34,7 +34,7 @@ jobs:
max-parallel: 1
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13']
uses: ./.github/workflows/reusable-python.yml
uses: ./.github/workflows/reusable-python.yaml
with:
python-version: ${{ matrix.python-version }}
install-extras: test,datalayer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ on:
- 'setup.py'
- 'mypy.ini'
- '.mypy.ini'
- '.github/workflows/py-typing.yml'
- '.github/workflows/reusable-python.yml'
- '.github/workflows/py-typing.yaml'
- '.github/workflows/reusable-python.yaml'
pull_request:
branches:
- main
Expand All @@ -25,8 +25,8 @@ on:
- 'setup.py'
- 'mypy.ini'
- '.mypy.ini'
- '.github/workflows/py-typing.yml'
- '.github/workflows/reusable-python.yml'
- '.github/workflows/py-typing.yaml'
- '.github/workflows/reusable-python.yaml'
workflow_dispatch:

concurrency:
Expand All @@ -35,7 +35,7 @@ concurrency:

jobs:
type-check:
uses: ./.github/workflows/reusable-python.yml
uses: ./.github/workflows/reusable-python.yaml
with:
python-version: '3.12'
install-extras: typing
Expand Down
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@

## Unreleased

## 1.9.39

- The GitHub workflow files are all `.yaml` now (`build`, `py-tests`,
`py-code-style`, `py-typing`, `reusable-python`, `environments-live`), the
spelling `release.yaml` already had; the reusable workflow's callers and
the contributing page follow.
- **Marimo reactivity through the Jupyter-shaped API** (#37). `run_code`
names its cell through the execution context (`Context(id=...)`; the same
id replaces the cell) and the result says what happened: `cell_id`, and
`reactions` — every cell re-run because of it, each a `Reaction` with its
code and its own `ExecutionResult`. `CodeSandboxClient.execute`,
`execute_code` and `execute_code_streaming` take `cell_id`; a reply carries
the reactions under `marimo` with their own Jupyter-shaped outputs;
`execute_interactive` emits them after the cell's own, each message tagged
`metadata.marimo = {cell_id, reaction}`; the stream tags every event with
`marimo_cell_id`. The client passes `run_cell`, `register_cell`,
`remove_cell`, `plan`, `graph` and `cells` through to a reactive sandbox and
says so with `reactive`. The `marimo_reactions` extra attribute is replaced
by the typed `reactions` field. Nothing changes on the wire.

## 1.9.38

- **The environments line and main are one branch again**: everything
Expand Down Expand Up @@ -337,7 +357,7 @@
that `stop()` skipped entirely, orphaned for good. Fixed identically in
`daytona_sandbox.py`, `modal_sandbox.py` and `e2b_sandbox.py`.
- **A nightly live matrix for the three managed builders**
(`.github/workflows/environments-live.yml`, PLAN_ENV.md E2-11). Builds a
(`.github/workflows/environments-live.yaml`, PLAN_ENV.md E2-11). Builds a
real artifact on each real provider from a real, hash-verified lock,
launches a sandbox from it, and runs the formal core tier — opening (or
commenting on) an issue naming the provider and the check on a genuine
Expand Down
6 changes: 6 additions & 0 deletions code_sandboxes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@
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 (
Expand All @@ -170,6 +171,7 @@
MIMEType,
OutputHandler,
OutputMessage,
Reaction,
ResourceConfig,
Result,
SandboxConfig,
Expand Down Expand Up @@ -204,7 +206,9 @@
"RUNTIMES_API_PREFIX",
"BuildEntry",
"BuiltArtifact",
"CellReply",
"CellRun",
"CellsRun",
"CloudflareSandbox",
"CodeError",
"CodeExecutionOutcome",
Expand Down Expand Up @@ -243,6 +247,7 @@
"Logs",
"MIMEType",
"ManifestLocation",
"MarimoCells",
"MarimoRun",
"MarimoSandbox",
"MaterializeEntry",
Expand All @@ -253,6 +258,7 @@
"PreparedAttachment",
"ProcessHandle",
"ProviderRequirement",
"Reaction",
"ResourceConfig",
"Result",
"Sandbox",
Expand Down
2 changes: 1 addition & 1 deletion code_sandboxes/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@

"""Code Sandboxes."""

__version__ = "1.9.38"
__version__ = "1.9.39"
124 changes: 109 additions & 15 deletions code_sandboxes/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
from .filesystem import FileInfo, SandboxFilesystem
from .models import (
CodeError,
Context,
ExecutionResult,
OutputMessage,
Result,
Expand Down Expand Up @@ -114,11 +115,32 @@ def execution_result_to_reply(execution: ExecutionResult) -> dict[str, Any]:
}
)

return {
reply: dict[str, Any] = {
"execution_count": execution.execution_count,
"outputs": outputs,
"status": "ok" if execution.success else "error",
}
if execution.cell_id is not None or execution.reactions:
# A reactive sandbox (Marimo): which cell this was, and what else
# ran because of it, each with its own Jupyter-shaped outputs — so a
# caller that only reads replies still sees every changed cell.
reply["marimo"] = {
"cell_id": execution.cell_id,
"reactions": [
{
"cell_id": reaction.cell_id,
"code": reaction.code,
# The same predicate as the reply's own status: a reaction
# that was interrupted, failed in the infrastructure or
# exited non-zero is not "ok" either.
"status": "ok" if reaction.result.success else "error",
"execution_count": reaction.result.execution_count,
"outputs": execution_result_to_reply(reaction.result)["outputs"],
}
for reaction in execution.reactions
],
}
return reply


@dataclass
Expand Down Expand Up @@ -405,32 +427,51 @@ async def close_async(self) -> None:
else:
self.close()

def _cell_kwargs(self, cell_id: str | None) -> dict[str, Any]:
"""The execution context that names a cell, only when one was named.

Passed as a keyword only then: a sandbox with no notion of cells —
every variant but Marimo, and any subclass someone wrote against the
older signatures — is called exactly as before.
"""
return {"context": Context(id=cell_id)} if cell_id else {}

def execute_code(
self,
code: str,
language: str = "python",
timeout: float | None = None,
envs: dict[str, str] | None = None,
cell_id: str | None = None,
) -> CodeExecutionOutcome:
"""Execute code and return a normalized outcome.

The sandbox is started automatically if needed.
The sandbox is started automatically if needed. ``cell_id`` names the
cell on a reactive sandbox (Marimo): the same id again replaces the
cell, and the cells depending on it re-run.
"""
self.start()
execution = self._sandbox.run_code(code, language=language, timeout=timeout, envs=envs)
execution = self._sandbox.run_code(
code, language=language, timeout=timeout, envs=envs, **self._cell_kwargs(cell_id)
)
return CodeExecutionOutcome.from_execution_result(execution)

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

On a reactive sandbox the reply also carries ``marimo``: the cell this
ran as and the reactions, each with its own outputs.
"""
del silent, kwargs
self.start()
execution = self._sandbox.run_code(code, timeout=timeout)
execution = self._sandbox.run_code(code, timeout=timeout, **self._cell_kwargs(cell_id))
return execution_result_to_reply(execution)

def execute_interactive(
Expand All @@ -457,21 +498,75 @@ def execute_interactive(
"""
reply = self.execute(code, silent=silent, timeout=timeout, **kwargs)
if output_hook is not None:
for output in reply["outputs"]:
msg_type = output.get("output_type", "display_data")
output_hook(
{
marimo = reply.get("marimo") or {}
# The cell's own outputs, then each reaction's: every message
# says which cell it belongs to, the way a kernel's IOPub
# messages carry their parent header.
batches = [(marimo.get("cell_id"), False, reply["outputs"])] + [
(reaction["cell_id"], True, reaction["outputs"])
for reaction in marimo.get("reactions", [])
]
for cell_id, is_reaction, outputs in batches:
for output in outputs:
msg_type = output.get("output_type", "display_data")
message: dict[str, Any] = {
"header": {"msg_type": msg_type},
"msg_type": msg_type,
"content": output,
}
)
if cell_id is not None:
message["metadata"] = {
"marimo": {"cell_id": cell_id, "reaction": is_reaction}
}
output_hook(message)
reply["content"] = {
"status": reply.get("status", "ok"),
"execution_count": reply.get("execution_count"),
}
return reply

# -- reactivity (the Marimo variant) ------------------------------------

@property
def reactive(self) -> bool:
"""Whether the sandbox keeps a cell graph and re-runs dependents."""
return hasattr(self._sandbox, "run_cell")

def _reactive_sandbox(self) -> Any:
if not self.reactive:
raise TypeError(
f"The {self.variant or 'current'} sandbox is not reactive; "
"cells and reactions are the marimo variant's."
)
self.start()
return self._sandbox

def run_cell(
self, cell_id: str, code: str, *, react: bool = True, timeout: float | None = None
) -> Any:
"""Run one named cell, then the cells that depend on it (`MarimoRun`)."""
return self._reactive_sandbox().run_cell(cell_id, code, react=react, timeout=timeout)

def register_cell(self, cell_id: str, code: str) -> dict[str, Any]:
"""Put a cell in the graph without running it; answers its names."""
return self._reactive_sandbox().register_cell(cell_id, code)

def remove_cell(self, cell_id: str) -> None:
self._reactive_sandbox().remove_cell(cell_id)

def plan(self, cell_id: str) -> list[str]:
"""The cells that re-run after `cell_id`, in dependency order."""
return self._reactive_sandbox().plan(cell_id)

def graph(self) -> dict[str, Any]:
"""Every cell's names and neighbours, the conflicts and the cycles."""
return self._reactive_sandbox().graph()

@property
def cells(self) -> dict[str, str]:
"""The registered cells' source, by id."""
return dict(self._reactive_sandbox().cells)

def get_variable(self, name: str) -> Any:
"""Read a variable through the wrapped sandbox."""
self.start()
Expand Down Expand Up @@ -546,18 +641,17 @@ def execute_code_streaming(
language: str = "python",
timeout: float | None = None,
envs: dict[str, str] | None = None,
cell_id: str | None = None,
) -> Iterator[StreamingItem]:
"""Execute code and stream output events.

This is a thin variant-agnostic wrapper over
``Sandbox.run_code_streaming``.
``Sandbox.run_code_streaming``. On a reactive sandbox the reactions'
events follow the cell's own, each tagged with ``marimo_cell_id``.
"""
self.start()
yield from self._sandbox.run_code_streaming(
code,
language=language,
timeout=timeout,
envs=envs,
code, language=language, timeout=timeout, envs=envs, **self._cell_kwargs(cell_id)
)

async def execute_code_streaming_async(
Expand Down
Loading
Loading