939 lines
31 KiB
Python
939 lines
31 KiB
Python
"""Main SandboxClient class for interacting with the sandbox server API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from langsmith import utils as ls_utils
|
|
from langsmith.sandbox._exceptions import (
|
|
ResourceCreationError,
|
|
ResourceInUseError,
|
|
ResourceNameConflictError,
|
|
ResourceNotFoundError,
|
|
ResourceTimeoutError,
|
|
SandboxAPIError,
|
|
ValidationError,
|
|
)
|
|
from langsmith.sandbox._helpers import (
|
|
handle_client_http_error,
|
|
handle_pool_error,
|
|
handle_sandbox_creation_error,
|
|
handle_volume_creation_error,
|
|
parse_error_response,
|
|
)
|
|
from langsmith.sandbox._models import (
|
|
Pool,
|
|
ResourceStatus,
|
|
SandboxTemplate,
|
|
Volume,
|
|
VolumeMountSpec,
|
|
)
|
|
from langsmith.sandbox._sandbox import Sandbox
|
|
from langsmith.sandbox._transport import RetryTransport
|
|
|
|
|
|
def _get_default_api_endpoint() -> str:
|
|
"""Get the default sandbox API endpoint from environment.
|
|
|
|
Derives the endpoint from LANGSMITH_ENDPOINT (or LANGCHAIN_ENDPOINT).
|
|
"""
|
|
base = ls_utils.get_env_var("ENDPOINT", default="https://api.smith.langchain.com")
|
|
return f"{base.rstrip('/')}/v2/sandboxes"
|
|
|
|
|
|
def _get_default_api_key() -> Optional[str]:
|
|
"""Get the default API key from environment."""
|
|
return ls_utils.get_env_var("API_KEY")
|
|
|
|
|
|
class SandboxClient:
|
|
"""Client for interacting with the Sandbox Server API.
|
|
|
|
This client provides a simple interface for managing sandboxes and templates.
|
|
|
|
Example:
|
|
# Uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY from environment
|
|
client = SandboxClient()
|
|
|
|
# Or with explicit configuration
|
|
client = SandboxClient(
|
|
api_endpoint="https://api.smith.langchain.com/v2/sandboxes",
|
|
api_key="your-api-key",
|
|
)
|
|
|
|
# Create a sandbox and run commands
|
|
with client.sandbox(template_name="python-sandbox") as sandbox:
|
|
result = sandbox.run("python --version")
|
|
print(result.stdout)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
api_endpoint: Optional[str] = None,
|
|
timeout: float = 10.0,
|
|
api_key: Optional[str] = None,
|
|
max_retries: int = 3,
|
|
):
|
|
"""Initialize the SandboxClient.
|
|
|
|
Args:
|
|
api_endpoint: Full URL of the sandbox API endpoint. If not provided,
|
|
derived from LANGSMITH_ENDPOINT environment variable.
|
|
timeout: Default HTTP timeout in seconds.
|
|
api_key: API key for authentication. If not provided, uses
|
|
LANGSMITH_API_KEY environment variable.
|
|
max_retries: Maximum number of retries for transient errors (502, 503,
|
|
504), rate limits (429), and connection failures. Set to 0
|
|
to disable retries. Default: 3.
|
|
"""
|
|
self._base_url = (api_endpoint or _get_default_api_endpoint()).rstrip("/")
|
|
resolved_api_key = api_key or _get_default_api_key()
|
|
self._api_key = resolved_api_key
|
|
headers: dict[str, str] = {}
|
|
if resolved_api_key:
|
|
headers["X-Api-Key"] = resolved_api_key
|
|
transport = RetryTransport(max_retries=max_retries)
|
|
self._http = httpx.Client(transport=transport, timeout=timeout, headers=headers)
|
|
|
|
def close(self) -> None:
|
|
"""Close the HTTP client."""
|
|
self._http.close()
|
|
|
|
def __del__(self) -> None:
|
|
"""Close the HTTP client on garbage collection."""
|
|
try:
|
|
if not self._http.is_closed:
|
|
self._http.close()
|
|
except Exception:
|
|
pass
|
|
|
|
def __enter__(self) -> SandboxClient:
|
|
"""Enter context manager."""
|
|
return self
|
|
|
|
def __exit__(
|
|
self,
|
|
exc_type: Optional[type],
|
|
exc_val: Optional[BaseException],
|
|
exc_tb: Optional[Any],
|
|
) -> None:
|
|
"""Exit context manager."""
|
|
self.close()
|
|
|
|
# ========================================================================
|
|
# Volume Operations
|
|
# ========================================================================
|
|
|
|
def create_volume(
|
|
self,
|
|
name: str,
|
|
size: str,
|
|
*,
|
|
timeout: int = 60,
|
|
) -> Volume:
|
|
"""Create a new persistent volume.
|
|
|
|
Creates a persistent storage volume that can be referenced in templates.
|
|
|
|
Args:
|
|
name: Volume name.
|
|
size: Storage size (e.g., "1Gi", "10Gi").
|
|
timeout: Timeout in seconds when waiting for ready (min: 5, max: 300).
|
|
|
|
Returns:
|
|
Created Volume.
|
|
|
|
Raises:
|
|
VolumeProvisioningError: If volume provisioning fails.
|
|
ResourceTimeoutError: If volume doesn't become ready within timeout.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/volumes"
|
|
|
|
payload = {
|
|
"name": name,
|
|
"size": size,
|
|
"wait_for_ready": True,
|
|
"timeout": timeout,
|
|
}
|
|
|
|
try:
|
|
response = self._http.post(url, json=payload, timeout=timeout + 30)
|
|
response.raise_for_status()
|
|
return Volume.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
handle_volume_creation_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def get_volume(self, name: str) -> Volume:
|
|
"""Get a volume by name.
|
|
|
|
Args:
|
|
name: Volume name.
|
|
|
|
Returns:
|
|
Volume.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If volume not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/volumes/{name}"
|
|
|
|
try:
|
|
response = self._http.get(url)
|
|
response.raise_for_status()
|
|
return Volume.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Volume '{name}' not found", resource_type="volume"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def list_volumes(self) -> list[Volume]:
|
|
"""List all volumes.
|
|
|
|
Returns:
|
|
List of Volumes.
|
|
"""
|
|
url = f"{self._base_url}/volumes"
|
|
|
|
try:
|
|
response = self._http.get(url)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
return [Volume.from_dict(v) for v in data.get("volumes", [])]
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise SandboxAPIError(
|
|
f"API endpoint not found: {url}. "
|
|
f"Check that api_endpoint is correct."
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def delete_volume(self, name: str) -> None:
|
|
"""Delete a volume.
|
|
|
|
Args:
|
|
name: Volume name.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If volume not found.
|
|
ResourceInUseError: If volume is referenced by templates.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/volumes/{name}"
|
|
|
|
try:
|
|
response = self._http.delete(url)
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Volume '{name}' not found", resource_type="volume"
|
|
) from e
|
|
if e.response.status_code == 409:
|
|
data = parse_error_response(e)
|
|
raise ResourceInUseError(data["message"], resource_type="volume") from e
|
|
handle_client_http_error(e)
|
|
|
|
def update_volume(
|
|
self,
|
|
name: str,
|
|
*,
|
|
new_name: Optional[str] = None,
|
|
size: Optional[str] = None,
|
|
) -> Volume:
|
|
"""Update a volume's name and/or size.
|
|
|
|
You can update the display name, size, or both in a single request.
|
|
Only storage size increases are allowed (storage backend limitation).
|
|
|
|
Args:
|
|
name: Current volume name.
|
|
new_name: New display name (optional).
|
|
size: New storage size (must be >= current size). Optional.
|
|
|
|
Returns:
|
|
Updated Volume.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If volume not found.
|
|
VolumeResizeError: If storage decrease attempted.
|
|
ResourceNameConflictError: If new_name is already in use.
|
|
SandboxQuotaExceededError: If storage quota would be exceeded.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/volumes/{name}"
|
|
payload: dict[str, Any] = {}
|
|
if new_name is not None:
|
|
payload["name"] = new_name
|
|
if size is not None:
|
|
payload["size"] = size
|
|
|
|
if not payload:
|
|
# Nothing to update, just return the current volume
|
|
return self.get_volume(name)
|
|
|
|
try:
|
|
response = self._http.patch(url, json=payload)
|
|
response.raise_for_status()
|
|
return Volume.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Volume '{name}' not found", resource_type="volume"
|
|
) from e
|
|
if e.response.status_code == 400:
|
|
data = parse_error_response(e)
|
|
raise ValidationError(data["message"], error_type="VolumeResize") from e
|
|
if e.response.status_code == 409:
|
|
data = parse_error_response(e)
|
|
raise ResourceNameConflictError(
|
|
data["message"], resource_type="volume"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
# ========================================================================
|
|
# Template Operations
|
|
# ========================================================================
|
|
|
|
def create_template(
|
|
self,
|
|
name: str,
|
|
image: str,
|
|
*,
|
|
cpu: str = "500m",
|
|
memory: str = "512Mi",
|
|
storage: Optional[str] = None,
|
|
volume_mounts: Optional[list[VolumeMountSpec]] = None,
|
|
) -> SandboxTemplate:
|
|
"""Create a new SandboxTemplate.
|
|
|
|
Only the container image, resource limits, and volume mounts can be
|
|
configured. All other container details are handled by the server.
|
|
|
|
Args:
|
|
name: Template name.
|
|
image: Container image (e.g., "python:3.12-slim").
|
|
cpu: CPU limit (e.g., "500m", "1", "2"). Default: "500m".
|
|
memory: Memory limit (e.g., "256Mi", "1Gi"). Default: "512Mi".
|
|
storage: Ephemeral storage limit (e.g., "1Gi"). Optional.
|
|
volume_mounts: List of volumes to mount in the sandbox. Optional.
|
|
|
|
Returns:
|
|
Created SandboxTemplate.
|
|
|
|
Raises:
|
|
SandboxClientError: If creation fails.
|
|
"""
|
|
url = f"{self._base_url}/templates"
|
|
|
|
payload: dict[str, Any] = {
|
|
"name": name,
|
|
"image": image,
|
|
"resources": {
|
|
"cpu": cpu,
|
|
"memory": memory,
|
|
},
|
|
}
|
|
if storage:
|
|
payload["resources"]["storage"] = storage
|
|
if volume_mounts:
|
|
payload["volume_mounts"] = [
|
|
{"volume_name": vm.volume_name, "mount_path": vm.mount_path}
|
|
for vm in volume_mounts
|
|
]
|
|
|
|
try:
|
|
response = self._http.post(url, json=payload)
|
|
response.raise_for_status()
|
|
return SandboxTemplate.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def get_template(self, name: str) -> SandboxTemplate:
|
|
"""Get a SandboxTemplate by name.
|
|
|
|
Args:
|
|
name: Template name.
|
|
|
|
Returns:
|
|
SandboxTemplate.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If template not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/templates/{name}"
|
|
|
|
try:
|
|
response = self._http.get(url)
|
|
response.raise_for_status()
|
|
return SandboxTemplate.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Template '{name}' not found", resource_type="template"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def list_templates(self) -> list[SandboxTemplate]:
|
|
"""List all SandboxTemplates.
|
|
|
|
Returns:
|
|
List of SandboxTemplates.
|
|
"""
|
|
url = f"{self._base_url}/templates"
|
|
|
|
try:
|
|
response = self._http.get(url)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
return [SandboxTemplate.from_dict(t) for t in data.get("templates", [])]
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise SandboxAPIError(
|
|
f"API endpoint not found: {url}. "
|
|
f"Check that api_endpoint is correct."
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def update_template(self, name: str, *, new_name: str) -> SandboxTemplate:
|
|
"""Update a template's display name.
|
|
|
|
Args:
|
|
name: Current template name.
|
|
new_name: New display name.
|
|
|
|
Returns:
|
|
Updated SandboxTemplate.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If template not found.
|
|
ResourceNameConflictError: If new_name is already in use.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/templates/{name}"
|
|
payload = {"name": new_name}
|
|
|
|
try:
|
|
response = self._http.patch(url, json=payload)
|
|
response.raise_for_status()
|
|
return SandboxTemplate.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Template '{name}' not found", resource_type="template"
|
|
) from e
|
|
if e.response.status_code == 409:
|
|
data = parse_error_response(e)
|
|
raise ResourceNameConflictError(
|
|
data["message"], resource_type="template"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def delete_template(self, name: str) -> None:
|
|
"""Delete a SandboxTemplate.
|
|
|
|
Args:
|
|
name: Template name.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If template not found.
|
|
ResourceInUseError: If template is referenced by sandboxes or pools.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/templates/{name}"
|
|
|
|
try:
|
|
response = self._http.delete(url)
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Template '{name}' not found", resource_type="template"
|
|
) from e
|
|
if e.response.status_code == 409:
|
|
data = parse_error_response(e)
|
|
raise ResourceInUseError(
|
|
data["message"], resource_type="template"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
|
|
# ========================================================================
|
|
# Pool Operations
|
|
# ========================================================================
|
|
|
|
def create_pool(
|
|
self,
|
|
name: str,
|
|
template_name: str,
|
|
replicas: int,
|
|
*,
|
|
timeout: int = 30,
|
|
) -> Pool:
|
|
"""Create a new Sandbox Pool.
|
|
|
|
Pools pre-provision sandboxes from a template for faster startup.
|
|
|
|
Args:
|
|
name: Pool name (lowercase letters, numbers, hyphens; max 63 chars).
|
|
template_name: Name of the SandboxTemplate to use (no volume mounts).
|
|
replicas: Number of sandboxes to pre-provision (1-100).
|
|
timeout: Timeout in seconds when waiting for ready (10-600).
|
|
|
|
Returns:
|
|
Created Pool.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If template not found.
|
|
ValidationError: If template has volumes attached.
|
|
ResourceAlreadyExistsError: If pool with this name already exists.
|
|
ResourceTimeoutError: If pool doesn't reach ready state within timeout.
|
|
SandboxQuotaExceededError: If organization quota is exceeded.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/pools"
|
|
|
|
payload: dict[str, Any] = {
|
|
"name": name,
|
|
"template_name": template_name,
|
|
"replicas": replicas,
|
|
"wait_for_ready": True,
|
|
"timeout": timeout,
|
|
}
|
|
|
|
try:
|
|
http_timeout = timeout + 30
|
|
response = self._http.post(url, json=payload, timeout=http_timeout)
|
|
response.raise_for_status()
|
|
return Pool.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
handle_pool_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def get_pool(self, name: str) -> Pool:
|
|
"""Get a Pool by name.
|
|
|
|
Args:
|
|
name: Pool name.
|
|
|
|
Returns:
|
|
Pool.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If pool not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/pools/{name}"
|
|
|
|
try:
|
|
response = self._http.get(url)
|
|
response.raise_for_status()
|
|
return Pool.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Pool '{name}' not found", resource_type="pool"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def list_pools(self) -> list[Pool]:
|
|
"""List all Pools.
|
|
|
|
Returns:
|
|
List of Pools.
|
|
"""
|
|
url = f"{self._base_url}/pools"
|
|
|
|
try:
|
|
response = self._http.get(url)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
return [Pool.from_dict(p) for p in data.get("pools", [])]
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise SandboxAPIError(
|
|
f"API endpoint not found: {url}. "
|
|
f"Check that api_endpoint is correct."
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def update_pool(
|
|
self,
|
|
name: str,
|
|
*,
|
|
new_name: Optional[str] = None,
|
|
replicas: Optional[int] = None,
|
|
) -> Pool:
|
|
"""Update a Pool's name and/or replica count.
|
|
|
|
You can update the display name, replica count, or both.
|
|
The template reference cannot be changed after creation.
|
|
|
|
Args:
|
|
name: Current pool name.
|
|
new_name: New display name (optional).
|
|
replicas: New number of replicas (0-100). Set to 0 to pause.
|
|
|
|
Returns:
|
|
Updated Pool.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If pool not found.
|
|
ValidationError: If template was deleted.
|
|
ResourceNameConflictError: If new_name is already in use.
|
|
SandboxQuotaExceededError: If quota exceeded when scaling up.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/pools/{name}"
|
|
|
|
payload: dict[str, Any] = {}
|
|
if new_name is not None:
|
|
payload["name"] = new_name
|
|
if replicas is not None:
|
|
payload["replicas"] = replicas
|
|
|
|
if not payload:
|
|
# Nothing to update, just return the current pool
|
|
return self.get_pool(name)
|
|
|
|
try:
|
|
response = self._http.patch(url, json=payload)
|
|
response.raise_for_status()
|
|
return Pool.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Pool '{name}' not found", resource_type="pool"
|
|
) from e
|
|
if e.response.status_code == 409:
|
|
data = parse_error_response(e)
|
|
raise ResourceNameConflictError(
|
|
data["message"], resource_type="pool"
|
|
) from e
|
|
handle_pool_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def delete_pool(self, name: str) -> None:
|
|
"""Delete a Pool.
|
|
|
|
This will terminate all sandboxes in the pool.
|
|
|
|
Args:
|
|
name: Pool name.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If pool not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/pools/{name}"
|
|
|
|
try:
|
|
response = self._http.delete(url)
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Pool '{name}' not found", resource_type="pool"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
|
|
# ========================================================================
|
|
# Sandbox Operations
|
|
# ========================================================================
|
|
|
|
def sandbox(
|
|
self,
|
|
template_name: str,
|
|
*,
|
|
name: Optional[str] = None,
|
|
timeout: int = 30,
|
|
) -> Sandbox:
|
|
"""Create a sandbox and return a Sandbox instance.
|
|
|
|
This is the primary method for creating sandboxes. Use it as a
|
|
context manager for automatic cleanup:
|
|
|
|
with client.sandbox(template_name="my-template") as sandbox:
|
|
result = sandbox.run("echo hello")
|
|
|
|
The sandbox is automatically deleted when exiting the context manager.
|
|
For sandboxes with manual lifecycle management, use create_sandbox().
|
|
|
|
Args:
|
|
template_name: Name of the SandboxTemplate to use.
|
|
name: Optional sandbox name (auto-generated if not provided).
|
|
timeout: Timeout in seconds when waiting for ready.
|
|
|
|
Returns:
|
|
Sandbox instance.
|
|
|
|
Raises:
|
|
ResourceTimeoutError: If timeout waiting for sandbox to be ready.
|
|
ResourceCreationError: If sandbox creation fails.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
sb = self.create_sandbox(
|
|
template_name=template_name,
|
|
name=name,
|
|
timeout=timeout,
|
|
)
|
|
sb._auto_delete = True
|
|
return sb
|
|
|
|
def create_sandbox(
|
|
self,
|
|
template_name: str,
|
|
*,
|
|
name: Optional[str] = None,
|
|
timeout: int = 30,
|
|
wait_for_ready: bool = True,
|
|
) -> Sandbox:
|
|
"""Create a new Sandbox.
|
|
|
|
The sandbox is NOT automatically deleted. Use delete_sandbox() for cleanup,
|
|
or use sandbox() for automatic cleanup with a context manager.
|
|
|
|
Args:
|
|
template_name: Name of the SandboxTemplate to use.
|
|
name: Optional sandbox name (auto-generated if not provided).
|
|
timeout: Timeout in seconds when waiting for ready (only used when
|
|
wait_for_ready=True).
|
|
wait_for_ready: If True (default), block until sandbox is ready.
|
|
If False, return immediately with status "provisioning". Use
|
|
get_sandbox_status() or wait_for_sandbox() to poll for readiness.
|
|
|
|
Returns:
|
|
Created Sandbox. When wait_for_ready=False, the sandbox will have
|
|
status="provisioning" and cannot be used for operations until ready.
|
|
|
|
Raises:
|
|
ResourceTimeoutError: If timeout waiting for sandbox to be ready.
|
|
ResourceCreationError: If sandbox creation fails.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/boxes"
|
|
|
|
payload: dict[str, Any] = {
|
|
"template_name": template_name,
|
|
"wait_for_ready": wait_for_ready,
|
|
}
|
|
if wait_for_ready:
|
|
payload["timeout"] = timeout
|
|
if name:
|
|
payload["name"] = name
|
|
|
|
http_timeout = (timeout + 30) if wait_for_ready else 30
|
|
|
|
try:
|
|
response = self._http.post(url, json=payload, timeout=http_timeout)
|
|
response.raise_for_status()
|
|
return Sandbox.from_dict(response.json(), client=self, auto_delete=False)
|
|
except httpx.HTTPStatusError as e:
|
|
handle_sandbox_creation_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def get_sandbox(self, name: str) -> Sandbox:
|
|
"""Get a Sandbox by name.
|
|
|
|
The sandbox is NOT automatically deleted. Use delete_sandbox() for cleanup.
|
|
|
|
Args:
|
|
name: Sandbox name.
|
|
|
|
Returns:
|
|
Sandbox.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If sandbox not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/boxes/{name}"
|
|
|
|
try:
|
|
response = self._http.get(url)
|
|
response.raise_for_status()
|
|
return Sandbox.from_dict(response.json(), client=self, auto_delete=False)
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Sandbox '{name}' not found", resource_type="sandbox"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def list_sandboxes(self) -> list[Sandbox]:
|
|
"""List all Sandboxes.
|
|
|
|
Returns:
|
|
List of Sandboxes.
|
|
"""
|
|
url = f"{self._base_url}/boxes"
|
|
|
|
try:
|
|
response = self._http.get(url)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
return [
|
|
Sandbox.from_dict(c, client=self, auto_delete=False)
|
|
for c in data.get("sandboxes", [])
|
|
]
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise SandboxAPIError(
|
|
f"API endpoint not found: {url}. "
|
|
f"Check that api_endpoint is correct."
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def update_sandbox(self, name: str, *, new_name: str) -> Sandbox:
|
|
"""Update a sandbox's display name.
|
|
|
|
Args:
|
|
name: Current sandbox name.
|
|
new_name: New display name.
|
|
|
|
Returns:
|
|
Updated Sandbox.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If sandbox not found.
|
|
ResourceNameConflictError: If new_name is already in use.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/boxes/{name}"
|
|
payload = {"name": new_name}
|
|
|
|
try:
|
|
response = self._http.patch(url, json=payload)
|
|
response.raise_for_status()
|
|
return Sandbox.from_dict(response.json(), client=self, auto_delete=False)
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Sandbox '{name}' not found", resource_type="sandbox"
|
|
) from e
|
|
if e.response.status_code == 409:
|
|
raise ResourceNameConflictError(
|
|
f"Sandbox name '{new_name}' already in use",
|
|
resource_type="sandbox",
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def delete_sandbox(self, name: str) -> None:
|
|
"""Delete a Sandbox.
|
|
|
|
Args:
|
|
name: Sandbox name.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If sandbox not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/boxes/{name}"
|
|
|
|
try:
|
|
response = self._http.delete(url)
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Sandbox '{name}' not found", resource_type="sandbox"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
|
|
def get_sandbox_status(self, name: str) -> ResourceStatus:
|
|
"""Get the provisioning status of a sandbox.
|
|
|
|
This is a lightweight endpoint designed for high-frequency polling
|
|
during sandbox provisioning. It returns only the status fields
|
|
without full sandbox data.
|
|
|
|
Args:
|
|
name: Sandbox name.
|
|
|
|
Returns:
|
|
ResourceStatus with status and status_message.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If sandbox not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/boxes/{name}/status"
|
|
|
|
try:
|
|
response = self._http.get(url)
|
|
response.raise_for_status()
|
|
return ResourceStatus.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Sandbox '{name}' not found", resource_type="sandbox"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def wait_for_sandbox(
|
|
self,
|
|
name: str,
|
|
*,
|
|
timeout: int = 120,
|
|
poll_interval: float = 1.0,
|
|
) -> Sandbox:
|
|
"""Poll until a sandbox reaches "ready" or "failed" status.
|
|
|
|
Uses the lightweight status endpoint for polling, then fetches the
|
|
full sandbox data once ready.
|
|
|
|
Args:
|
|
name: Sandbox name.
|
|
timeout: Maximum time to wait in seconds.
|
|
poll_interval: Time between status checks in seconds.
|
|
|
|
Returns:
|
|
Sandbox in "ready" status.
|
|
|
|
Raises:
|
|
ResourceCreationError: If sandbox status becomes "failed".
|
|
ResourceTimeoutError: If timeout expires while still "provisioning".
|
|
ResourceNotFoundError: If sandbox not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
import time
|
|
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
status = self.get_sandbox_status(name)
|
|
if status.status == "ready":
|
|
return self.get_sandbox(name)
|
|
if status.status == "failed":
|
|
raise ResourceCreationError(
|
|
status.status_message or "Sandbox provisioning failed",
|
|
resource_type="sandbox",
|
|
)
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise ResourceTimeoutError(
|
|
f"Sandbox '{name}' not ready after {timeout}s",
|
|
resource_type="sandbox",
|
|
last_status=status.status,
|
|
)
|
|
time.sleep(min(poll_interval, remaining))
|