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
38 changes: 28 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ with Porter() as porter:
sb.terminate()
```

Volumes can be created up front and mounted into sandboxes at launch, and their
contents browsed and read straight from the volume handle:

```python
volume = porter.volumes.create(name="my-data")

sb = porter.sandboxes.create(
image="python:3.11-alpine",
volume_mounts={"/mnt/my-data": volume.id},
)

for file in volume.listdir("/checkpoints"):
print(file.path, file.size_bytes)

config = volume.read_text("/checkpoints/config.json")

with open("model.safetensors", "wb") as out:
for chunk in volume.stream("/checkpoints/model.safetensors"):
out.write(chunk)
```

Inside a sandbox-enabled Porter cluster, the SDK connects to the in-cluster
sandbox API at `http://sandbox-api.porter-sandbox-system.svc.cluster.local:8080`
automatically, with no configuration needed.
Expand Down Expand Up @@ -70,10 +91,13 @@ async with AsyncPorter() as porter:

## Layout

- `porter_sandbox/porter.py`, resource namespace modules like `porter_sandbox/sandboxes.py`, `porter_sandbox/_client.py`, `_models.py`, `enums.py`, `_errors.py`, `resources/` — generated from the [sandbox-api OpenAPI spec](https://github.com/porter-dev/workstation/tree/main/code/sandbox/schemas) via the [sdk-gen workspace](https://github.com/porter-dev/workstation/tree/main/code/sandbox/sdk-gen). Do not edit by hand.
- `porter_sandbox/sandbox.py` — hand-written rich sandbox handle used by the generated `sandboxes` namespace
- `porter_sandbox/_base_client.py` / `_async_base_client.py` — hand-written sync and async HTTP transports
- `porter_sandbox/_config.py`, `_retries.py` — hand-written runtime (env-var resolution, retry/backoff)
Everything under `porter_sandbox/` is generated from the Porter Sandbox OpenAPI
spec. Do not edit it by hand - changes there are overwritten on the next release.

- `porter_sandbox/sandbox.py`, `porter_sandbox/volume.py` - sync and async `Sandbox` and `Volume` handles
- `porter_sandbox/porter.py` and resource namespace modules like `porter_sandbox/sandboxes.py` - public client and namespaces
- `porter_sandbox/_client.py`, `_models.py`, `enums.py`, `_errors.py`, `resources/` - low-level client, models, and errors
- `porter_sandbox/_base_client.py`, `_async_base_client.py`, `_config.py`, `_retries.py` - sync and async HTTP transports, env-var resolution, retry/backoff

## Development

Expand All @@ -83,9 +107,3 @@ pytest
ruff check .
mypy
```

To pull in a fresh generation from the sdk-gen workspace:

```bash
./scripts/sync-generated.sh /path/to/workstation/code/sandbox/sdk-gen/out/python
```
100 changes: 93 additions & 7 deletions porter_sandbox/__init__.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,118 @@
# Generated by oagen. DO NOT EDIT.


from __future__ import annotations

from ._binary import BinaryContent, ContentRange
from ._client import AsyncPorterSandboxApiClient, PorterSandboxApiClient
from ._errors import (
AuthenticationError,
NotFoundError,
RateLimitError,
SandboxError,
SandboxTimeoutError,
ServerError,
)
from ._models import (
CountPoint,
CountResponse,
CreateResponse,
Error,
ExecRequest,
ExecResponse,
ExecTarget,
FilterValuesResponse,
HealthResponse,
ListResponse,
LogLine,
LogsResponse,
LookupResult,
Pagination,
ReadinessResponse,
SandboxDomainSpec,
SandboxEgressSpec,
SandboxNetworkingSpec,
SandboxSpec,
StatusResponse,
VolumeFileEntry,
VolumeFileListResponse,
VolumeListResponse,
VolumeSpec,
)
from ._models import Volume as VolumeRecord
from .enums import (
FilterValuesResponsePhases,
LogLineLevel,
SandboxDomainSpecVisibility,
SandboxesPhase,
StatusResponsePhase,
VolumeFileEntryType,
VolumePhase,
)
from .healthz import AsyncHealthz, Healthz
from .porter import AsyncPorter, Porter
from .readyz import AsyncReadyz, Readyz
from .sandbox import AsyncSandbox, Sandbox
from .sandboxes import AsyncSandboxes, Sandboxes
from .volume import AsyncVolume, Volume, VolumeFile
from .volumes import AsyncVolumes, Volumes

__all__ = [
"Porter",
"AsyncHealthz",
"AsyncPorter",
"Sandbox",
"AsyncPorterSandboxApiClient",
"AsyncReadyz",
"AsyncSandbox",
"Sandboxes",
"AsyncSandboxes",
"Volumes",
"AsyncVolume",
"AsyncVolumes",
"PorterSandboxApiClient",
"AsyncPorterSandboxApiClient",
"SandboxError",
"AuthenticationError",
"BinaryContent",
"ContentRange",
"CountPoint",
"CountResponse",
"CreateResponse",
"Error",
"ExecRequest",
"ExecResponse",
"ExecTarget",
"FilterValuesResponse",
"FilterValuesResponsePhases",
"HealthResponse",
"Healthz",
"ListResponse",
"LogLine",
"LogLineLevel",
"LogsResponse",
"LookupResult",
"NotFoundError",
"Pagination",
"Porter",
"PorterSandboxApiClient",
"RateLimitError",
"ReadinessResponse",
"Readyz",
"Sandbox",
"SandboxDomainSpec",
"SandboxDomainSpecVisibility",
"SandboxEgressSpec",
"SandboxError",
"SandboxNetworkingSpec",
"SandboxSpec",
"SandboxTimeoutError",
"Sandboxes",
"SandboxesPhase",
"ServerError",
"StatusResponse",
"StatusResponsePhase",
"Volume",
"VolumeFile",
"VolumeFileEntry",
"VolumeFileEntryType",
"VolumeFileListResponse",
"VolumeListResponse",
"VolumePhase",
"VolumeRecord",
"VolumeSpec",
"Volumes",
]
76 changes: 53 additions & 23 deletions porter_sandbox/_async_base_client.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,30 @@
# Generated by oagen. DO NOT EDIT.


from __future__ import annotations

import json as json_lib
from collections.abc import Mapping
from typing import Any

import httpx
from httpx._client import UseClientDefault

from ._base_client import _decode_body, _error_message, _request_headers
from ._binary import BinaryContent, _binary_content
from ._config import Config
from ._errors import SandboxError, SandboxTimeoutError, error_for_status
from ._retries import DEFAULT_MAX_RETRIES, should_retry, sleep_for_attempt

USER_AGENT = "porter-sandbox-python/0.0.1"


def _decode_body(response: httpx.Response) -> Any:
if response.status_code == 204 or not response.content:
return None
content_type = response.headers.get("content-type", "")
if "application/json" in content_type:
try:
return response.json()
except json_lib.JSONDecodeError:
return response.text
return response.text


def _error_message(body: Any, status_code: int) -> str:
if isinstance(body, dict):
error = body.get("error")
if isinstance(error, str):
return error
return f"HTTP {status_code}"


class _AsyncBaseClient:
"""Async HTTP transport shared by all generated async resource classes.

Owns the httpx.AsyncClient, header injection, retry loop, and error mapping.
Resource methods call `await self._client._request(...)`.
Resource methods call `await self._client._request(...)`, or
`_request_binary(...)` for the endpoints that answer with bytes rather than
JSON.
"""

def __init__(
Expand Down Expand Up @@ -80,9 +66,52 @@ async def _request(
path: str,
params: Mapping[str, Any] | None = None,
json: Any = None,
headers: Mapping[str, str | None] | None = None,
timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT,
retry: bool = True,
) -> Any:
response = await self._send(
method=method,
path=path,
params=params,
json=json,
headers=headers,
accept="application/json",
timeout=timeout,
retry=retry,
)
return _decode_body(response)

async def _request_binary(
self,
*,
method: str,
path: str,
params: Mapping[str, Any] | None = None,
headers: Mapping[str, str | None] | None = None,
) -> BinaryContent:
response = await self._send(
method=method,
path=path,
params=params,
json=None,
headers=headers,
accept="application/octet-stream",
)
return _binary_content(response)

async def _send(
self,
*,
method: str,
path: str,
params: Mapping[str, Any] | None,
json: Any,
headers: Mapping[str, str | None] | None,
accept: str,
timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT,
retry: bool = True,
) -> httpx.Response:
# `timeout=None` disables the timeout entirely, used for long-running
# calls like exec, where the API works for the full duration of the
# request. `retry=False` is for calls that must not be re-sent (exec):
Expand All @@ -98,6 +127,7 @@ async def _request(
url=path,
params=params,
json=json,
headers=_request_headers(headers, accept),
timeout=timeout,
)
except httpx.TimeoutException as exc:
Expand All @@ -113,7 +143,7 @@ async def _request(
raise SandboxError(f"Network error: {exc}") from exc

if 200 <= response.status_code < 300:
return _decode_body(response)
return response

if should_retry(response.status_code) and attempt < max_retries:
await sleep_for_attempt(attempt)
Expand Down
Loading
Loading