From 475526a7922727a9180ffac31c65a3c2753fe12a Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Mon, 13 Apr 2026 17:01:35 -0400 Subject: [PATCH 1/3] Add list APIs --- leap0/__init__.py | 10 ++- leap0/_async/sandbox.py | 110 ++++++++++++++++++++++++++++++++- leap0/_async/snapshots.py | 41 +++++++++++- leap0/_schemas/sandbox.py | 16 +++++ leap0/_schemas/snapshot.py | 5 ++ leap0/_sync/sandbox.py | 109 +++++++++++++++++++++++++++++++- leap0/_sync/snapshots.py | 41 +++++++++++- leap0/models/sandbox.py | 56 ++++++++++++++++- leap0/models/snapshot.py | 16 ++++- tests/_async/test_sandboxes.py | 83 +++++++++++++++++++++++++ tests/_async/test_snapshots.py | 34 ++++++++++ tests/_sync/test_sandboxes.py | 70 +++++++++++++++++++++ tests/_sync/test_snapshots.py | 26 ++++++++ tests/test_import.py | 6 ++ 14 files changed, 612 insertions(+), 11 deletions(-) diff --git a/leap0/__init__.py b/leap0/__init__.py index c497188..504e4d8 100644 --- a/leap0/__init__.py +++ b/leap0/__init__.py @@ -77,11 +77,13 @@ from .models.sandbox import ( CreateSandboxParams, NetworkPolicyMode, + SandboxListItem, + SandboxListResponse, SandboxState, SandboxStatus, sandbox_id_of, ) - from .models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, snapshot_id_of + from .models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, SnapshotListResponse, snapshot_id_of from .models.ssh import SshAccess, SshValidation from .models.template import ( AwsRegistryCredentials, @@ -199,11 +201,14 @@ "RegistryCredentialsInput", "ResumeSnapshotParams", "Sandbox", + "SandboxListItem", + "SandboxListResponse", "SandboxState", "SandboxStatus", "SandboxesClient", "SearchMatch", "Snapshot", + "SnapshotListResponse", "snapshot_id_of", "SnapshotsClient", "SshAccess", @@ -262,12 +267,15 @@ "Leap0WebSocketError": (".models.errors", "Leap0WebSocketError"), "CreateSandboxParams": (".models.sandbox", "CreateSandboxParams"), "NetworkPolicyMode": (".models.sandbox", "NetworkPolicyMode"), + "SandboxListItem": (".models.sandbox", "SandboxListItem"), + "SandboxListResponse": (".models.sandbox", "SandboxListResponse"), "SandboxStatus": (".models.sandbox", "SandboxStatus"), "SandboxState": (".models.sandbox", "SandboxState"), "sandbox_id_of": (".models.sandbox", "sandbox_id_of"), "CreateSnapshotParams": (".models.snapshot", "CreateSnapshotParams"), "ResumeSnapshotParams": (".models.snapshot", "ResumeSnapshotParams"), "Snapshot": (".models.snapshot", "Snapshot"), + "SnapshotListResponse": (".models.snapshot", "SnapshotListResponse"), "snapshot_id_of": (".models.snapshot", "snapshot_id_of"), "EditFileResult": (".models.filesystem", "EditFileResult"), "EditResult": (".models.filesystem", "EditResult"), diff --git a/leap0/_async/sandbox.py b/leap0/_async/sandbox.py index 1c75983..6734911 100644 --- a/leap0/_async/sandbox.py +++ b/leap0/_async/sandbox.py @@ -13,8 +13,15 @@ DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU, ) -from ..models.sandbox import CreateSandboxParams, Sandbox as SandboxData, SandboxRef, SandboxStatus, sandbox_id_of -from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict, SandboxStatusResponseDict +from ..models.sandbox import ( + CreateSandboxParams, + Sandbox as SandboxData, + SandboxListResponse, + SandboxRef, + SandboxStatus, + sandbox_id_of, +) +from .._schemas.sandbox import ListSandboxesResponseDict, NetworkPolicyDict, SandboxCreateResponseDict, SandboxStatusResponseDict from .._utils.errors import intercept_errors from .._utils.url import ensure_leading_slash, sandbox_base_url, websocket_url_from_http from ._transport import AsyncTransport @@ -147,6 +154,14 @@ def websocket_url(self, path: str = "/", *, port: int | None = None) -> str: """ return self._client.sandboxes.websocket_url(self, path=path, port=port) + async def get_user_home_dir(self, http_timeout: float | None = None) -> str: + """Fetch the resolved home directory for the sandbox user.""" + return await self._client.sandboxes.get_user_home_dir(self, http_timeout=http_timeout) + + async def get_workdir(self, http_timeout: float | None = None) -> str: + """Fetch the configured working directory for the sandbox.""" + return await self._client.sandboxes.get_workdir(self, http_timeout=http_timeout) + def _inject_otel_env(env_vars: dict[str, str] | None) -> dict[str, str] | None: endpoint = os.environ.get(OTEL_EXPORTER_OTLP_ENDPOINT_ENV) @@ -239,6 +254,56 @@ async def create( ) return self._wrap_sandbox(SandboxData.from_dict(data)) + @intercept_errors("Failed to list sandboxes: ") + async def list( + self, + *, + state: str | None = None, + sort: str = "created_at", + order_by: str = "desc", + page: int = 1, + page_size: int = 20, + http_timeout: float | None = None, + ) -> SandboxListResponse: + """List sandboxes for the authenticated organization. + + Args: + state: Optional sandbox state filter. + sort: Sort field, either ``created_at`` or ``state``. + order_by: Sort direction, either ``asc`` or ``desc``. + page: 1-based page number. + page_size: Page size between 1 and 100. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + SandboxListResponse: Paginated sandbox summaries. + """ + valid_states = {"starting", "snapshotting", "running", "paused", "unpausing", "deleting"} + if state is not None and state not in valid_states: + raise ValueError(f"state must be one of {sorted(valid_states)}") + if sort not in {"created_at", "state"}: + raise ValueError("sort must be one of ['created_at', 'state']") + if order_by not in {"asc", "desc"}: + raise ValueError("order_by must be one of ['asc', 'desc']") + if page < 1: + raise ValueError("page must be at least 1") + if page_size < 1 or page_size > 100: + raise ValueError("page_size must be between 1 and 100") + + data = cast(ListSandboxesResponseDict, await self._transport.request_json( + "GET", + "/v1/sandboxes", + params={ + "state": state, + "sort": sort, + "order-by": order_by, + "page": page, + "page-size": page_size, + }, + timeout=http_timeout, + )) + return SandboxListResponse.from_dict(data) + @intercept_errors("Failed to pause sandbox: ") async def pause( self, @@ -293,6 +358,47 @@ async def delete(self, sandbox: SandboxRef, http_timeout: float | None = None) - timeout=http_timeout, ) + @intercept_errors("Failed to get sandbox user home directory: ") + async def get_user_home_dir(self, sandbox: SandboxRef, http_timeout: float | None = None) -> str: + """Get the resolved home directory for the sandbox user. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + str: Resolved sandbox user home directory. + """ + data = cast(dict[str, object], await self._transport.request_json( + "GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/system/user-home-dir", + timeout=http_timeout, + )) + value = data.get("user_home_dir") + if not isinstance(value, str): + raise ValueError("Sandbox user home directory response missing 'user_home_dir'") + return value + + @intercept_errors("Failed to get sandbox workdir: ") + async def get_workdir(self, sandbox: SandboxRef, http_timeout: float | None = None) -> str: + """Get the configured working directory for the sandbox. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + str: Configured sandbox workdir. + """ + data = cast(dict[str, object], await self._transport.request_json( + "GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/system/workdir", + timeout=http_timeout, + )) + value = data.get("workdir") + if not isinstance(value, str): + raise ValueError("Sandbox workdir response missing 'workdir'") + return value + + def invoke_url(self, sandbox: SandboxRef, path: str = "/", *, port: int | None = None) -> str: """Build an HTTPS URL for this sandbox. diff --git a/leap0/_async/snapshots.py b/leap0/_async/snapshots.py index 1aee14c..b23a170 100644 --- a/leap0/_async/snapshots.py +++ b/leap0/_async/snapshots.py @@ -4,8 +4,8 @@ from .._internal.types import SandboxFactory from ..models.sandbox import Sandbox, SandboxRef, sandbox_id_of -from ..models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, SnapshotRef, snapshot_id_of -from .._schemas.snapshot import SnapshotCreateResponseDict +from ..models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, SnapshotListResponse, SnapshotRef, snapshot_id_of +from .._schemas.snapshot import ListSnapshotsResponseDict, SnapshotCreateResponseDict from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict from .._utils.errors import intercept_errors from ._transport import AsyncTransport @@ -35,6 +35,43 @@ def _wrap_sandbox(self, sandbox: Sandbox) -> AsyncSnapshotSandboxT | Sandbox: return sandbox return self._sandbox_factory(sandbox) + @intercept_errors("Failed to list snapshots: ") + async def list( + self, + *, + query: str = "", + sort: str = "created_at", + order_by: str = "desc", + page: int = 1, + page_size: int = 20, + http_timeout: float | None = None, + ) -> SnapshotListResponse: + """List snapshots for the authenticated organization.""" + if len(query) > 64: + raise ValueError("query must be at most 64 characters") + if sort not in {"created_at", "template_id"}: + raise ValueError("sort must be one of ['created_at', 'template_id']") + if order_by not in {"asc", "desc"}: + raise ValueError("order_by must be one of ['asc', 'desc']") + if page < 1: + raise ValueError("page must be at least 1") + if page_size < 1 or page_size > 100: + raise ValueError("page_size must be between 1 and 100") + + data = cast(ListSnapshotsResponseDict, await self._transport.request_json( + "GET", + "/v1/snapshots", + params={ + "query": query, + "sort": sort, + "order-by": order_by, + "page": page, + "page-size": page_size, + }, + timeout=http_timeout, + )) + return SnapshotListResponse.from_dict(data) + @intercept_errors("Failed to create snapshot: ") async def create( self, diff --git a/leap0/_schemas/sandbox.py b/leap0/_schemas/sandbox.py index decbf5e..2e66d6e 100644 --- a/leap0/_schemas/sandbox.py +++ b/leap0/_schemas/sandbox.py @@ -41,3 +41,19 @@ class SandboxStatusResponseDict(TypedDict): state: SandboxState | str auto_pause: bool created_at: str + +class SandboxListItemResponseDict(TypedDict, total=False): + """Wire schema for sandbox list items.""" + id: Required[str] + template_id: Required[str] + pod_id: Required[str] + state: Required[SandboxState | str] + launch_time: str + state_change_time: str + timeout_at: int + created_at: Required[str] + +class ListSandboxesResponseDict(TypedDict): + """Wire schema for paginated sandbox list responses.""" + items: list[SandboxListItemResponseDict] + total_items: int diff --git a/leap0/_schemas/snapshot.py b/leap0/_schemas/snapshot.py index 1856da8..80a7279 100644 --- a/leap0/_schemas/snapshot.py +++ b/leap0/_schemas/snapshot.py @@ -17,3 +17,8 @@ class SnapshotCreateResponseDict(TypedDict, total=False): state: SandboxState | str created_at: str network_policy: NetworkPolicyDict | None + +class ListSnapshotsResponseDict(TypedDict): + """Wire schema for paginated snapshot list responses.""" + items: list[SnapshotCreateResponseDict] + total_items: int diff --git a/leap0/_sync/sandbox.py b/leap0/_sync/sandbox.py index 51a8af3..5042a2f 100644 --- a/leap0/_sync/sandbox.py +++ b/leap0/_sync/sandbox.py @@ -12,8 +12,15 @@ DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU, ) -from ..models.sandbox import CreateSandboxParams, Sandbox as SandboxData, SandboxRef, SandboxStatus, sandbox_id_of -from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict, SandboxStatusResponseDict +from ..models.sandbox import ( + CreateSandboxParams, + Sandbox as SandboxData, + SandboxListResponse, + SandboxRef, + SandboxStatus, + sandbox_id_of, +) +from .._schemas.sandbox import ListSandboxesResponseDict, NetworkPolicyDict, SandboxCreateResponseDict, SandboxStatusResponseDict from .._utils.errors import intercept_errors from .._utils.url import ensure_leading_slash, sandbox_base_url, websocket_url_from_http from ._transport import Transport @@ -144,6 +151,14 @@ def websocket_url(self, path: str = "/", *, port: int | None = None) -> str: """ return self._client.sandboxes.websocket_url(self, path=path, port=port) + def get_user_home_dir(self, http_timeout: float | None = None) -> str: + """Fetch the resolved home directory for the sandbox user.""" + return self._client.sandboxes.get_user_home_dir(self, http_timeout=http_timeout) + + def get_workdir(self, http_timeout: float | None = None) -> str: + """Fetch the configured working directory for the sandbox.""" + return self._client.sandboxes.get_workdir(self, http_timeout=http_timeout) + def _inject_otel_env(env_vars: dict[str, str] | None) -> dict[str, str] | None: endpoint = os.environ.get(OTEL_EXPORTER_OTLP_ENDPOINT_ENV) @@ -244,6 +259,56 @@ def create( ) return self._wrap_sandbox(SandboxData.from_dict(data)) + @intercept_errors("Failed to list sandboxes: ") + def list( + self, + *, + state: str | None = None, + sort: str = "created_at", + order_by: str = "desc", + page: int = 1, + page_size: int = 20, + http_timeout: float | None = None, + ) -> SandboxListResponse: + """List sandboxes for the authenticated organization. + + Args: + state: Optional sandbox state filter. + sort: Sort field, either ``created_at`` or ``state``. + order_by: Sort direction, either ``asc`` or ``desc``. + page: 1-based page number. + page_size: Page size between 1 and 100. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + SandboxListResponse: Paginated sandbox summaries. + """ + valid_states = {"starting", "snapshotting", "running", "paused", "unpausing", "deleting"} + if state is not None and state not in valid_states: + raise ValueError(f"state must be one of {sorted(valid_states)}") + if sort not in {"created_at", "state"}: + raise ValueError("sort must be one of ['created_at', 'state']") + if order_by not in {"asc", "desc"}: + raise ValueError("order_by must be one of ['asc', 'desc']") + if page < 1: + raise ValueError("page must be at least 1") + if page_size < 1 or page_size > 100: + raise ValueError("page_size must be between 1 and 100") + + data = cast(ListSandboxesResponseDict, self._transport.request_json( + "GET", + "/v1/sandboxes", + params={ + "state": state, + "sort": sort, + "order-by": order_by, + "page": page, + "page-size": page_size, + }, + timeout=http_timeout, + )) + return SandboxListResponse.from_dict(data) + @intercept_errors("Failed to pause sandbox: ") def pause(self, sandbox: SandboxRef, http_timeout: float | None = None) -> SandboxT | SandboxData | SandboxStatus: """Pause a running sandbox. @@ -292,6 +357,46 @@ def delete(self, sandbox: SandboxRef, http_timeout: float | None = None) -> None timeout=http_timeout, ) + @intercept_errors("Failed to get sandbox user home directory: ") + def get_user_home_dir(self, sandbox: SandboxRef, http_timeout: float | None = None) -> str: + """Get the resolved home directory for the sandbox user. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + str: Resolved sandbox user home directory. + """ + data = cast(dict[str, object], self._transport.request_json( + "GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/system/user-home-dir", + timeout=http_timeout, + )) + value = data.get("user_home_dir") + if not isinstance(value, str): + raise ValueError("Sandbox user home directory response missing 'user_home_dir'") + return value + + @intercept_errors("Failed to get sandbox workdir: ") + def get_workdir(self, sandbox: SandboxRef, http_timeout: float | None = None) -> str: + """Get the configured working directory for the sandbox. + + Args: + sandbox: Sandbox ID or object. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + str: Configured sandbox workdir. + """ + data = cast(dict[str, object], self._transport.request_json( + "GET", f"/v1/sandbox/{sandbox_id_of(sandbox)}/system/workdir", + timeout=http_timeout, + )) + value = data.get("workdir") + if not isinstance(value, str): + raise ValueError("Sandbox workdir response missing 'workdir'") + return value + def invoke_url(self, sandbox: SandboxRef, path: str = "/", *, port: int | None = None) -> str: """Build an HTTPS URL that routes directly to the sandbox. diff --git a/leap0/_sync/snapshots.py b/leap0/_sync/snapshots.py index fc5adb3..10b8a77 100644 --- a/leap0/_sync/snapshots.py +++ b/leap0/_sync/snapshots.py @@ -4,8 +4,8 @@ from .._internal.types import SandboxFactory from ..models.sandbox import Sandbox, SandboxRef, sandbox_id_of -from ..models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, SnapshotRef, snapshot_id_of -from .._schemas.snapshot import SnapshotCreateResponseDict +from ..models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, SnapshotListResponse, SnapshotRef, snapshot_id_of +from .._schemas.snapshot import ListSnapshotsResponseDict, SnapshotCreateResponseDict from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict from .._utils.errors import intercept_errors from ._transport import Transport @@ -35,6 +35,43 @@ def _wrap_sandbox(self, sandbox: Sandbox) -> SnapshotSandboxT | Sandbox: return sandbox return self._sandbox_factory(sandbox) + @intercept_errors("Failed to list snapshots: ") + def list( + self, + *, + query: str = "", + sort: str = "created_at", + order_by: str = "desc", + page: int = 1, + page_size: int = 20, + http_timeout: float | None = None, + ) -> SnapshotListResponse: + """List snapshots for the authenticated organization.""" + if len(query) > 64: + raise ValueError("query must be at most 64 characters") + if sort not in {"created_at", "template_id"}: + raise ValueError("sort must be one of ['created_at', 'template_id']") + if order_by not in {"asc", "desc"}: + raise ValueError("order_by must be one of ['asc', 'desc']") + if page < 1: + raise ValueError("page must be at least 1") + if page_size < 1 or page_size > 100: + raise ValueError("page_size must be between 1 and 100") + + data = cast(ListSnapshotsResponseDict, self._transport.request_json( + "GET", + "/v1/snapshots", + params={ + "query": query, + "sort": sort, + "order-by": order_by, + "page": page, + "page-size": page_size, + }, + timeout=http_timeout, + )) + return SnapshotListResponse.from_dict(data) + @intercept_errors("Failed to create snapshot: ") def create( self, diff --git a/leap0/models/sandbox.py b/leap0/models/sandbox.py index 15130b4..ba55827 100644 --- a/leap0/models/sandbox.py +++ b/leap0/models/sandbox.py @@ -11,7 +11,14 @@ from .._internal.types import SandboxHandle from .._internal.types import JsonObject -from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict, SandboxStatusResponseDict, TransformRuleDict +from .._schemas.sandbox import ( + ListSandboxesResponseDict, + NetworkPolicyDict, + SandboxCreateResponseDict, + SandboxListItemResponseDict, + SandboxStatusResponseDict, + TransformRuleDict, +) from .config import DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT_MIN, DEFAULT_VCPU class SandboxState(str, Enum): @@ -196,6 +203,53 @@ def from_dict(cls, data: SandboxStatusResponseDict) -> SandboxStatus: created_at=data.get("created_at", ""), ) +@dataclass(slots=True) +class SandboxListItem: + """Summary entry returned by the sandbox list API.""" + id: str + template_id: str + pod_id: str + state: SandboxState | str + launch_time: str | None = None + state_change_time: str | None = None + timeout_at: int | None = None + created_at: str = "" + + @classmethod + def from_dict(cls, data: SandboxListItemResponseDict) -> SandboxListItem: + """Build an instance from a wire-format dictionary.""" + sandbox_id = data.get("id") + if not isinstance(sandbox_id, str) or not sandbox_id.strip(): + raise ValueError(f"SandboxListItem response missing required non-empty string 'id', got: {sandbox_id!r}") + state = _parse_sandbox_state(data.get("state")) + launch_time = data.get("launch_time") + state_change_time = data.get("state_change_time") + timeout_at = data.get("timeout_at") + return cls( + id=sandbox_id, + template_id=data.get("template_id", ""), + pod_id=data.get("pod_id", ""), + state=state, + launch_time=launch_time if isinstance(launch_time, str) else None, + state_change_time=state_change_time if isinstance(state_change_time, str) else None, + timeout_at=int(timeout_at) if timeout_at is not None else None, + created_at=data.get("created_at", ""), + ) + +@dataclass(slots=True) +class SandboxListResponse: + """Paginated sandbox list response.""" + items: list[SandboxListItem] + total_items: int + + @classmethod + def from_dict(cls, data: ListSandboxesResponseDict) -> SandboxListResponse: + """Build an instance from a wire-format dictionary.""" + return cls( + items=[SandboxListItem.from_dict(item) for item in data.get("items", [])], + total_items=int(data.get("total_items", 0)), + ) + SandboxRef: TypeAlias = str | SandboxHandle def _parse_sandbox_state(value: SandboxState | str | None) -> SandboxState | str: diff --git a/leap0/models/snapshot.py b/leap0/models/snapshot.py index 1a38c4f..043735b 100644 --- a/leap0/models/snapshot.py +++ b/leap0/models/snapshot.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, ConfigDict, model_validator -from .._schemas.snapshot import SnapshotCreateResponseDict +from .._schemas.snapshot import ListSnapshotsResponseDict, SnapshotCreateResponseDict from .sandbox import NetworkPolicyDict, NetworkPolicyMode, SandboxState, _parse_sandbox_state class CreateSnapshotParams(BaseModel): @@ -101,6 +101,20 @@ def from_dict(cls, data: SnapshotCreateResponseDict) -> Snapshot: created_at=data.get("created_at", ""), ) +@dataclass(slots=True) +class SnapshotListResponse: + """Paginated snapshot list response.""" + items: list[Snapshot] + total_items: int + + @classmethod + def from_dict(cls, data: ListSnapshotsResponseDict) -> SnapshotListResponse: + """Build an instance from a wire-format dictionary.""" + return cls( + items=[Snapshot.from_dict(item) for item in data.get("items", [])], + total_items=int(data.get("total_items", 0)), + ) + class SnapshotIdentifiable(Protocol): """Protocol for objects exposing a snapshot ID.""" id: str diff --git a/tests/_async/test_sandboxes.py b/tests/_async/test_sandboxes.py index 7cbd0e3..c3c032f 100644 --- a/tests/_async/test_sandboxes.py +++ b/tests/_async/test_sandboxes.py @@ -40,6 +40,40 @@ async def run() -> None: asyncio.run(run()) + def test_list(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = { + "items": [{ + "id": "sbx-1", "template_id": "tpl-1", "pod_id": "pod-1", "state": "running", + "created_at": "2026-01-01T00:00:00Z", + }], + "total_items": 1, + } + + result = await AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev").list( + state="running", sort="state", order_by="asc", page=2, page_size=10, + ) + + args, kwargs = async_mock_transport.request_json.call_args + assert args == ("GET", "/v1/sandboxes") + assert kwargs["params"] == { + "state": "running", + "sort": "state", + "order-by": "asc", + "page": 2, + "page-size": 10, + } + assert result.total_items == 1 + + asyncio.run(run()) + + def test_list_validates_input(self, async_mock_transport): + async def run() -> None: + with pytest.raises(Leap0Error, match="state must be one of"): + await AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev").list(state="deleted") + + asyncio.run(run()) + def test_create_injects_otel_env_when_enabled(self, async_mock_transport, monkeypatch): async def run() -> None: monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318") @@ -89,6 +123,29 @@ async def run() -> None: asyncio.run(run()) + def test_get_user_home_dir(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = {"user_home_dir": "/home/steven"} + + result = await AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev").get_user_home_dir("sbx-1") + + assert result == "/home/steven" + assert async_mock_transport.request_json.call_args[0][1] == "/v1/sandbox/sbx-1/system/user-home-dir" + + asyncio.run(run()) + + def test_get_workdir(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = {"workdir": "/home/steve/agent"} + + result = await AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev").get_workdir("sbx-1") + + assert result == "/home/steve/agent" + assert async_mock_transport.request_json.call_args[0][1] == "/v1/sandbox/sbx-1/system/workdir" + + asyncio.run(run()) + + class TestAsyncSandbox: def test_pause_forwards_http_timeout(self): @@ -112,3 +169,29 @@ async def pause(sandbox: object, http_timeout: float | None = None): assert sandbox.state == "paused" asyncio.run(run()) + + def test_runtime_info_helpers_delegate_to_sandboxes_client(self): + async def run() -> None: + sandboxes = SimpleNamespace() + fake_client = SimpleNamespace( + _filesystem=SimpleNamespace(), _git=SimpleNamespace(), _process=SimpleNamespace(), _pty=SimpleNamespace(), + _lsp=SimpleNamespace(), _ssh=SimpleNamespace(), _code_interpreter=SimpleNamespace(), _desktop=SimpleNamespace(), + sandboxes=sandboxes, + ) + + async def get_user_home_dir(sandbox: object, http_timeout: float | None = None): + assert http_timeout == 1.5 + return "/home/steven" + + async def get_workdir(sandbox: object, http_timeout: float | None = None): + assert http_timeout == 2.5 + return "/home/steve/agent" + + sandboxes.get_user_home_dir = get_user_home_dir + sandboxes.get_workdir = get_workdir + sandbox = AsyncSandbox(fake_client, Sandbox(id="sbx-1", state="running")) + + assert await sandbox.get_user_home_dir(http_timeout=1.5) == "/home/steven" + assert await sandbox.get_workdir(http_timeout=2.5) == "/home/steve/agent" + + asyncio.run(run()) diff --git a/tests/_async/test_snapshots.py b/tests/_async/test_snapshots.py index da3cb2d..f92f59f 100644 --- a/tests/_async/test_snapshots.py +++ b/tests/_async/test_snapshots.py @@ -3,6 +3,8 @@ import asyncio from unittest.mock import MagicMock +import pytest + from leap0._async.snapshots import AsyncSnapshotsClient from leap0.models.snapshot import Snapshot @@ -28,3 +30,35 @@ async def run() -> None: assert "snap-obj" in async_mock_transport.request.call_args[0][1] asyncio.run(run()) + + def test_list(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = { + "items": [{ + "id": "snap-1", "name": "snap-a", "template_id": "tpl-1", "vcpu": 2, + "memory_mib": 1024, "disk_mib": 4096, "created_at": "2026-01-01T00:00:00Z", + }], + "total_items": 1, + } + + result = await AsyncSnapshotsClient(async_mock_transport).list(query="snap", sort="template_id", order_by="asc", page=2, page_size=5) + + args, kwargs = async_mock_transport.request_json.call_args + assert args == ("GET", "/v1/snapshots") + assert kwargs["params"] == { + "query": "snap", + "sort": "template_id", + "order-by": "asc", + "page": 2, + "page-size": 5, + } + assert result.total_items == 1 + + asyncio.run(run()) + + def test_list_validates_page_size(self, async_mock_transport): + async def run() -> None: + with pytest.raises(Exception, match="page_size"): + await AsyncSnapshotsClient(async_mock_transport).list(page_size=101) + + asyncio.run(run()) diff --git a/tests/_sync/test_sandboxes.py b/tests/_sync/test_sandboxes.py index eab5319..4cf6557 100644 --- a/tests/_sync/test_sandboxes.py +++ b/tests/_sync/test_sandboxes.py @@ -29,11 +29,58 @@ def test_get(self, mock_transport): SandboxesClient(mock_transport, sandbox_domain="s.dev").get("sbx-1") assert mock_transport.request_json.call_args[0][1] == "/v1/sandbox/sbx-1/" + def test_list(self, mock_transport): + mock_transport.request_json.return_value = { + "items": [{ + "id": "sbx-1", "template_id": "tpl-1", "pod_id": "pod-1", "state": "running", + "launch_time": "2026-01-01T00:00:05Z", "state_change_time": "2026-01-01T00:00:10Z", + "timeout_at": 1735689900, "created_at": "2026-01-01T00:00:00Z", + }], + "total_items": 1, + } + + result = SandboxesClient(mock_transport, sandbox_domain="s.dev").list( + state="running", sort="state", order_by="asc", page=2, page_size=10, + ) + + args, kwargs = mock_transport.request_json.call_args + assert args == ("GET", "/v1/sandboxes") + assert kwargs["params"] == { + "state": "running", + "sort": "state", + "order-by": "asc", + "page": 2, + "page-size": 10, + } + assert result.total_items == 1 + assert result.items[0].pod_id == "pod-1" + + def test_list_validates_input(self, mock_transport): + with pytest.raises(Leap0Error, match="page_size"): + SandboxesClient(mock_transport, sandbox_domain="s.dev").list(page_size=101) + def test_delete(self, mock_transport): mock_transport.request.return_value = MagicMock(status_code=204) SandboxesClient(mock_transport, sandbox_domain="s.dev").delete("sbx-1") assert mock_transport.request.call_args[1]["expected_status"] == 204 + def test_get_user_home_dir(self, mock_transport): + mock_transport.request_json.return_value = {"user_home_dir": "/home/steven"} + + result = SandboxesClient(mock_transport, sandbox_domain="s.dev").get_user_home_dir("sbx-1") + + assert result == "/home/steven" + assert mock_transport.request_json.call_args[0][1] == "/v1/sandbox/sbx-1/system/user-home-dir" + + def test_get_workdir(self, mock_transport): + mock_transport.request_json.return_value = {"workdir": "/home/steve/agent"} + + result = SandboxesClient(mock_transport, sandbox_domain="s.dev").get_workdir("sbx-1") + + assert result == "/home/steve/agent" + assert mock_transport.request_json.call_args[0][1] == "/v1/sandbox/sbx-1/system/workdir" + + def test_accepts_sandbox_object(self, mock_transport): mock_transport.request_json.return_value = { "id": "sbx-1", "template_id": "t", "vcpu": 1, "memory_mib": 512, @@ -199,3 +246,26 @@ def test_pause_forwards_http_timeout(self): sandboxes.pause.assert_called_once_with(sandbox, http_timeout=7.5) assert sandbox.state == "paused" + + def test_runtime_info_helpers_delegate_to_sandboxes_client(self): + sandboxes = MagicMock() + sandboxes.get_user_home_dir.return_value = "/home/steven" + sandboxes.get_workdir.return_value = "/home/steve/agent" + client = SimpleNamespace( + _filesystem=MagicMock(), + _git=MagicMock(), + _process=MagicMock(), + _pty=MagicMock(), + _lsp=MagicMock(), + _ssh=MagicMock(), + _code_interpreter=MagicMock(), + _desktop=MagicMock(), + sandboxes=sandboxes, + ) + + sandbox = RichSandbox(client, Sandbox(id="sbx-1", state="running")) + + assert sandbox.get_user_home_dir(http_timeout=1.5) == "/home/steven" + assert sandbox.get_workdir(http_timeout=2.5) == "/home/steve/agent" + sandboxes.get_user_home_dir.assert_called_once_with(sandbox, http_timeout=1.5) + sandboxes.get_workdir.assert_called_once_with(sandbox, http_timeout=2.5) diff --git a/tests/_sync/test_snapshots.py b/tests/_sync/test_snapshots.py index e192f6e..6e2a740 100644 --- a/tests/_sync/test_snapshots.py +++ b/tests/_sync/test_snapshots.py @@ -30,6 +30,32 @@ def test_delete_accepts_object(self, mock_transport): SnapshotsClient(mock_transport).delete(Snapshot(id="snap-obj", name="n")) assert "snap-obj" in mock_transport.request.call_args[0][1] + def test_list(self, mock_transport): + mock_transport.request_json.return_value = { + "items": [{ + "id": "snap-1", "name": "snap-a", "template_id": "tpl-1", "vcpu": 2, + "memory_mib": 1024, "disk_mib": 4096, "created_at": "2026-01-01T00:00:00Z", + }], + "total_items": 1, + } + + result = SnapshotsClient(mock_transport).list(query="snap", sort="template_id", order_by="asc", page=2, page_size=5) + + args, kwargs = mock_transport.request_json.call_args + assert args == ("GET", "/v1/snapshots") + assert kwargs["params"] == { + "query": "snap", + "sort": "template_id", + "order-by": "asc", + "page": 2, + "page-size": 5, + } + assert result.total_items == 1 + + def test_list_validates_page_size(self, mock_transport): + with pytest.raises(Leap0Error, match="page_size"): + SnapshotsClient(mock_transport).list(page_size=101) + def test_resume_validates_input(self, mock_transport): with pytest.raises(Leap0Error, match="snapshot_name"): SnapshotsClient(mock_transport).resume(snapshot_name=" ") diff --git a/tests/test_import.py b/tests/test_import.py index da036d2..0d61030 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -38,6 +38,9 @@ RenameTemplateParams, ResumeSnapshotParams, Sandbox, + SandboxListItem, + SandboxListResponse, + SnapshotListResponse, SandboxesClient, SnapshotsClient, StreamEventType, @@ -57,6 +60,9 @@ def test_service_client_imports() -> None: assert ProcessClient is not None assert PtyClient is not None assert Sandbox is not None + assert SandboxListItem is not None + assert SandboxListResponse is not None + assert SnapshotListResponse is not None assert SandboxesClient is not None assert SnapshotsClient is not None assert TemplatesClient is not None From da7f06982164fc5ed36a549cd6a6b665d3c197ed Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Mon, 13 Apr 2026 17:14:14 -0400 Subject: [PATCH 2/3] Fix review notes --- leap0/_async/sandbox.py | 17 ++++++++++------- leap0/_sync/sandbox.py | 17 ++++++++++------- leap0/models/snapshot.py | 13 +++++++++++-- tests/_async/test_snapshots.py | 3 ++- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/leap0/_async/sandbox.py b/leap0/_async/sandbox.py index 6734911..f9ebb1f 100644 --- a/leap0/_async/sandbox.py +++ b/leap0/_async/sandbox.py @@ -290,16 +290,19 @@ async def list( if page_size < 1 or page_size > 100: raise ValueError("page_size must be between 1 and 100") + params: dict[str, str | int] = { + "sort": sort, + "order-by": order_by, + "page": page, + "page-size": page_size, + } + if state is not None: + params["state"] = state + data = cast(ListSandboxesResponseDict, await self._transport.request_json( "GET", "/v1/sandboxes", - params={ - "state": state, - "sort": sort, - "order-by": order_by, - "page": page, - "page-size": page_size, - }, + params=params, timeout=http_timeout, )) return SandboxListResponse.from_dict(data) diff --git a/leap0/_sync/sandbox.py b/leap0/_sync/sandbox.py index 5042a2f..e1d20ce 100644 --- a/leap0/_sync/sandbox.py +++ b/leap0/_sync/sandbox.py @@ -295,16 +295,19 @@ def list( if page_size < 1 or page_size > 100: raise ValueError("page_size must be between 1 and 100") + params: dict[str, str | int] = { + "sort": sort, + "order-by": order_by, + "page": page, + "page-size": page_size, + } + if state is not None: + params["state"] = state + data = cast(ListSandboxesResponseDict, self._transport.request_json( "GET", "/v1/sandboxes", - params={ - "state": state, - "sort": sort, - "order-by": order_by, - "page": page, - "page-size": page_size, - }, + params=params, timeout=http_timeout, )) return SandboxListResponse.from_dict(data) diff --git a/leap0/models/snapshot.py b/leap0/models/snapshot.py index 043735b..e9da22a 100644 --- a/leap0/models/snapshot.py +++ b/leap0/models/snapshot.py @@ -110,9 +110,18 @@ class SnapshotListResponse: @classmethod def from_dict(cls, data: ListSnapshotsResponseDict) -> SnapshotListResponse: """Build an instance from a wire-format dictionary.""" + items = data.get("items") + if not isinstance(items, list): + raise ValueError("Snapshot list response missing required list 'items'") + + total_items = data.get("total_items") + if total_items is None: + raise ValueError("Snapshot list response missing required 'total_items'") + + parsed_total_items = int(total_items) return cls( - items=[Snapshot.from_dict(item) for item in data.get("items", [])], - total_items=int(data.get("total_items", 0)), + items=[Snapshot.from_dict(item) for item in items], + total_items=parsed_total_items, ) class SnapshotIdentifiable(Protocol): diff --git a/tests/_async/test_snapshots.py b/tests/_async/test_snapshots.py index f92f59f..8a2cd27 100644 --- a/tests/_async/test_snapshots.py +++ b/tests/_async/test_snapshots.py @@ -6,6 +6,7 @@ import pytest from leap0._async.snapshots import AsyncSnapshotsClient +from leap0.models.errors import Leap0Error from leap0.models.snapshot import Snapshot @@ -58,7 +59,7 @@ async def run() -> None: def test_list_validates_page_size(self, async_mock_transport): async def run() -> None: - with pytest.raises(Exception, match="page_size"): + with pytest.raises(Leap0Error, match="page_size"): await AsyncSnapshotsClient(async_mock_transport).list(page_size=101) asyncio.run(run()) From 073b884e166579a8016f422a411cd2b8ab8d43f8 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Mon, 13 Apr 2026 17:18:49 -0400 Subject: [PATCH 3/3] Test optional state --- tests/_async/test_sandboxes.py | 15 +++++++++++++++ tests/_sync/test_sandboxes.py | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/tests/_async/test_sandboxes.py b/tests/_async/test_sandboxes.py index c3c032f..afbd135 100644 --- a/tests/_async/test_sandboxes.py +++ b/tests/_async/test_sandboxes.py @@ -74,6 +74,21 @@ async def run() -> None: asyncio.run(run()) + def test_list_omits_state_when_not_provided(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = {"items": [], "total_items": 0} + + await AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev").list() + + assert async_mock_transport.request_json.call_args[1]["params"] == { + "sort": "created_at", + "order-by": "desc", + "page": 1, + "page-size": 20, + } + + asyncio.run(run()) + def test_create_injects_otel_env_when_enabled(self, async_mock_transport, monkeypatch): async def run() -> None: monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318") diff --git a/tests/_sync/test_sandboxes.py b/tests/_sync/test_sandboxes.py index 4cf6557..edb7fed 100644 --- a/tests/_sync/test_sandboxes.py +++ b/tests/_sync/test_sandboxes.py @@ -59,6 +59,18 @@ def test_list_validates_input(self, mock_transport): with pytest.raises(Leap0Error, match="page_size"): SandboxesClient(mock_transport, sandbox_domain="s.dev").list(page_size=101) + def test_list_omits_state_when_not_provided(self, mock_transport): + mock_transport.request_json.return_value = {"items": [], "total_items": 0} + + SandboxesClient(mock_transport, sandbox_domain="s.dev").list() + + assert mock_transport.request_json.call_args[1]["params"] == { + "sort": "created_at", + "order-by": "desc", + "page": 1, + "page-size": 20, + } + def test_delete(self, mock_transport): mock_transport.request.return_value = MagicMock(status_code=204) SandboxesClient(mock_transport, sandbox_domain="s.dev").delete("sbx-1")