Metadata-Version: 2.3
Name: asta-sandbox
Version: 0.1.0.dev2
Summary: Shared code execution sandbox abstractions for Asta projects
Author: Allen Institute for Artificial Intelligence
Requires-Dist: ipython>=9.8.0
Requires-Dist: docker>=7.0 ; extra == 'all'
Requires-Dist: modal>=0.73 ; extra == 'all'
Requires-Dist: requests ; extra == 'all'
Requires-Dist: jupyter-server>=2.0 ; extra == 'all'
Requires-Dist: jupyter-kernel-gateway ; extra == 'all'
Requires-Dist: matplotlib ; extra == 'all'
Requires-Dist: matplotlib-inline ; extra == 'all'
Requires-Dist: docker>=7.0 ; extra == 'docker'
Requires-Dist: requests ; extra == 'docker'
Requires-Dist: jupyter-server>=2.0 ; extra == 'docker'
Requires-Dist: jupyter-kernel-gateway ; extra == 'docker'
Requires-Dist: matplotlib ; extra == 'inprocess'
Requires-Dist: matplotlib-inline ; extra == 'inprocess'
Requires-Dist: modal>=0.73 ; extra == 'modal'
Requires-Dist: requests ; extra == 'modal'
Requires-Dist: jupyter-server>=2.0 ; extra == 'modal'
Requires-Dist: jupyter-kernel-gateway ; extra == 'modal'
Requires-Dist: modal>=0.73 ; extra == 'modal-ephemeral'
Requires-Dist: modal>=0.73 ; extra == 'modal-kernel'
Requires-Dist: requests ; extra == 'modal-kernel'
Requires-Dist: jupyter-server>=2.0 ; extra == 'modal-kernel'
Requires-Dist: jupyter-kernel-gateway ; extra == 'modal-kernel'
Requires-Python: >=3.12
Provides-Extra: all
Provides-Extra: docker
Provides-Extra: inprocess
Provides-Extra: modal
Provides-Extra: modal-ephemeral
Provides-Extra: modal-kernel
Description-Content-Type: text/markdown

# asta-sandbox

Shared code-execution sandbox abstractions for Asta projects. Provides a
uniform async interface over four backends — in-process IPython, Docker, and
two Modal modes (stateful kernel, stateless ephemeral) — so application code
doesn't depend on a specific execution backend.

## Installation

```bash
# Base package (in-process backend only, requires ipython)
pip install asta-sandbox

# Modal stateful backend (persistent sandbox + Jupyter Kernel Gateway)
pip install "asta-sandbox[modal-kernel]"

# Modal stateless backend (fresh sandbox per call)
pip install "asta-sandbox[modal-ephemeral]"

# Docker backend
pip install "asta-sandbox[docker]"

# Everything
pip install "asta-sandbox[all]"
```

### Declaring as a dependency in a uv workspace

Once published to PyPI, install normally:

```bash
pip install "asta-sandbox[modal-kernel]"   # or [docker], [modal-ephemeral], [all]
```

Until then, or to pin a specific branch, declare it as a git source in a `uv`-managed workspace:

```toml
# pyproject.toml (workspace root)
[tool.uv.sources]
asta-sandbox = { git = "https://github.com/allenai/dv-core-asta-integration", subdirectory = "packages/sandbox", rev = "main" }
```

For local development against an unpublished branch, swap to a path source
(and keep the git source commented out for CI):

```toml
[tool.uv.sources]
# Local dev: uncomment below and comment out the git source
# asta-sandbox = { path = "../dv-core-asta-integration/packages/sandbox", editable = true }
asta-sandbox = { git = "https://github.com/allenai/dv-core-asta-integration", subdirectory = "packages/sandbox", rev = "main" }
```

Then add `"asta-sandbox"` to the `dependencies` list of any workspace member
that needs it, and run `uv sync --all-packages` to install.

## Quick start — Modal stateful sandbox

`ModalKernelExecutor` runs a persistent Jupyter Kernel Gateway inside a Modal
sandbox. Variable and import state accumulates across `run_code()` calls,
mirroring interactive notebook behaviour.

```python
import asyncio
from asta_sandbox.backends.modal_kernel import ModalKernelExecutor

async def main():
    async with ModalKernelExecutor(app_name="my-analysis") as executor:
        # Imports accumulate
        await executor.run_code("import pandas as pd")

        # Each subsequent call sees prior state
        await executor.run_code("df = pd.DataFrame({'x': [1, 2, 3], 'y': [4, 5, 6]})")
        result = await executor.run_code("print(df.describe())")

        print(result.stdout)
        print("success:", result.success)

asyncio.run(main())
```

### With cloud-mounted buckets

Pass `CloudMount` objects to make S3 or GCS bucket prefixes available as local
paths inside the sandbox, without downloading to the host machine. Multiple
buckets (including a mix of S3 and GCS) can be mounted simultaneously by
providing one `CloudMount` per bucket with distinct `mount_path` values.

```python
import asyncio
import modal
from asta_sandbox import CloudMount
from asta_sandbox.backends.modal_kernel import ModalKernelExecutor

# modal_secret carries the bucket credentials (AWS or GCS keys) — separate
# from Modal's own authentication, which is configured via `modal token new`.
mount = CloudMount(
    bucket="my-data-bucket",
    key_prefix="datasets/project/",
    mount_path="/data/project/",
    read_only=True,
    modal_secret=modal.Secret.from_name("aws-credentials"),
)

async def main():
    async with ModalKernelExecutor(app_name="my-analysis", cloud_mounts=[mount]) as executor:
        # The bucket prefix is now visible at /data/project/ inside the sandbox
        result = await executor.run_code("import os; print(os.listdir('/data/project/'))")
        print(result.stdout)

asyncio.run(main())
```

