From 3b279b812919d92fb7c11008c7a9f08e1fbc5d62 Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Wed, 22 Apr 2026 14:58:01 -0400 Subject: [PATCH 1/2] update snapshot restore and sandbox snapshot APIs --- examples/snapshots.py | 4 +- leap0/__init__.py | 9 ++-- leap0/_async/sandbox.py | 47 +++++++++++++++++++ leap0/_async/snapshots.py | 85 ++++------------------------------ leap0/_sync/sandbox.py | 47 +++++++++++++++++++ leap0/_sync/snapshots.py | 85 ++++------------------------------ leap0/models/sandbox.py | 28 +++++++++++ leap0/models/snapshot.py | 32 ++----------- tests/_async/test_sandboxes.py | 43 +++++++++++++++++ tests/_async/test_snapshots.py | 13 ------ tests/_sync/test_sandboxes.py | 41 ++++++++++++++++ tests/_sync/test_snapshots.py | 20 ++------ tests/test_import.py | 4 +- 13 files changed, 242 insertions(+), 216 deletions(-) diff --git a/examples/snapshots.py b/examples/snapshots.py index c3c9cda..020c397 100644 --- a/examples/snapshots.py +++ b/examples/snapshots.py @@ -15,10 +15,10 @@ def main() -> None: try: sandbox.filesystem.write_file(path="/workspace/checkpoint.txt", content="before snapshot\n") - snapshot: Snapshot = client.snapshots.create(sandbox, name="example-checkpoint") + snapshot: Snapshot = sandbox.create_snapshot(name="example-checkpoint") print("snapshot:", snapshot.id) - restored: Sandbox = client.snapshots.resume(snapshot_name=snapshot.name) + restored: Sandbox = client.snapshots.restore(snapshot_name=snapshot.name) try: content = restored.filesystem.read_file(path="/workspace/checkpoint.txt") print("restored file:", content.strip()) diff --git a/leap0/__init__.py b/leap0/__init__.py index fcd9383..8f7e3f6 100644 --- a/leap0/__init__.py +++ b/leap0/__init__.py @@ -86,7 +86,8 @@ SandboxStatus, sandbox_id_of, ) - from .models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, SnapshotListResponse, snapshot_id_of + from .models.sandbox import CreateSnapshotParams + from .models.snapshot import RestoreSnapshotParams, Snapshot, SnapshotListResponse, snapshot_id_of from .models.ssh import SshAccess, SshValidation from .models.template import ( AwsRegistryCredentials, @@ -205,7 +206,7 @@ "RegistryCredentials", "RegistryCredentialsDict", "RegistryCredentialsInput", - "ResumeSnapshotParams", + "RestoreSnapshotParams", "Sandbox", "SandboxListItem", "SandboxListResponse", @@ -280,8 +281,8 @@ "SandboxStatus": (".models.sandbox", "SandboxStatus"), "SandboxState": (".models.sandbox", "SandboxState"), "sandbox_id_of": (".models.sandbox", "sandbox_id_of"), - "CreateSnapshotParams": (".models.snapshot", "CreateSnapshotParams"), - "ResumeSnapshotParams": (".models.snapshot", "ResumeSnapshotParams"), + "CreateSnapshotParams": (".models.sandbox", "CreateSnapshotParams"), + "RestoreSnapshotParams": (".models.snapshot", "RestoreSnapshotParams"), "Snapshot": (".models.snapshot", "Snapshot"), "SnapshotListResponse": (".models.snapshot", "SnapshotListResponse"), "snapshot_id_of": (".models.snapshot", "snapshot_id_of"), diff --git a/leap0/_async/sandbox.py b/leap0/_async/sandbox.py index 9c6c0a4..b8e80e4 100644 --- a/leap0/_async/sandbox.py +++ b/leap0/_async/sandbox.py @@ -14,6 +14,7 @@ DEFAULT_VCPU, ) from ..models.sandbox import ( + CreateSnapshotParams, CreatePresignedURLParams, CreateSandboxParams, ObjectStorageMount, @@ -25,6 +26,8 @@ _validate_object_storage_mount_update, sandbox_id_of, ) +from ..models.snapshot import Snapshot +from .._schemas.snapshot import SnapshotCreateResponseDict from .._schemas.sandbox import ListSandboxesResponseDict, NetworkPolicyDict, ObjectStorageMountDict, ObjectStorageMountRequestDict, ObjectStorageMountUpdateDict, PresignedURLResponseDict, SandboxCreateResponseDict, SandboxStatusResponseDict from .._utils.errors import intercept_errors from .._utils.url import ensure_leading_slash, sandbox_base_url, websocket_url_from_http @@ -126,6 +129,30 @@ async def pause(self, http_timeout: float | None = None) -> AsyncSandbox: self._data = latest._data return self + async def create_snapshot( + self, + *, + name: str | None = None, + kill_sandbox_after: bool = False, + http_timeout: float | None = None, + ): + """Create a snapshot from this sandbox. + + Args: + name: Optional snapshot name. Auto-generated if omitted. + kill_sandbox_after: Terminate the source sandbox after the snapshot is stored. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + Snapshot: Created snapshot metadata. + """ + return await self._client.sandboxes.create_snapshot( + self, + name=name, + kill_sandbox_after=kill_sandbox_after, + http_timeout=http_timeout, + ) + async def delete(self, http_timeout: float | None = None) -> None: """Terminate and delete a sandbox. @@ -377,6 +404,26 @@ async def pause( ) return self._wrap_sandbox(SandboxData.from_dict(data)) + @intercept_errors("Failed to create snapshot: ") + async def create_snapshot( + self, + sandbox: SandboxRef, + *, + name: str | None = None, + kill_sandbox_after: bool = False, + http_timeout: float | None = None, + ) -> Snapshot: + """Create a snapshot from a running sandbox.""" + payload = CreateSnapshotParams(name=name, kill_sandbox_after=kill_sandbox_after).to_payload() + data: SnapshotCreateResponseDict = await self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/snapshot/create", + json=payload, + expected_status=201, + timeout=http_timeout, + ) + return Snapshot.from_dict(data) + @intercept_errors("Failed to get sandbox: ") async def get(self, sandbox: SandboxRef, http_timeout: float | None = None) -> AsyncSandboxT | SandboxData | SandboxStatus: """Get the latest sandbox metadata. diff --git a/leap0/_async/snapshots.py b/leap0/_async/snapshots.py index b9dcf84..485cee6 100644 --- a/leap0/_async/snapshots.py +++ b/leap0/_async/snapshots.py @@ -3,9 +3,9 @@ from typing import Generic, TypeVar, cast from .._internal.types import SandboxFactory -from ..models.sandbox import Sandbox, SandboxRef, sandbox_id_of -from ..models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, SnapshotListResponse, SnapshotRef, snapshot_id_of -from .._schemas.snapshot import ListSnapshotsResponseDict, SnapshotCreateResponseDict +from ..models.sandbox import Sandbox +from ..models.snapshot import RestoreSnapshotParams, Snapshot, SnapshotListResponse, SnapshotRef, snapshot_id_of +from .._schemas.snapshot import ListSnapshotsResponseDict from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict from .._utils.errors import intercept_errors from ._transport import AsyncTransport @@ -14,7 +14,7 @@ class AsyncSnapshotsClient(Generic[AsyncSnapshotSandboxT]): - """Create, resume, and delete sandbox snapshots. + """List, restore, and delete sandbox snapshots. A snapshot captures the full state of a running sandbox so it can be restored later. @@ -72,75 +72,8 @@ async def list( )) return SnapshotListResponse.from_dict(data) - @intercept_errors("Failed to create snapshot: ") - async def create( - self, - sandbox: SandboxRef, - *, - name: str | None = None, - http_timeout: float | None = None, - ) -> Snapshot: - """Create a snapshot of a running sandbox without stopping it. - - Args: - sandbox: Sandbox ID or object to snapshot. - name: Optional snapshot name. Auto-generated if omitted. - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - - Args: - sandbox: Sandbox ID or object to pause. - name: Optional snapshot name. Auto-generated if omitted. - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - Returns: - Snapshot: Created snapshot metadata. - - Returns: - Snapshot: Snapshot metadata including ID and optional name. - """ - payload = CreateSnapshotParams(name=name).to_payload() - data = cast(SnapshotCreateResponseDict, await self._transport.request_json( - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/snapshot/create", - json=payload, - expected_status=201, - timeout=http_timeout, - )) - return Snapshot.from_dict(data) - - @intercept_errors("Failed to pause sandbox: ") - async def pause( - self, - sandbox: SandboxRef, - *, - name: str | None = None, - http_timeout: float | None = None, - ) -> Snapshot: - """Pause a running sandbox and create a snapshot in one step. - - The sandbox is stopped after the snapshot is taken. - - Args: - sandbox: Sandbox ID or object. - name: Name used by this operation. - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - - Returns: - object: Result returned by this operation. - """ - payload = CreateSnapshotParams(name=name).to_payload() - data = cast(SnapshotCreateResponseDict, await self._transport.request_json( - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/snapshot/pause", - json=payload, - expected_status=201, - timeout=http_timeout, - )) - return Snapshot.from_dict(data) - - @intercept_errors("Failed to resume snapshot: ") - async def resume( + @intercept_errors("Failed to restore snapshot: ") + async def restore( self, *, snapshot_name: str, @@ -163,9 +96,9 @@ async def resume( http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: - Sandbox: Newly resumed sandbox. + Sandbox: Newly restored sandbox. """ - payload = ResumeSnapshotParams( + payload = RestoreSnapshotParams( snapshot_name=snapshot_name, auto_pause=auto_pause, timeout=timeout, @@ -173,7 +106,7 @@ async def resume( ).to_payload() data = cast(SandboxCreateResponseDict, await self._transport.request_json( "POST", - "/v1/snapshot/resume", + "/v1/snapshot/restore", json=payload, expected_status=201, timeout=http_timeout, diff --git a/leap0/_sync/sandbox.py b/leap0/_sync/sandbox.py index cb8ac19..0c51100 100644 --- a/leap0/_sync/sandbox.py +++ b/leap0/_sync/sandbox.py @@ -13,6 +13,7 @@ DEFAULT_VCPU, ) from ..models.sandbox import ( + CreateSnapshotParams, CreatePresignedURLParams, CreateSandboxParams, ObjectStorageMount, @@ -24,6 +25,8 @@ _validate_object_storage_mount_update, sandbox_id_of, ) +from ..models.snapshot import Snapshot +from .._schemas.snapshot import SnapshotCreateResponseDict from .._schemas.sandbox import ListSandboxesResponseDict, NetworkPolicyDict, ObjectStorageMountDict, ObjectStorageMountRequestDict, ObjectStorageMountUpdateDict, PresignedURLResponseDict, SandboxCreateResponseDict, SandboxStatusResponseDict from .._utils.errors import intercept_errors from .._utils.url import ensure_leading_slash, sandbox_base_url, websocket_url_from_http @@ -123,6 +126,30 @@ def pause(self, http_timeout: float | None = None) -> Sandbox: self._data = latest._data return self + def create_snapshot( + self, + *, + name: str | None = None, + kill_sandbox_after: bool = False, + http_timeout: float | None = None, + ): + """Create a snapshot from this sandbox. + + Args: + name: Optional snapshot name. Auto-generated if omitted. + kill_sandbox_after: Terminate the source sandbox after the snapshot is stored. + http_timeout: Optional HTTP request timeout in seconds for this SDK call. + + Returns: + Snapshot: Created snapshot metadata. + """ + return self._client.sandboxes.create_snapshot( + self, + name=name, + kill_sandbox_after=kill_sandbox_after, + http_timeout=http_timeout, + ) + def delete(self, http_timeout: float | None = None) -> None: """Delete the sandbox. @@ -376,6 +403,26 @@ def pause(self, sandbox: SandboxRef, http_timeout: float | None = None) -> Sandb ) return self._wrap_sandbox(SandboxData.from_dict(data)) + @intercept_errors("Failed to create snapshot: ") + def create_snapshot( + self, + sandbox: SandboxRef, + *, + name: str | None = None, + kill_sandbox_after: bool = False, + http_timeout: float | None = None, + ) -> Snapshot: + """Create a snapshot from a running sandbox.""" + payload = CreateSnapshotParams(name=name, kill_sandbox_after=kill_sandbox_after).to_payload() + data: SnapshotCreateResponseDict = self._transport.request_json( + "POST", + f"/v1/sandbox/{sandbox_id_of(sandbox)}/snapshot/create", + json=payload, + expected_status=201, + timeout=http_timeout, + ) + return Snapshot.from_dict(data) + @intercept_errors("Failed to get sandbox: ") def get(self, sandbox: SandboxRef, http_timeout: float | None = None) -> SandboxT | SandboxData | SandboxStatus: """Get the latest sandbox metadata. diff --git a/leap0/_sync/snapshots.py b/leap0/_sync/snapshots.py index d73f1b1..c1e9579 100644 --- a/leap0/_sync/snapshots.py +++ b/leap0/_sync/snapshots.py @@ -3,9 +3,9 @@ from typing import Generic, TypeVar, cast from .._internal.types import SandboxFactory -from ..models.sandbox import Sandbox, SandboxRef, sandbox_id_of -from ..models.snapshot import CreateSnapshotParams, ResumeSnapshotParams, Snapshot, SnapshotListResponse, SnapshotRef, snapshot_id_of -from .._schemas.snapshot import ListSnapshotsResponseDict, SnapshotCreateResponseDict +from ..models.sandbox import Sandbox +from ..models.snapshot import RestoreSnapshotParams, Snapshot, SnapshotListResponse, SnapshotRef, snapshot_id_of +from .._schemas.snapshot import ListSnapshotsResponseDict from .._schemas.sandbox import NetworkPolicyDict, SandboxCreateResponseDict from .._utils.errors import intercept_errors from ._transport import Transport @@ -14,7 +14,7 @@ class SnapshotsClient(Generic[SnapshotSandboxT]): - """Create, resume, and delete sandbox snapshots. + """List, restore, and delete sandbox snapshots. A snapshot captures the full state of a running sandbox so it can be restored later. @@ -72,75 +72,8 @@ def list( )) return SnapshotListResponse.from_dict(data) - @intercept_errors("Failed to create snapshot: ") - def create( - self, - sandbox: SandboxRef, - *, - name: str | None = None, - http_timeout: float | None = None, - ) -> Snapshot: - """Create a snapshot of a running sandbox without stopping it. - - Args: - sandbox: Sandbox ID or object to snapshot. - name: Optional snapshot name. Auto-generated if omitted. - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - - Args: - sandbox: Sandbox ID or object to pause. - name: Optional snapshot name. Auto-generated if omitted. - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - Returns: - Snapshot: Created snapshot metadata. - - Returns: - Snapshot: Snapshot metadata including ID and optional name. - """ - payload = CreateSnapshotParams(name=name).to_payload() - data = cast(SnapshotCreateResponseDict, self._transport.request_json( - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/snapshot/create", - json=payload, - expected_status=201, - timeout=http_timeout, - )) - return Snapshot.from_dict(data) - - @intercept_errors("Failed to pause sandbox: ") - def pause( - self, - sandbox: SandboxRef, - *, - name: str | None = None, - http_timeout: float | None = None, - ) -> Snapshot: - """Pause a running sandbox and create a snapshot in one step. - - The sandbox is stopped after the snapshot is taken. - - Args: - sandbox: Sandbox ID or object. - name: Name used by this operation. - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - - Returns: - object: Result returned by this operation. - """ - payload = CreateSnapshotParams(name=name).to_payload() - data = cast(SnapshotCreateResponseDict, self._transport.request_json( - "POST", - f"/v1/sandbox/{sandbox_id_of(sandbox)}/snapshot/pause", - json=payload, - expected_status=201, - timeout=http_timeout, - )) - return Snapshot.from_dict(data) - - @intercept_errors("Failed to resume snapshot: ") - def resume( + @intercept_errors("Failed to restore snapshot: ") + def restore( self, *, snapshot_name: str, @@ -163,9 +96,9 @@ def resume( http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: - Sandbox: Newly resumed sandbox. + Sandbox: Newly restored sandbox. """ - payload = ResumeSnapshotParams( + payload = RestoreSnapshotParams( snapshot_name=snapshot_name, auto_pause=auto_pause, timeout=timeout, @@ -173,7 +106,7 @@ def resume( ).to_payload() data = cast(SandboxCreateResponseDict, self._transport.request_json( "POST", - "/v1/snapshot/resume", + "/v1/snapshot/restore", json=payload, expected_status=201, timeout=http_timeout, diff --git a/leap0/models/sandbox.py b/leap0/models/sandbox.py index c6faba1..97a7aa7 100644 --- a/leap0/models/sandbox.py +++ b/leap0/models/sandbox.py @@ -27,6 +27,34 @@ ) from .config import DEFAULT_MEMORY_MIB, DEFAULT_TEMPLATE_NAME, DEFAULT_TIMEOUT, DEFAULT_VCPU + +class CreateSnapshotParams(BaseModel): + """Validated snapshot creation parameters for sandbox snapshots.""" + model_config = ConfigDict(extra="forbid") + + name: str | None = None + kill_sandbox_after: bool = False + + @model_validator(mode="after") + def _validate_name(self) -> CreateSnapshotParams: + if self.name is not None: + name = self.name.strip() + if not name: + raise ValueError("name must be a non-empty string when provided") + if len(name) > 64: + raise ValueError("name must be at most 64 characters") + self.name = name + return self + + def to_payload(self) -> dict[str, str | bool]: + """Convert this object to an API request payload.""" + payload: dict[str, str | bool] = {} + if self.name is not None: + payload["name"] = self.name + if self.kill_sandbox_after: + payload["kill_sandbox_after"] = True + return payload + class SandboxState(str, Enum): """Lifecycle states for a sandbox.""" STARTING = "starting" diff --git a/leap0/models/snapshot.py b/leap0/models/snapshot.py index 3afb521..bd9dbd9 100644 --- a/leap0/models/snapshot.py +++ b/leap0/models/snapshot.py @@ -8,31 +8,8 @@ from .._schemas.snapshot import ListSnapshotsResponseDict, SnapshotCreateResponseDict from .sandbox import NetworkPolicyDict, NetworkPolicyMode, SandboxState, _parse_sandbox_state -class CreateSnapshotParams(BaseModel): - """Validated snapshot creation parameters.""" - model_config = ConfigDict(extra="forbid") - - name: str | None = None - - @model_validator(mode="after") - def _validate_name(self) -> CreateSnapshotParams: - if self.name is not None: - name = self.name.strip() - if not name: - raise ValueError("name must be a non-empty string when provided") - if len(name) > 64: - raise ValueError("name must be at most 64 characters") - self.name = name - return self - - def to_payload(self) -> dict[str, str]: - """Convert this object to an API request payload.""" - if self.name is None: - return {} - return {"name": self.name} - -class ResumeSnapshotParams(BaseModel): - """Validated snapshot resume parameters.""" +class RestoreSnapshotParams(BaseModel): + """Validated snapshot restore parameters.""" model_config = ConfigDict(extra="forbid") snapshot_name: str @@ -41,7 +18,7 @@ class ResumeSnapshotParams(BaseModel): network_policy: NetworkPolicyDict | None = None @model_validator(mode="after") - def _validate_values(self) -> ResumeSnapshotParams: + def _validate_values(self) -> RestoreSnapshotParams: snapshot_name = self.snapshot_name.strip() if not snapshot_name: raise ValueError("snapshot_name must be a non-empty string") @@ -59,8 +36,7 @@ def to_payload(self) -> dict[str, object]: return payload -CreateSnapshotParams.model_rebuild(_types_namespace={"NetworkPolicyMode": NetworkPolicyMode}) -ResumeSnapshotParams.model_rebuild(_types_namespace={"NetworkPolicyMode": NetworkPolicyMode}) +RestoreSnapshotParams.model_rebuild(_types_namespace={"NetworkPolicyMode": NetworkPolicyMode}) @dataclass(slots=True) class Snapshot: diff --git a/tests/_async/test_sandboxes.py b/tests/_async/test_sandboxes.py index 119b95c..5af36ff 100644 --- a/tests/_async/test_sandboxes.py +++ b/tests/_async/test_sandboxes.py @@ -61,6 +61,26 @@ async def run() -> None: asyncio.run(run()) + def test_create_snapshot(self, async_mock_transport): + async def run() -> None: + async_mock_transport.request_json.return_value = { + "id": "snap-1", "name": "snap-a", "template_id": "tpl-1", + "vcpu": 2, "memory": 1024, "disk": 4096, "created_at": "", + } + + result = await AsyncSandboxesClient(async_mock_transport, sandbox_domain="s.dev").create_snapshot( + "sbx-1", + name="snap-a", + kill_sandbox_after=True, + ) + + args, kwargs = async_mock_transport.request_json.call_args + assert args == ("POST", "/v1/sandbox/sbx-1/snapshot/create") + assert kwargs["json"] == {"name": "snap-a", "kill_sandbox_after": True} + assert result.id == "snap-1" + + asyncio.run(run()) + def test_factory_returns_async_sandbox(self, async_mock_transport): async def run() -> None: fake_client = SimpleNamespace( @@ -315,6 +335,29 @@ async def pause(sandbox: object, http_timeout: float | None = None): asyncio.run(run()) + def test_create_snapshot_delegates_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, + ) + calls: list[tuple[object, str | None, bool, float | None]] = [] + + async def create_snapshot(sandbox: object, *, name: str | None = None, kill_sandbox_after: bool = False, http_timeout: float | None = None): + calls.append((sandbox, name, kill_sandbox_after, http_timeout)) + return None + + sandboxes.create_snapshot = create_snapshot + sandbox = AsyncSandbox(fake_client, Sandbox(id="sbx-1", state="running")) + + await sandbox.create_snapshot(name="snap-a", kill_sandbox_after=True, http_timeout=1.5) + + assert calls == [(sandbox, "snap-a", True, 1.5)] + + asyncio.run(run()) + def test_runtime_info_helpers_delegate_to_sandboxes_client(self): async def run() -> None: sandboxes = SimpleNamespace() diff --git a/tests/_async/test_snapshots.py b/tests/_async/test_snapshots.py index 24f6f3f..5bed200 100644 --- a/tests/_async/test_snapshots.py +++ b/tests/_async/test_snapshots.py @@ -11,19 +11,6 @@ class TestAsyncSnapshotsClient: - def test_create(self, async_mock_transport): - async def run() -> None: - async_mock_transport.request_json.return_value = { - "id": "snap-1", "name": "s", "template_id": "t", - "vcpu": 1, "memory": 512, "disk": 10240, "network_policy": None, "created_at": "", - } - await AsyncSnapshotsClient(async_mock_transport).create("sbx-1", name="my-snap") - args, kwargs = async_mock_transport.request_json.call_args - assert args[1] == "/v1/sandbox/sbx-1/snapshot/create" - assert kwargs["json"]["name"] == "my-snap" - - asyncio.run(run()) - def test_delete_accepts_object(self, async_mock_transport): async def run() -> None: async_mock_transport.request.return_value = MagicMock(status_code=204) diff --git a/tests/_sync/test_sandboxes.py b/tests/_sync/test_sandboxes.py index b9d39fe..00e6ed3 100644 --- a/tests/_sync/test_sandboxes.py +++ b/tests/_sync/test_sandboxes.py @@ -53,6 +53,23 @@ def test_get(self, mock_transport): assert result.mounts is not None assert result.mounts[0].id == "mnt-1" + def test_create_snapshot(self, mock_transport): + mock_transport.request_json.return_value = { + "id": "snap-1", "name": "snap-a", "template_id": "tpl-1", + "vcpu": 2, "memory": 1024, "disk": 4096, "created_at": "", + } + + result = SandboxesClient(mock_transport, sandbox_domain="s.dev").create_snapshot( + "sbx-1", + name="snap-a", + kill_sandbox_after=True, + ) + + args, kwargs = mock_transport.request_json.call_args + assert args == ("POST", "/v1/sandbox/sbx-1/snapshot/create") + assert kwargs["json"] == {"name": "snap-a", "kill_sandbox_after": True} + assert result.id == "snap-1" + def test_list(self, mock_transport): mock_transport.request_json.return_value = { "items": [{ @@ -402,6 +419,30 @@ def test_pause_forwards_http_timeout(self): sandboxes.pause.assert_called_once_with(sandbox, http_timeout=7.5) assert sandbox.state == "paused" + def test_create_snapshot_delegates_to_sandboxes_client(self): + sandboxes = MagicMock() + 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")) + + sandbox.create_snapshot(name="snap-a", kill_sandbox_after=True, http_timeout=1.5) + + sandboxes.create_snapshot.assert_called_once_with( + sandbox, + name="snap-a", + kill_sandbox_after=True, + http_timeout=1.5, + ) + def test_presigned_url_helpers_delegate_to_sandboxes_client(self): sandboxes = MagicMock() client = SimpleNamespace( diff --git a/tests/_sync/test_snapshots.py b/tests/_sync/test_snapshots.py index 85e24b6..96dbe48 100644 --- a/tests/_sync/test_snapshots.py +++ b/tests/_sync/test_snapshots.py @@ -6,20 +6,10 @@ from leap0.models.errors import Leap0Error from leap0._sync.snapshots import SnapshotsClient -from leap0.models.snapshot import ResumeSnapshotParams, Snapshot +from leap0.models.snapshot import RestoreSnapshotParams, Snapshot class TestSnapshotsClient: - def test_create(self, mock_transport): - mock_transport.request_json.return_value = { - "id": "snap-1", "name": "s", "template_id": "t", - "vcpu": 1, "memory": 512, "disk": 10240, "network_policy": None, "created_at": "", - } - SnapshotsClient(mock_transport).create("sbx-1", name="my-snap") - args, kwargs = mock_transport.request_json.call_args - assert args[1] == "/v1/sandbox/sbx-1/snapshot/create" - assert kwargs["json"]["name"] == "my-snap" - def test_delete(self, mock_transport): mock_transport.request.return_value = MagicMock(status_code=204) SnapshotsClient(mock_transport).delete("snap-1") @@ -56,12 +46,12 @@ 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): + def test_restore_validates_input(self, mock_transport): with pytest.raises(Leap0Error, match="snapshot_name"): - SnapshotsClient(mock_transport).resume(snapshot_name=" ") + SnapshotsClient(mock_transport).restore(snapshot_name=" ") -class TestResumeSnapshotParams: +class TestRestoreSnapshotParams: def test_payload_trims_snapshot_name(self): - payload = ResumeSnapshotParams(snapshot_name=" snap-1 ").to_payload() + payload = RestoreSnapshotParams(snapshot_name=" snap-1 ").to_payload() assert payload["snapshot_name"] == "snap-1" diff --git a/tests/test_import.py b/tests/test_import.py index 0d61030..8213095 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -36,7 +36,7 @@ ProcessClient, PtyClient, RenameTemplateParams, - ResumeSnapshotParams, + RestoreSnapshotParams, Sandbox, SandboxListItem, SandboxListResponse, @@ -91,7 +91,7 @@ def test_service_client_imports() -> None: assert AzureRegistryCredentialsDict is not None assert CreateSandboxParams is not None assert CreateSnapshotParams is not None - assert ResumeSnapshotParams is not None + assert RestoreSnapshotParams is not None assert CreateTemplateParams is not None assert RenameTemplateParams is not None assert CreatePtySessionParams is not None From ba028c60782a8c47fc1865953da9c7139f2320dc Mon Sep 17 00:00:00 2001 From: steven-passynkov Date: Wed, 22 Apr 2026 17:07:45 -0400 Subject: [PATCH 2/2] fix snapshot restore docs and return typing --- leap0/_async/sandbox.py | 2 +- leap0/_async/snapshots.py | 4 ---- leap0/_sync/sandbox.py | 2 +- leap0/_sync/snapshots.py | 4 ---- leap0/models/snapshot.py | 4 +--- tests/_async/test_sandboxes.py | 6 ++++-- tests/_sync/test_sandboxes.py | 5 ++++- tests/_sync/test_snapshots.py | 21 +++++++++++++++++++++ 8 files changed, 32 insertions(+), 16 deletions(-) diff --git a/leap0/_async/sandbox.py b/leap0/_async/sandbox.py index b8e80e4..3226951 100644 --- a/leap0/_async/sandbox.py +++ b/leap0/_async/sandbox.py @@ -135,7 +135,7 @@ async def create_snapshot( name: str | None = None, kill_sandbox_after: bool = False, http_timeout: float | None = None, - ): + ) -> Snapshot: """Create a snapshot from this sandbox. Args: diff --git a/leap0/_async/snapshots.py b/leap0/_async/snapshots.py index 485cee6..0c5e56d 100644 --- a/leap0/_async/snapshots.py +++ b/leap0/_async/snapshots.py @@ -89,10 +89,6 @@ async def restore( auto_pause: Automatically pause the restored sandbox on timeout. timeout: Sandbox timeout in seconds. network_policy: Override the network policy from the snapshot. - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - - Args: http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: diff --git a/leap0/_sync/sandbox.py b/leap0/_sync/sandbox.py index 0c51100..af01466 100644 --- a/leap0/_sync/sandbox.py +++ b/leap0/_sync/sandbox.py @@ -132,7 +132,7 @@ def create_snapshot( name: str | None = None, kill_sandbox_after: bool = False, http_timeout: float | None = None, - ): + ) -> Snapshot: """Create a snapshot from this sandbox. Args: diff --git a/leap0/_sync/snapshots.py b/leap0/_sync/snapshots.py index c1e9579..2e7ed3c 100644 --- a/leap0/_sync/snapshots.py +++ b/leap0/_sync/snapshots.py @@ -89,10 +89,6 @@ def restore( auto_pause: Automatically pause the restored sandbox on timeout. timeout: Sandbox timeout in seconds. network_policy: Override the network policy from the snapshot. - - http_timeout: Optional HTTP request timeout in seconds for this SDK call. - - Args: http_timeout: Optional HTTP request timeout in seconds for this SDK call. Returns: diff --git a/leap0/models/snapshot.py b/leap0/models/snapshot.py index bd9dbd9..eab1bb1 100644 --- a/leap0/models/snapshot.py +++ b/leap0/models/snapshot.py @@ -31,9 +31,7 @@ def _validate_values(self) -> RestoreSnapshotParams: def to_payload(self) -> dict[str, object]: """Convert this object to an API request payload.""" - payload = self.model_dump(exclude_none=True) - payload["snapshot_name"] = self.snapshot_name - return payload + return self.model_dump(exclude_none=True) RestoreSnapshotParams.model_rebuild(_types_namespace={"NetworkPolicyMode": NetworkPolicyMode}) diff --git a/tests/_async/test_sandboxes.py b/tests/_async/test_sandboxes.py index 5af36ff..38e22ab 100644 --- a/tests/_async/test_sandboxes.py +++ b/tests/_async/test_sandboxes.py @@ -344,17 +344,19 @@ async def run() -> None: sandboxes=sandboxes, ) calls: list[tuple[object, str | None, bool, float | None]] = [] + sentinel = object() async def create_snapshot(sandbox: object, *, name: str | None = None, kill_sandbox_after: bool = False, http_timeout: float | None = None): calls.append((sandbox, name, kill_sandbox_after, http_timeout)) - return None + return sentinel sandboxes.create_snapshot = create_snapshot sandbox = AsyncSandbox(fake_client, Sandbox(id="sbx-1", state="running")) - await sandbox.create_snapshot(name="snap-a", kill_sandbox_after=True, http_timeout=1.5) + result = await sandbox.create_snapshot(name="snap-a", kill_sandbox_after=True, http_timeout=1.5) assert calls == [(sandbox, "snap-a", True, 1.5)] + assert result is sentinel asyncio.run(run()) diff --git a/tests/_sync/test_sandboxes.py b/tests/_sync/test_sandboxes.py index 00e6ed3..2716cb7 100644 --- a/tests/_sync/test_sandboxes.py +++ b/tests/_sync/test_sandboxes.py @@ -433,8 +433,10 @@ def test_create_snapshot_delegates_to_sandboxes_client(self): sandboxes=sandboxes, ) sandbox = RichSandbox(client, Sandbox(id="sbx-1", state="running")) + sentinel = object() + sandboxes.create_snapshot.return_value = sentinel - sandbox.create_snapshot(name="snap-a", kill_sandbox_after=True, http_timeout=1.5) + result = sandbox.create_snapshot(name="snap-a", kill_sandbox_after=True, http_timeout=1.5) sandboxes.create_snapshot.assert_called_once_with( sandbox, @@ -442,6 +444,7 @@ def test_create_snapshot_delegates_to_sandboxes_client(self): kill_sandbox_after=True, http_timeout=1.5, ) + assert result is sentinel def test_presigned_url_helpers_delegate_to_sandboxes_client(self): sandboxes = MagicMock() diff --git a/tests/_sync/test_snapshots.py b/tests/_sync/test_snapshots.py index 96dbe48..75cc62f 100644 --- a/tests/_sync/test_snapshots.py +++ b/tests/_sync/test_snapshots.py @@ -46,6 +46,27 @@ 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_restore(self, mock_transport): + mock_transport.request_json.return_value = { + "id": "sbx-1", + "template_id": "tpl-1", + "vcpu": 2, + "memory": 1024, + "disk": 4096, + "state": "running", + "created_at": "2026-01-01T00:00:00Z", + } + + result = SnapshotsClient(mock_transport).restore(snapshot_name="snap-1") + + args, kwargs = mock_transport.request_json.call_args + assert args == ("POST", "/v1/snapshot/restore") + assert kwargs["json"] == RestoreSnapshotParams(snapshot_name="snap-1").to_payload() + assert kwargs["expected_status"] == 201 + assert result.id == "sbx-1" + assert result.template_id == "tpl-1" + assert result.state == "running" + def test_restore_validates_input(self, mock_transport): with pytest.raises(Leap0Error, match="snapshot_name"): SnapshotsClient(mock_transport).restore(snapshot_name=" ")