Skip to content

Commit 87975c7

Browse files
committed
Add support for image= to sandbox.create(...)
1 parent a62cf84 commit 87975c7

7 files changed

Lines changed: 82 additions & 2 deletions

File tree

src/vercel/_internal/unstable/sandbox/api_client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
ValidationError,
2222
field_serializer,
2323
field_validator,
24+
model_validator,
2425
)
2526

2627
from vercel._internal.http import (
@@ -112,6 +113,7 @@ class _CreateSandboxRequest(_ApiRequestModel):
112113
project_id: str = Field(serialization_alias="projectId")
113114
name: str | None = None
114115
runtime: str | None = None
116+
image: str | None = None
115117
source: SandboxSource | None = None
116118
ports: list[int] | None = None
117119
timeout: timedelta | None = None
@@ -143,6 +145,12 @@ def _serialize_retention(self, value: SnapshotRetention | None) -> JSONObject |
143145
def _serialize_network_policy(self, value: NetworkPolicy | None) -> JSONObject | None:
144146
return None if value is None else _serialize_network_policy(value)
145147

148+
@model_validator(mode="after")
149+
def _validate_boot_selectors(self) -> "_CreateSandboxRequest":
150+
if self.runtime is not None and self.image is not None:
151+
raise ValueError("runtime and image are mutually exclusive")
152+
return self
153+
146154
@field_validator("network_policy", mode="before")
147155
@classmethod
148156
def _validate_network_policy(cls, value: object) -> NetworkPolicy | None:
@@ -911,6 +919,7 @@ async def create_sandbox(
911919
project_id: str | None = None,
912920
name: str | None = None,
913921
runtime: str | None = None,
922+
image: str | None = None,
914923
source: SandboxSource | None = None,
915924
ports: list[int] | None = None,
916925
execution_time_limit: timedelta | None = None,
@@ -927,6 +936,7 @@ async def create_sandbox(
927936
project_id=project_id or credentials.project_id,
928937
name=name,
929938
runtime=runtime,
939+
image=image,
930940
source=source,
931941
ports=ports,
932942
timeout=execution_time_limit,

src/vercel/_internal/unstable/sandbox/async_runtime.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,7 @@ class _CreateSandboxParams:
945945
project_id: str | None = None
946946
name: str | None = None
947947
runtime: str | None = None
948+
image: str | None = None
948949
source: SandboxSource | None = None
949950
ports: list[int] | None = None
950951
execution_time_limit: timedelta | None = None
@@ -991,6 +992,7 @@ async def _run_once(self) -> Sandbox:
991992
project_id=self._params.project_id,
992993
name=self._params.name,
993994
runtime=self._params.runtime,
995+
image=self._params.image,
994996
source=self._params.source,
995997
ports=self._params.ports,
996998
execution_time_limit=self._params.execution_time_limit,
@@ -1133,6 +1135,7 @@ def create_sandbox_operation(
11331135
project_id: str | None = None,
11341136
name: str | None = None,
11351137
runtime: str | None = None,
1138+
image: str | None = None,
11361139
source: SandboxSource | None = None,
11371140
ports: list[int] | None = None,
11381141
execution_time_limit: DurationInput = None,
@@ -1151,6 +1154,7 @@ def create_sandbox_operation(
11511154
project_id=project_id,
11521155
name=name,
11531156
runtime=runtime,
1157+
image=image,
11541158
source=source,
11551159
ports=ports,
11561160
execution_time_limit=parse_duration_seconds(execution_time_limit),

src/vercel/_internal/unstable/sandbox/service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ async def create_sandbox(
232232
project_id: str | None = None,
233233
name: str | None = None,
234234
runtime: str | None = None,
235+
image: str | None = None,
235236
source: SandboxSource | None = None,
236237
ports: list[int] | None = None,
237238
execution_time_limit: timedelta | None = None,
@@ -248,6 +249,7 @@ async def create_sandbox(
248249
project_id=project_id,
249250
name=name,
250251
runtime=runtime,
252+
image=image,
251253
source=source,
252254
ports=ports,
253255
execution_time_limit=execution_time_limit,

src/vercel/_internal/unstable/sandbox/sync_runtime.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,7 @@ def create_sandbox(
10501050
project_id: str | None = None,
10511051
name: str | None = None,
10521052
runtime: str | None = None,
1053+
image: str | None = None,
10531054
source: SandboxSource | None = None,
10541055
ports: list[int] | None = None,
10551056
execution_time_limit: DurationInput = None,
@@ -1068,6 +1069,7 @@ def create_sandbox(
10681069
project_id=project_id,
10691070
name=name,
10701071
runtime=runtime,
1072+
image=image,
10711073
source=source,
10721074
ports=ports,
10731075
execution_time_limit=parse_duration_seconds(execution_time_limit),

src/vercel/unstable/sandbox/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def create_sandbox(
7979
project_id: str | None = None,
8080
name: str | None = None,
8181
runtime: str | None = None,
82+
image: str | None = None,
8283
source: SandboxSource | None = None,
8384
ports: list[int] | None = None,
8485
execution_time_limit: DurationInput = None,
@@ -101,7 +102,9 @@ def create_sandbox(
101102
project_id: Project that owns the sandbox. Uses the active credentials
102103
when omitted.
103104
name: Requested sandbox name. The service generates one when omitted.
104-
runtime: Runtime image or runtime identifier.
105+
runtime: Runtime identifier. Mutually exclusive with ``image``.
106+
image: Vercel Container Registry image to start the sandbox from.
107+
Mutually exclusive with ``runtime``.
105108
source: Git, tarball, or snapshot source used to initialize the sandbox.
106109
ports: Ports to expose from the sandbox.
107110
execution_time_limit: Maximum session runtime in seconds or as a
@@ -129,6 +132,7 @@ def create_sandbox(
129132
project_id=project_id,
130133
name=name,
131134
runtime=runtime,
135+
image=image,
132136
source=source,
133137
ports=ports,
134138
execution_time_limit=execution_time_limit,

src/vercel/unstable/sandbox/sync.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def create_sandbox(
7676
project_id: str | None = None,
7777
name: str | None = None,
7878
runtime: str | None = None,
79+
image: str | None = None,
7980
source: SandboxSource | None = None,
8081
ports: list[int] | None = None,
8182
execution_time_limit: DurationInput = None,
@@ -98,7 +99,9 @@ def create_sandbox(
9899
project_id: Project that owns the sandbox. Uses the active credentials
99100
when omitted.
100101
name: Requested sandbox name. The service generates one when omitted.
101-
runtime: Runtime image or runtime identifier.
102+
runtime: Runtime identifier. Mutually exclusive with ``image``.
103+
image: Vercel Container Registry image to start the sandbox from.
104+
Mutually exclusive with ``runtime``.
102105
source: Git, tarball, or snapshot source used to initialize the sandbox.
103106
ports: Ports to expose from the sandbox.
104107
execution_time_limit: Maximum session runtime in seconds or as a
@@ -125,6 +128,7 @@ def create_sandbox(
125128
project_id=project_id,
126129
name=name,
127130
runtime=runtime,
131+
image=image,
128132
source=source,
129133
ports=ports,
130134
execution_time_limit=execution_time_limit,

tests/unstable/test_sandbox_public_flow.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,60 @@ def update_handler(request: httpx.Request) -> httpx.Response:
437437
assert not hasattr(handle, "model_dump")
438438

439439

440+
@respx.mock
441+
async def test_public_create_sandbox_serializes_image_without_runtime(
442+
mock_env_clear: None,
443+
) -> None:
444+
route = respx.post("https://sandbox.test/v2/sandboxes").mock(
445+
return_value=httpx.Response(200, json=_sandbox_response())
446+
)
447+
448+
async with vercel.session(service_options=_session_options()):
449+
await sandbox.create_sandbox(
450+
name="preview",
451+
image="acme/worker:latest",
452+
source=SnapshotSource(snapshot_id="snap_123"),
453+
)
454+
455+
body = json.loads(route.calls.last.request.content)
456+
assert body["image"] == "acme/worker:latest"
457+
assert body["source"] == {"type": "snapshot", "snapshotId": "snap_123"}
458+
assert "runtime" not in body
459+
460+
461+
@respx.mock
462+
def test_sync_create_sandbox_serializes_image_without_runtime(mock_env_clear: None) -> None:
463+
route = respx.post("https://sandbox.test/v2/sandboxes").mock(
464+
return_value=httpx.Response(200, json=_sandbox_response())
465+
)
466+
467+
with vercel.session(service_options=_session_options()):
468+
sandbox_sync.create_sandbox(name="preview", image="acme/worker:latest")
469+
470+
body = json.loads(route.calls.last.request.content)
471+
assert body["image"] == "acme/worker:latest"
472+
assert "runtime" not in body
473+
474+
475+
@respx.mock
476+
async def test_public_create_sandbox_rejects_runtime_and_image(
477+
mock_env_clear: None,
478+
) -> None:
479+
route = respx.post("https://sandbox.test/v2/sandboxes").mock(
480+
return_value=httpx.Response(200, json=_sandbox_response())
481+
)
482+
483+
async with vercel.session(service_options=_session_options()):
484+
with pytest.raises(ValidationError, match="runtime and image"):
485+
await sandbox.create_sandbox(
486+
name="preview",
487+
runtime="python3.13",
488+
image="acme/worker:latest",
489+
)
490+
491+
assert not route.called
492+
493+
440494
@respx.mock
441495
async def test_network_policy_async_public_flow(mock_env_clear: None) -> None:
442496
create_route = respx.post("https://sandbox.test/v2/sandboxes").mock(

0 commit comments

Comments
 (0)