feat(sandbox): add fork() and snapshot() helpers to SandboxInstance - #201
Conversation
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
🧪 Testing GuideWhat this PR addressesAdds public Steps to exercise the new behavior
What to verify (expected behavior)
Note Posted by PR Testing Guide · Tag @mendral-app with feedback. |
🔀 Interaction Flow DiagramHere's how the new sequenceDiagram
participant User as User Code
participant Async as SandboxInstance (async)
participant Sync as SyncSandboxInstance
participant Helper as _unwrap_response()
participant API as Generated Client API
participant CP as Control Plane
Note over User,CP: Snapshot Flow
User->>Async: await sbx.snapshot("before-migration")
Async->>API: create_sandbox_snapshot(name, SandboxSnapshotRequest)
API->>CP: POST /sandboxes/{name}/snapshots
CP-->>API: Response (SandboxSnapshot | ErrorResponse)
API-->>Async: raw response
Async->>Helper: _unwrap_response(response, SandboxSnapshot)
Helper-->>Async: SandboxSnapshot (unwrapped)
Async-->>User: SandboxSnapshot
Note over User,CP: List Snapshots Flow
User->>Async: await sbx.list_snapshots()
Async->>API: list_sandbox_snapshots(name)
API->>CP: GET /sandboxes/{name}/snapshots
CP-->>API: Response
API-->>Async: List[SandboxSnapshot]
Async-->>User: List[SandboxSnapshot]
Note over User,CP: Delete Snapshot Flow
User->>Async: await sbx.delete_snapshot("snap_abc")
Async->>API: delete_sandbox_snapshot(name, snapshot_id)
API->>CP: DELETE /sandboxes/{name}/snapshots/{id}
CP-->>API: Response (None on 204 | ErrorResponse)
API-->>Async: raw response
Async->>Helper: _unwrap_response(response, None)
Helper-->>Async: None
Async-->>User: None
Note over User,CP: Fork Flow
User->>Async: await sbx.fork(target="sandbox")
Async->>API: fork_sandbox(name, SandboxForkRequest)
API->>CP: POST /sandboxes/{name}/fork
CP-->>API: Response (SandboxForkResponse | ErrorResponse)
API-->>Async: raw response
Async->>Helper: _unwrap_response(response, SandboxForkResponse)
Helper-->>Async: SandboxForkResponse
Async-->>User: SandboxForkResponse
Note over User,Sync: Sync Wrappers
User->>Sync: sbx.snapshot() / fork() / etc.
Sync->>Async: Delegates (same logic, no await)
Sync->>Helper: _unwrap_response (imported from default module)
Sync-->>User: Same return types
SummaryThis PR adds four public methods (
Note Posted by PR Sequence Diagram · Tag @mendral-app with feedback. |
|
📋 Created Linear issue ENG-4160 — status: In Progress
Auto-created because no Linear reference was found in the PR title, description, or branch name. Note Posted by Linear Issue Enforcer · Tag @mendral-app with feedback. |
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
There was a problem hiding this comment.
LGTM
All three previous issues (the delete_snapshot 204-handling bug) are fixed in 812a705. The _unwrap_response helper now accepts allow_none=True, both variants pass it for delete, and a dedicated test verifies the behavior. No new issues found.
Tag @mendral-app with feedback or questions. View session
| @pytest.mark.asyncio | ||
| async def test_fork_defaults_to_sandbox_target(): | ||
| sandbox = sandbox_instance("my-sandbox") | ||
|
|
||
| with patch( | ||
| "blaxel.core.sandbox.default.sandbox.fork_sandbox", new_callable=AsyncMock | ||
| ) as mock_fork: | ||
| mock_fork.return_value = MagicMock(name="my-sandbox-copy", type="sandbox") | ||
|
|
||
| await sandbox.fork("my-sandbox-copy") | ||
|
|
||
| assert mock_fork.call_args.args[0] == "my-sandbox" | ||
| body = mock_fork.call_args.kwargs["body"] | ||
| assert body.target_name == "my-sandbox-copy" | ||
| assert body.target_type == "sandbox" | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_fork_forwards_application_options_and_snapshot(): | ||
| sandbox = sandbox_instance("my-sandbox") | ||
|
|
||
| with patch( | ||
| "blaxel.core.sandbox.default.sandbox.fork_sandbox", new_callable=AsyncMock | ||
| ) as mock_fork: | ||
| mock_fork.return_value = MagicMock() | ||
|
|
||
| await sandbox.fork( | ||
| "my-app", | ||
| target_type="application", | ||
| traffic=100, | ||
| port=8080, | ||
| custom_domain="app.example.com", | ||
| snapshot_id="snap_abc123", | ||
| ) | ||
|
|
||
| body = mock_fork.call_args.kwargs["body"] | ||
| assert body.target_name == "my-app" | ||
| assert body.target_type == "application" | ||
| assert body.traffic == 100 | ||
| assert body.port == 8080 | ||
| assert body.custom_domain == "app.example.com" | ||
| assert body.snapshot_id == "snap_abc123" | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_snapshot_sends_optional_name(): | ||
| sandbox = sandbox_instance("my-sandbox") | ||
|
|
||
| with patch( | ||
| "blaxel.core.sandbox.default.sandbox.create_sandbox_snapshot", new_callable=AsyncMock | ||
| ) as mock_snapshot: | ||
| mock_snapshot.return_value = MagicMock() | ||
|
|
||
| await sandbox.snapshot("before") | ||
|
|
||
| assert mock_snapshot.call_args.args[0] == "my-sandbox" | ||
| assert mock_snapshot.call_args.kwargs["body"].name == "before" | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_delete_snapshot_accepts_none_204_response(): | ||
| sandbox = sandbox_instance("my-sandbox") | ||
|
|
||
| with patch( | ||
| "blaxel.core.sandbox.default.sandbox.delete_sandbox_snapshot", new_callable=AsyncMock | ||
| ) as mock_delete: | ||
| # Generated client returns None for a successful 204 No Content. | ||
| mock_delete.return_value = None | ||
|
|
||
| await sandbox.delete_snapshot("snap_abc123") | ||
|
|
||
| assert mock_delete.call_args.args == ("my-sandbox", "snap_abc123") | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_fork_raises_on_error_response(): | ||
| from blaxel.core.client.models.error import Error | ||
|
|
||
| sandbox = sandbox_instance("my-sandbox") | ||
|
|
||
| with patch( | ||
| "blaxel.core.sandbox.default.sandbox.fork_sandbox", new_callable=AsyncMock | ||
| ) as mock_fork: | ||
| mock_fork.return_value = Error(error="boom", code=400) | ||
|
|
||
| with pytest.raises(SandboxAPIError): | ||
| await sandbox.fork("my-sandbox-copy") | ||
|
|
||
|
|
||
| def test_sync_fork_and_snapshot_helpers(): | ||
| sandbox = sandbox_instance("my-sandbox", cls=SyncSandboxInstance) | ||
|
|
||
| with ( | ||
| patch("blaxel.core.sandbox.sync.sandbox.fork_sandbox") as mock_fork, | ||
| patch("blaxel.core.sandbox.sync.sandbox.create_sandbox_snapshot") as mock_snapshot, | ||
| ): | ||
| mock_fork.return_value = MagicMock() | ||
| mock_snapshot.return_value = MagicMock() | ||
|
|
||
| sandbox.fork("my-sandbox-copy", snapshot_id="snap_abc123") | ||
| sandbox.snapshot("before") | ||
|
|
||
| fork_body = mock_fork.call_args.kwargs["body"] | ||
| assert fork_body.target_name == "my-sandbox-copy" | ||
| assert fork_body.target_type == "sandbox" | ||
| assert fork_body.snapshot_id == "snap_abc123" | ||
| assert mock_snapshot.call_args.kwargs["body"].name == "before" |
There was a problem hiding this comment.
🟡 New sandbox fork/snapshot feature ships without required integration tests
The new fork/snapshot capability is added with only mocked unit tests (tests/core/test_sandbox.py:839-945) and no integration test exercising it against the real API, which AGENTS.md requires for every feature.
Impact: The feature is merged without any real-API test coverage, so real endpoint regressions can go undetected.
Why this violates the repository rule
AGENTS.md states under Testing: "Every feature must ship with an integration test exercising it against the real API". Existing sandbox features each have an integration test under tests/integration/core/sandbox/ (e.g. test_sandbox_crud.py, test_lifecycle.py). The new fork(), snapshot(), list_snapshots(), and delete_snapshot() methods added in src/blaxel/core/sandbox/default/sandbox.py and src/blaxel/core/sandbox/sync/sandbox.py only receive mocked unit tests in tests/core/test_sandbox.py; a grep of tests/integration/core/sandbox/ finds no fork/snapshot integration test (the only matches concern the unrelated snapshot_enabled config).
Prompt for agents
AGENTS.md requires that every feature ship with an integration test exercising it against the real API, and existing sandbox features each have one under tests/integration/core/sandbox/. This PR adds fork()/snapshot()/list_snapshots()/delete_snapshot() to both SandboxInstance and SyncSandboxInstance but only adds mocked unit tests in tests/core/test_sandbox.py. Add an integration test (e.g. tests/integration/core/sandbox/test_fork_snapshot.py) that creates a sandbox, snapshots it, lists snapshots, forks it, and deletes the snapshot against the real API, using helpers from tests/helpers.py (unique_name, default_labels, default_image) and cleaning up resources in a class-level cleanup fixture. If the endpoint is WIP/slow, gate it behind a dedicated environment variable as described in AGENTS.md so it is skipped by default.
Was this helpful? React with 👍 or 👎 to provide feedback.
…G-4244) (#206) * fix(sandbox): propagate control-plane errors from update_*/delete (ENG-4244) Generated control-plane API functions return `Union[Error, T] | None`, so an error status is a return value rather than an exception. The sandbox update_* and delete helpers passed that response straight into `cls(response)`, which does not type-check, so a 403/404/500 produced an instance wrapping an `Error`. Callers that ignore the return value -- the common shape -- saw a failed write as a success: the TTL was never applied, or the sandbox was never deleted and kept billing. Callers that used the result hit `AttributeError: 'Error' object has no attribute 'metadata'` far from the real cause. Route the responses through `_unwrap_response()`, already present in the same module and already imported by the sync tree; it was added in #201 for the fork/snapshot helpers and never applied to the update/delete paths. Volume delete had the identical gap and uses that file's existing guard shape. Reported by Mathis Joffre. TypeScript (`throwOnError: true`) and Go are already correct; Python was the only affected SDK. Note: delete on a 404 now raises `SandboxAPIError` instead of silently returning an `Error`, and the empty case raises `SandboxAPIError` instead of `ValueError`. * fix(volume): raise on empty delete responses too Mendral review on #206: the volume delete helpers guarded Error but not None, while the sandbox path routes through _unwrap_response, which raises on both. delete_volume returns Union[Error, Volume] | None, so a None response returned silently despite the -> Volume annotation -- the same silent-failure class this PR removes for sandboxes. * test(lifecycle): stop asserting a lifecycle change that never applied test_update_lifecycle_with_different_policy_types_preserves_files built a lifecycle with ttl-idle listed twice. The control plane rejects duplicate policy types with a 400, and update_lifecycle used to swallow that 400, so the update silently never applied. The test then asserted the file survived a change that had not happened, and passed for the wrong reason. Now that update_lifecycle raises, the 400 surfaces and the copy-paste duplicate is visible. Drop the duplicate entry so the test exercises the policy-type change it claims to.
Fixes ENG-4160
Summary
Python counterpart to the sdk-typescript helpers. Adds public
fork()/snapshot()/list_snapshots()/delete_snapshot()methods to both the asyncSandboxInstanceand theSyncSandboxInstance, wrapping the generatedfork_sandbox/*_sandbox_snapshotAPI functions (generatedclient/apiandclient/modelsuntouched).The generated
asyncio/syncfunctions returnUnion[Error, T] | None; a shared_unwrap_response()helper raisesSandboxAPIErroron anError/Noneresult and otherwise returns the payload. Optional fork fields (port,traffic,custom_domain,prefix,snapshot_id) are only set onSandboxForkRequestwhen provided.Adds unit tests in
tests/core/test_sandbox.py(mock the generated functions, assert request bodies and error handling) for both async and sync paths.Companion PRs: sdk-typescript#396 and docs update (blaxel-ai/docs#624).
Link to Devin session: https://app.devin.ai/sessions/13e4e4ca02fe48a4ab4101a997e085a9
Requested by: @drappier-charles
Note
Adds
fork(),snapshot(),list_snapshots(), anddelete_snapshot()methods to both async and sync sandbox instances, with a shared_unwrap_response()helper that now correctly handles 204 No Content viaallow_none=True.Written by Mendral for commit 812a705.