For GCS, add `bucket_endpoint_url="https://storage.googleapis.com"` to the `CloudMount`.

### Installing packages at runtime

```python
async with ModalKernelExecutor(app_name="my-analysis") as executor:
    await executor.install_packages(("scanpy", "anndata"))
    result = await executor.run_code("import scanpy; print(scanpy.__version__)")
```

### Custom image

Use `build_modal_kernel_image` to bake packages into the image instead of
installing them at runtime (faster cold starts, reproducible environment):

```python
from asta_sandbox.images import build_modal_kernel_image
from asta_sandbox.backends.modal_kernel import ModalKernelExecutor

image = build_modal_kernel_image(extra_packages=["scanpy", "anndata", "matplotlib"])

async with ModalKernelExecutor(app_name="my-analysis", image=image) as executor:
    result = await executor.run_code("import scanpy; print(scanpy.__version__)")
```

---

## Quick start — Modal stateless (ephemeral) sandbox

`ModalEphemeralExecutor` creates a fresh Modal sandbox for every `run_code()`
call. No state survives between calls. The executor instance itself is
long-lived; construct it once and call it many times.

```python
import asyncio
from asta_sandbox.backends.modal_ephemeral import ModalEphemeralExecutor

async def main():
    async with ModalEphemeralExecutor(app_name="my-runner") as executor:
        r1 = await executor.run_code("x = 42; print(x)")
        r2 = await executor.run_code("print(x)")   # NameError — x not in this sandbox

        print(r1.stdout)        # "42"
        print(r2.success)       # False
        print(r2.error.etype)   # "NameError"

asyncio.run(main())
```

For a known fixed set of packages, bake them into the image (faster, reproducible):

```python
from asta_sandbox.images import build_modal_ephemeral_image

image = build_modal_ephemeral_image(extra_packages=["httpx"])
executor = ModalEphemeralExecutor(app_name="my-runner", image=image)
```

For on-demand installs (e.g. a coding agent that discovers mid-task it needs a
package), embed a `%pip install` magic at the top of the same code string that
uses the package. Because each `run_code()` call runs in a fresh container, the
install and the import must be in the same call:

```python
async with ModalEphemeralExecutor(app_name="my-runner") as executor:
    result = await executor.run_code("""\
%pip install httpx
import httpx
print(httpx.__version__)
""")
    print(result.stdout)
```

---

## Quick start — in-process (local / testing)

```python
import asyncio
from asta_sandbox import InProcessExecutor

async def main():
    async with InProcessExecutor() as executor:
        await executor.run_code("x = 1 + 1")
        result = await executor.run_code("print(x)")
        print(result.stdout)   # "2"

asyncio.run(main())
```

---

## Per-call options

`RunOptions` overrides behaviour for a single `run_code()` call:

```python
from asta_sandbox import RunOptions

result = await executor.run_code(
    "import time; time.sleep(10)",
    RunOptions(timeout_seconds=5, title="long cell"),
)
print(result.success)    # False
print(result.error.etype)  # "TimeoutError"
```

| Field | Default | Notes |
|---|---|---|
| `timeout_seconds` | `None` | Per-call limit. For `InProcessExecutor` without `use_subprocess`, enforced as a soft limit (asyncio cancels the wait; the thread may still run). For remote backends, enforced by the sandbox/process kill. |
| `use_subprocess` | `False` | Spawn a fresh child process for the call (`InProcessExecutor` and `ModalEphemeralExecutor`). Provides hard timeout enforcement and stronger isolation for those backends. |
| `allow_mime` | `None` | Override MIME allowlist for rich outputs. |
| `title` | `"cell"` | Label carried through to `result.metadata["title"]`. |

---

## Quick start — Docker (local stateful sandbox)

`DockerExecutor` runs a Jupyter Kernel Gateway inside a Docker container.
Stateful (like `ModalKernelExecutor`): variable state persists across calls.
Useful for local development and CI without a Modal account.

```python
import asyncio
from asta_sandbox.backends.docker import DockerExecutor

async def main():
    async with DockerExecutor() as executor:
        await executor.run_code("x = 42")
        result = await executor.run_code("print(x)")
        print(result.stdout)   # "42"

asyncio.run(main())
```

The default image is built from `dockerfiles/kernel_gateway/Dockerfile`. If
the image is not present locally, `DockerExecutor` will build it automatically
on first use (requires `docker` running).

---

For a full description of all types, backend capabilities, and component
relationships, see [docs/components.md](docs/components.md).

---

## Publishing to PyPI

Publishing is manual for now. You need a PyPI account with Trusted Publisher
configured for this repo, or a PyPI API token.

**First-time setup (once per PyPI account):**

1. Create the package on PyPI via the Trusted Publishers UI (pypi.org → Your
   projects → Publishing), or generate an API token under Account settings.
2. If using Trusted Publishers: register `allenai/dv-core-asta-integration`
   as the trusted publisher for `asta-sandbox`.

**Publishing a release:**

```bash
cd packages/sandbox

# Bump version in pyproject.toml (use PEP 440: 0.1.0, 0.1.0.dev1, 0.2.0a1, ...)
# Then:

uv build                                      # produces dist/
uv publish                                    # uses OIDC if available
# or: uv publish --token pypi-YOUR_TOKEN_HERE
```

Dev/pre-release versions (`0.1.0.dev1`, `0.1.0a1`, etc.) are not installed by
`pip install asta-sandbox` by default — users must pin the exact version or pass
`--pre`. Prefer a dev version for early testing and a proper `0.x.0` tag for
anything shared more broadly.
