Metadata-Version: 2.3
Name: asta-sandbox
Version: 0.1.1
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]"
```

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
from asta_sandbox.backends.modal_kernel import ModalKernelExecutor

executor = ModalKernelExecutor(app_name="my-analysis")

result = await executor.run_code("""
%pip install pandas
import pandas as pd
""")
assert not result.error

result = await executor.run_code("""
df = pd.DataFrame({'x': [1, 2, 3], 'y': [4, 5, 6]})
print(df.describe())
""")
print(result.stdout)
print("success:", result.success)
```

### With cloud-mounted buckets

Pass `CloudShare` 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 `CloudShare` per bucket with distinct `dest` values.

```python
import modal
from asta_sandbox import CloudShare
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`.
share = CloudShare(
    bucket="my-data-bucket",
    key_prefix="datasets/project/",
    dest="/data/project/",
    read_only=True,
    modal_secret=modal.Secret.from_name("aws-credentials"),
)

executor = ModalKernelExecutor(app_name="my-analysis")
await executor.add_shares(share)
async with 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)
```

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

### Installing packages at runtime

Use a `%pip install` magic inside `run_code()`. Because the stateful backends use a
persistent kernel, the installed package stays available for subsequent calls in the
same session:

```python
async with ModalKernelExecutor(app_name="my-analysis") as executor:
    await executor.run_code("%pip install 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
from asta_sandbox.backends.modal_ephemeral import ModalEphemeralExecutor

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"
```

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)
```

Cloud bucket mounts (S3/GCS) are also supported for ephemeral sandboxes via
`add_shares()`. See the [Cloud mounts](#with-cloud-mounted-buckets) subsection
under the stateful sandbox for the `CloudShare` API; it works identically here.

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
from asta_sandbox import InProcessExecutor

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

---

## 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
from asta_sandbox.backends.docker import DockerExecutor

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

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).

### Mounting and uploading files

Use `add_shares()` to make local paths available inside the container:

```python
from pathlib import Path
from asta_sandbox import CopyShare
from asta_sandbox.backends.docker import DockerExecutor

async with DockerExecutor() as executor:
    await executor.add_shares(CopyShare(source=Path("results.csv"), dest="/workspace/results.csv"))
    result = await executor.run_code("import os; print(os.listdir('/workspace'))")
```

`MountShare` (bind-mount) must be added before the first `run_code()` call, since
Docker can't bind-mount into a running container:

```python
from asta_sandbox import MountShare

async with DockerExecutor() as executor:
    await executor.add_shares(MountShare(source=Path("my_data"), dest="/workspace/data"))
    result = await executor.run_code("import os; print(os.listdir('/workspace/data'))")
```

`ModalKernelExecutor` supports the same `MountShare` / `CopyShare` / `CloudShare`
API. `InProcessExecutor` does not support file shares. See
[docs/components.md](docs/components.md) for the full per-type × backend matrix.

---

## Per-call timeout

Pass `timeout_seconds` to limit how long a single `run_code()` call may run:

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

`InProcessExecutor` enforces a soft limit (asyncio cancels the wait; the underlying thread may still run). Remote backends (Docker, Modal) kill the sandbox/process, giving a hard deadline.

---

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. You need a PyPI API token (Account settings → API tokens).

```bash
cd packages/sandbox

# 1. Bump version in pyproject.toml (PEP 440: 0.1.0, 0.1.0.dev1, 0.2.0a1, ...)
# 2. Build and publish:

rm -rf dist/
uv build
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`.
