From c6630ee81f3c65d77039b036094e268d135a30a3 Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Wed, 28 Oct 2026 10:25:12 -0500 Subject: [PATCH 1/6] Add sandbox volume support --- baseten/client/managementapi/__init__.py | 26 + baseten/client/managementapi/_client.py | 193 ++++++ baseten/client/managementapi/_models.py | 322 ++++++++- baseten/sandbox/__init__.py | 14 + baseten/sandbox/_client.py | 31 + baseten/sandbox/_images.py | 101 ++- baseten/sandbox/_info.py | 39 ++ baseten/sandbox/_volumes.py | 384 ++++++++++ scripts/apigen/specs/management.json | 846 ++++++++++++++++++++++- tests/sandbox/test_client.py | 26 + tests/sandbox/test_e2e.py | 193 +++++- tests/sandbox/test_images.py | 39 +- tests/sandbox/test_parity.py | 40 ++ tests/sandbox/test_volumes.py | 213 ++++++ uv.lock | 54 +- 15 files changed, 2444 insertions(+), 77 deletions(-) create mode 100644 baseten/sandbox/_volumes.py create mode 100644 tests/sandbox/test_volumes.py diff --git a/baseten/client/managementapi/__init__.py b/baseten/client/managementapi/__init__.py index c59fce7..643f009 100644 --- a/baseten/client/managementapi/__init__.py +++ b/baseten/client/managementapi/__init__.py @@ -69,6 +69,9 @@ AuditLogEventSecretDeleted, AuditLogEventSecretUpdated, AuditLogEventSshCertificateSigned, + AuditLogEventTelemetryExportCreated, + AuditLogEventTelemetryExportDeleted, + AuditLogEventTelemetryExportUpdated, AuditLogEventUserInvited, AuditLogEventUserJoinedOrganization, AuditLogEventUserRemoved, @@ -157,6 +160,8 @@ CreateSandboxPreviewTokenParams, CreateSandboxPreviewTokenRequest, CreateSandboxRequest, + CreateSandboxVolumeParams, + CreateSandboxVolumeRequest, CreateTokenRequest, CreateTrainingJob, CreateTrainingJobAccelerator, @@ -186,6 +191,7 @@ DeleteSandboxParams, DeleteSandboxPreviewParams, DeleteSandboxPreviewTokenParams, + DeleteSandboxVolumeParams, DeleteVolumeRequest, DeleteVolumeResponse, DeleteVolumeTagRequest, @@ -313,6 +319,7 @@ GetSandboxMetricsParams, GetSandboxParams, GetSandboxPreviewParams, + GetSandboxVolumeParams, GetTeamsLoopsRunsParams, GetTeamsLoopsSamplersParams, GetTeamsModelsParams, @@ -404,6 +411,8 @@ ListSandboxPreviewsResponse, ListSandboxPreviewTokensParams, ListSandboxPreviewTokensResponse, + ListSandboxVolumesParams, + ListSandboxVolumesResponse, ListTrainingJobsResponse, ListTrainingProjectsResponse, ListVolumeNamespacesResponse, @@ -532,6 +541,8 @@ SandboxRegion, SandboxTTLIdleExpirationPolicy, SandboxTTLMaxAgeExpirationPolicy, + SandboxVolume, + SandboxVolumeAttachment, SearchPattern, SearchTrainingJobsRequest, SearchTrainingJobsResponse, @@ -636,6 +647,8 @@ UpdateSandboxPreviewParams, UpdateSandboxPreviewRequest, UpdateSandboxRequest, + UpdateSandboxVolumeParams, + UpdateSandboxVolumeRequest, UpdateTrafficRoutingSettings, UpdateTrainingJobRequest, UpdateTrainingJobResponse, @@ -740,6 +753,9 @@ "AuditLogEventSecretDeleted", "AuditLogEventSecretUpdated", "AuditLogEventSshCertificateSigned", + "AuditLogEventTelemetryExportCreated", + "AuditLogEventTelemetryExportDeleted", + "AuditLogEventTelemetryExportUpdated", "AuditLogEventUserInvited", "AuditLogEventUserJoinedOrganization", "AuditLogEventUserRemoved", @@ -826,6 +842,8 @@ "CreateSandboxPreviewTokenParams", "CreateSandboxPreviewTokenRequest", "CreateSandboxRequest", + "CreateSandboxVolumeParams", + "CreateSandboxVolumeRequest", "CreateTokenRequest", "CreateTrainingJob", "CreateTrainingJobAccelerator", @@ -856,6 +874,7 @@ "DeleteSandboxParams", "DeleteSandboxPreviewParams", "DeleteSandboxPreviewTokenParams", + "DeleteSandboxVolumeParams", "DeleteVolumeRequest", "DeleteVolumeResponse", "DeleteVolumeTagRequest", @@ -983,6 +1002,7 @@ "GetSandboxMetricsParams", "GetSandboxParams", "GetSandboxPreviewParams", + "GetSandboxVolumeParams", "GetTeamsLoopsRunsParams", "GetTeamsLoopsSamplersParams", "GetTeamsModelsParams", @@ -1075,6 +1095,8 @@ "ListSandboxPreviewTokensResponse", "ListSandboxPreviewsParams", "ListSandboxPreviewsResponse", + "ListSandboxVolumesParams", + "ListSandboxVolumesResponse", "ListSandboxesParams", "ListSandboxesResponse", "ListTrainingJobsResponse", @@ -1203,6 +1225,8 @@ "SandboxRegion", "SandboxTTLIdleExpirationPolicy", "SandboxTTLMaxAgeExpirationPolicy", + "SandboxVolume", + "SandboxVolumeAttachment", "SearchPattern", "SearchTrainingJobsRequest", "SearchTrainingJobsResponse", @@ -1307,6 +1331,8 @@ "UpdateSandboxPreviewParams", "UpdateSandboxPreviewRequest", "UpdateSandboxRequest", + "UpdateSandboxVolumeParams", + "UpdateSandboxVolumeRequest", "UpdateTrafficRoutingSettings", "UpdateTrainingJobRequest", "UpdateTrainingJobResponse", diff --git a/baseten/client/managementapi/_client.py b/baseten/client/managementapi/_client.py index fd8d245..1c5ec94 100644 --- a/baseten/client/managementapi/_client.py +++ b/baseten/client/managementapi/_client.py @@ -55,6 +55,8 @@ CreateSandboxPreviewTokenParams, CreateSandboxPreviewTokenRequest, CreateSandboxRequest, + CreateSandboxVolumeParams, + CreateSandboxVolumeRequest, CreateTokenRequest, CreateTrainingJobRequest, CreateTrainingJobResponse, @@ -70,6 +72,7 @@ DeleteSandboxParams, DeleteSandboxPreviewParams, DeleteSandboxPreviewTokenParams, + DeleteSandboxVolumeParams, DeleteVolumeRequest, DeleteVolumeResponse, DeleteVolumeTagRequest, @@ -155,6 +158,7 @@ GetSandboxMetricsParams, GetSandboxParams, GetSandboxPreviewParams, + GetSandboxVolumeParams, GetTeamsLoopsRunsParams, GetTeamsLoopsSamplersParams, GetTeamsModelsParams, @@ -215,6 +219,8 @@ ListSandboxPreviewsResponse, ListSandboxPreviewTokensParams, ListSandboxPreviewTokensResponse, + ListSandboxVolumesParams, + ListSandboxVolumesResponse, ListTrainingJobsResponse, ListTrainingProjectsResponse, ListVolumeNamespacesResponse, @@ -263,6 +269,7 @@ SandboxMetrics, SandboxPreview, SandboxPreviewToken, + SandboxVolume, SearchTrainingJobsRequest, SearchTrainingJobsResponse, Secret, @@ -311,6 +318,8 @@ UpdateSandboxPreviewParams, UpdateSandboxPreviewRequest, UpdateSandboxRequest, + UpdateSandboxVolumeParams, + UpdateSandboxVolumeRequest, UpdateTrainingJobRequest, UpdateTrainingJobResponse, UpsertSecretRequest, @@ -468,6 +477,26 @@ def create_sandbox_preview_token( ), ) + def create_sandbox_volume( + self, + *, + params: CreateSandboxVolumeParams | None = None, + request: CreateSandboxVolumeRequest, + ) -> SandboxVolume: + """Create a volume""" + return self._do_json( + SandboxVolume, + _ApiRequest( + method="POST", + path_fmt="/v1/sandboxes/volumes", + path_args=[], + body=request, + query=params, + success_codes=[201], + error_codes=None, + ), + ) + def delete_api_keys(self, *, api_key_prefix: str) -> APIKeyTombstone: """Deletes an API key by prefix""" return self._do_json( @@ -757,6 +786,23 @@ def delete_sandbox_preview_token( ) ) + def delete_sandbox_volume( + self, *, volume_name: str, params: DeleteSandboxVolumeParams | None = None + ) -> SandboxVolume: + """Delete a volume""" + return self._do_json( + SandboxVolume, + _ApiRequest( + method="DELETE", + path_fmt="/v1/sandboxes/volumes/{}", + path_args=[volume_name], + body=None, + query=params, + success_codes=[202], + error_codes=None, + ), + ) + def delete_secrets(self, *, secret_name: str) -> SecretTombstone: """Deletes a secret by name""" return self._do_json( @@ -2340,6 +2386,23 @@ def get_sandbox_preview( ), ) + def get_sandbox_volume( + self, *, volume_name: str, params: GetSandboxVolumeParams | None = None + ) -> SandboxVolume: + """Get a volume""" + return self._do_json( + SandboxVolume, + _ApiRequest( + method="GET", + path_fmt="/v1/sandboxes/volumes/{}", + path_args=[volume_name], + body=None, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + def get_secrets(self) -> Secrets: """Gets all secrets (metadata only, no plain text keys)""" return self._do_json( @@ -2978,6 +3041,23 @@ def list_sandbox_previews( ), ) + def list_sandbox_volumes( + self, *, params: ListSandboxVolumesParams | None = None + ) -> ListSandboxVolumesResponse: + """List volumes""" + return self._do_json( + ListSandboxVolumesResponse, + _ApiRequest( + method="GET", + path_fmt="/v1/sandboxes/volumes", + path_args=[], + body=None, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + def list_sandboxes( self, *, params: ListSandboxesParams | None = None ) -> ListSandboxesResponse: @@ -4784,6 +4864,27 @@ def update_sandbox_preview( ), ) + def update_sandbox_volume( + self, + *, + volume_name: str, + params: UpdateSandboxVolumeParams | None = None, + request: UpdateSandboxVolumeRequest, + ) -> SandboxVolume: + """Update a volume""" + return self._do_json( + SandboxVolume, + _ApiRequest( + method="PATCH", + path_fmt="/v1/sandboxes/volumes/{}", + path_args=[volume_name], + body=request, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + def _build_request(self, request: _ApiRequest) -> httpx.Request: path = request.path_fmt.format( *[urllib.parse.quote(a, safe="") for a in request.path_args] @@ -4974,6 +5075,26 @@ async def create_sandbox_preview_token( ), ) + async def create_sandbox_volume( + self, + *, + params: CreateSandboxVolumeParams | None = None, + request: CreateSandboxVolumeRequest, + ) -> SandboxVolume: + """Create a volume""" + return await self._do_json( + SandboxVolume, + _ApiRequest( + method="POST", + path_fmt="/v1/sandboxes/volumes", + path_args=[], + body=request, + query=params, + success_codes=[201], + error_codes=None, + ), + ) + async def delete_api_keys(self, *, api_key_prefix: str) -> APIKeyTombstone: """Deletes an API key by prefix""" return await self._do_json( @@ -5265,6 +5386,23 @@ async def delete_sandbox_preview_token( ) ) + async def delete_sandbox_volume( + self, *, volume_name: str, params: DeleteSandboxVolumeParams | None = None + ) -> SandboxVolume: + """Delete a volume""" + return await self._do_json( + SandboxVolume, + _ApiRequest( + method="DELETE", + path_fmt="/v1/sandboxes/volumes/{}", + path_args=[volume_name], + body=None, + query=params, + success_codes=[202], + error_codes=None, + ), + ) + async def delete_secrets(self, *, secret_name: str) -> SecretTombstone: """Deletes a secret by name""" return await self._do_json( @@ -6852,6 +6990,23 @@ async def get_sandbox_preview( ), ) + async def get_sandbox_volume( + self, *, volume_name: str, params: GetSandboxVolumeParams | None = None + ) -> SandboxVolume: + """Get a volume""" + return await self._do_json( + SandboxVolume, + _ApiRequest( + method="GET", + path_fmt="/v1/sandboxes/volumes/{}", + path_args=[volume_name], + body=None, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + async def get_secrets(self) -> Secrets: """Gets all secrets (metadata only, no plain text keys)""" return await self._do_json( @@ -7492,6 +7647,23 @@ async def list_sandbox_previews( ), ) + async def list_sandbox_volumes( + self, *, params: ListSandboxVolumesParams | None = None + ) -> ListSandboxVolumesResponse: + """List volumes""" + return await self._do_json( + ListSandboxVolumesResponse, + _ApiRequest( + method="GET", + path_fmt="/v1/sandboxes/volumes", + path_args=[], + body=None, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + async def list_sandboxes( self, *, params: ListSandboxesParams | None = None ) -> ListSandboxesResponse: @@ -9312,6 +9484,27 @@ async def update_sandbox_preview( ), ) + async def update_sandbox_volume( + self, + *, + volume_name: str, + params: UpdateSandboxVolumeParams | None = None, + request: UpdateSandboxVolumeRequest, + ) -> SandboxVolume: + """Update a volume""" + return await self._do_json( + SandboxVolume, + _ApiRequest( + method="PATCH", + path_fmt="/v1/sandboxes/volumes/{}", + path_args=[volume_name], + body=request, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + def _build_request(self, request: _ApiRequest) -> httpx.Request: path = request.path_fmt.format( *[urllib.parse.quote(a, safe="") for a in request.path_args] diff --git a/baseten/client/managementapi/_models.py b/baseten/client/managementapi/_models.py index 4a55190..376c4a2 100644 --- a/baseten/client/managementapi/_models.py +++ b/baseten/client/managementapi/_models.py @@ -1241,6 +1241,46 @@ class DeleteSandboxPreviewTokenParams(BaseModel): ] = None +class CreateSandboxVolumeParams(BaseModel): + team_id: Annotated[ + str | None, + Field( + description="Optional team ID. Must match X-Team-Id when both are supplied. If neither selector is supplied, defaults to the caller's only accessible team. Callers with multiple accessible teams must select a team. Requests without access to any team are forbidden.", + min_length=1, + ), + ] = None + + +class GetSandboxVolumeParams(BaseModel): + team_id: Annotated[ + str | None, + Field( + description="Optional team ID. Must match X-Team-Id when both are supplied. If neither selector is supplied, defaults to the caller's only accessible team. Callers with multiple accessible teams must select a team. Requests without access to any team are forbidden.", + min_length=1, + ), + ] = None + + +class UpdateSandboxVolumeParams(BaseModel): + team_id: Annotated[ + str | None, + Field( + description="Optional team ID. Must match X-Team-Id when both are supplied. If neither selector is supplied, defaults to the caller's only accessible team. Callers with multiple accessible teams must select a team. Requests without access to any team are forbidden.", + min_length=1, + ), + ] = None + + +class DeleteSandboxVolumeParams(BaseModel): + team_id: Annotated[ + str | None, + Field( + description="Optional team ID. Must match X-Team-Id when both are supplied. If neither selector is supplied, defaults to the caller's only accessible team. Callers with multiple accessible teams must select a team. Requests without access to any team are forbidden.", + min_length=1, + ), + ] = None + + class ListImagesParams(BaseModel): team_id: Annotated[ str | None, @@ -3221,6 +3261,7 @@ class AuditLogEventApiKeyDeleted(BaseModel): ), ] prefix: Annotated[str, Field(title="Prefix")] + revocation_reason: Literal["INACTIVITY"] | str | None = None class AuditLogEventAutoscalingScheduleSettings(BaseModel): @@ -3749,6 +3790,35 @@ class AuditLogEventSshCertificateSigned(BaseModel): expires_at: Annotated[str, Field(title="Expires At")] +class AuditLogEventTelemetryExportCreated(BaseModel): + telemetry_export_configuration_id: Annotated[ + str, Field(title="Telemetry Export Configuration Id") + ] + event_type: Annotated[ + Literal["TELEMETRY_EXPORT_CREATED"], Field(title="Event Type") + ] + + +class AuditLogEventTelemetryExportDeleted(BaseModel): + telemetry_export_configuration_id: Annotated[ + str, Field(title="Telemetry Export Configuration Id") + ] + event_type: Annotated[ + Literal["TELEMETRY_EXPORT_DELETED"], Field(title="Event Type") + ] + + +class AuditLogEventTelemetryExportUpdated(BaseModel): + telemetry_export_configuration_id: Annotated[ + str, Field(title="Telemetry Export Configuration Id") + ] + event_type: Annotated[ + Literal["TELEMETRY_EXPORT_UPDATED"], Field(title="Event Type") + ] + credential_rotated: Annotated[bool, Field(title="Credential Rotated")] + enabled: Annotated[bool | None, Field(title="Enabled")] + + class AuditLogEventUserInvited(BaseModel): event_type: Annotated[Literal["USER_INVITED"], Field(title="Event Type")] invited_user_email: Annotated[str, Field(title="Invited User Email")] @@ -6248,6 +6318,13 @@ class LoopsSampler(BaseModel): int, Field(description="Number of nodes serving the sampler.", title="Node Count"), ] = 1 + base_checkpoint: Annotated[ + str | None, + Field( + description="The custom base checkpoint the sampler serves, pinned to a commit (hf:///@). Null when it uses the base model's own weights.", + title="Base Checkpoint", + ), + ] = None class Name3(RootModel[str]): @@ -6352,6 +6429,13 @@ class CreateLoopsRunRequest(BaseModel): title="Reuse From Session Id", ), ] = None + base_checkpoint: Annotated[ + str | None, + Field( + description="Optional Hugging Face checkpoint to start training from instead of the base model's weights. Form: hf:///[@]. It must be a complete, merged checkpoint in safetensors format with the same architecture as base_model, which still selects the hardware and training configuration. It must include its tokenizer files: the run's sampler and the SDK load the tokenizer and chat template from the checkpoint. A private or gated repository needs a Hugging Face token in the team secret hf_access_token. The revision is pinned to a commit when the run is created. Cannot be combined with path, reuse_from_run_id, or reuse_from_session_id.", + title="Base Checkpoint", + ), + ] = None sampler_num_replicas: Annotated[ SamplerNumReplicas | None, Field( @@ -6452,6 +6536,13 @@ class CreateLoopsTrainerRequest(BaseModel): title="Reuse From Session Id", ), ] = None + base_checkpoint: Annotated[ + str | None, + Field( + description="Optional Hugging Face checkpoint to start training from instead of the base model's weights. Form: hf:///[@]. It must be a complete, merged checkpoint in safetensors format with the same architecture as base_model, which still selects the hardware and training configuration. It must include its tokenizer files: the run's sampler and the SDK load the tokenizer and chat template from the checkpoint. A private or gated repository needs a Hugging Face token in the team secret hf_access_token. The revision is pinned to a commit when the run is created. Cannot be combined with path, reuse_from_run_id, or reuse_from_session_id.", + title="Base Checkpoint", + ), + ] = None class ListLoopsSamplersResponse(BaseModel): @@ -6741,6 +6832,13 @@ class LoopsDeployment(BaseModel): title="Base Model", ), ] + base_checkpoint: Annotated[ + str | None, + Field( + description="The custom base checkpoint the deployment's trainer loaded, pinned to a commit (hf:///@). Null when it uses the base model's own weights.", + title="Base Checkpoint", + ), + ] = None base_url: Annotated[str, Field(description="The run's base URL.", title="Base Url")] status: Annotated[ LoopsDeploymentStatus, Field(description="Latest deployment status.") @@ -9954,7 +10052,7 @@ class SandboxLibraryImageVolume(BaseModel): description="Volume type, persistent when empty.", examples=["ephemeral"] ), ] = None - size_mb: Annotated[ + size: Annotated[ int | None, Field( description="Storage capacity in megabytes for ephemeral volumes.", @@ -9989,6 +10087,21 @@ class CleanupSandboxImagesResponse(BaseModel): ], ), ] + remaining: Annotated[ + int | None, + Field( + description="Number of unused image versions left for a next call.", + examples=[0], + ge=0, + ), + ] = None + has_more: Annotated[ + bool | None, + Field( + description="True when the cleanup stopped on its time limit before every unused version was removed. Call it again to continue.", + examples=[False], + ), + ] = None class SandboxPreviewConfiguration(BaseModel): @@ -10156,6 +10269,135 @@ class ListSandboxPreviewTokensResponse(BaseModel): pagination: SandboxApiPagination +class SandboxVolumeAttachment(BaseModel): + name: Annotated[ + str, + Field( + description="Name of the volume to mount.", + examples=["datasets"], + min_length=1, + ), + ] + mount_path: Annotated[ + str, + Field( + description="Absolute path where the volume is mounted in the sandbox.", + examples=["/data"], + pattern="^/", + ), + ] + read_only: Annotated[ + bool, + Field( + description="Mount the volume read-only. Defaults to false.", + examples=[False], + ), + ] = False + + +class CreateSandboxVolumeRequest(BaseModel): + name: Annotated[ + str | None, + Field( + description="Volume name, unique in the team. Generated when omitted.", + examples=["datasets"], + max_length=49, + min_length=1, + pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + ), + ] = None + size: Annotated[ + str | int, + Field( + description='Capacity, as a size (e.g. "10Gi", "512Mi") or a number of Mi (e.g. 1024). Can grow later, never shrink.', + examples=["10Gi"], + ), + ] + region: Annotated[ + str | None, + Field( + description="Region of the volume. Defaults to the default sandbox region. Sandboxes mounting it must be in the same region.", + examples=["us-pdx-1"], + ), + ] = None + labels: dict[str, str] | None = None + + +class UpdateSandboxVolumeRequest(BaseModel): + size: Annotated[ + str | int | None, + Field( + description='New capacity, as a size (e.g. "20Gi") or a number of Mi, greater than or equal to the current size.', + examples=["20Gi"], + ), + ] = None + labels: Annotated[ + dict[str, str] | None, + Field( + description="Labels replacing all current labels, not merged with them. Omit to keep them." + ), + ] = None + + +class SandboxVolume(BaseModel): + name: Annotated[ + str, + Field( + description="Volume name, used in volume_name path parameters and sandbox volumes.", + examples=["datasets"], + ), + ] + size: Annotated[ + str | int, + Field( + description='Capacity, as a size (e.g. "10Gi", "512Mi") or a number of Mi (e.g. 1024).', + examples=["10Gi"], + ), + ] + region: Annotated[ + str, Field(description="Region of the volume.", examples=["us-pdx-1"]) + ] + status: Annotated[ + Literal["DEPLOYING", "DEPLOYED", "FAILED", "DELETING"] | str, + Field(description="Volume status, always uppercase.", examples=["DEPLOYED"]), + ] + attached_to: Annotated[ + str | None, + Field( + description="Name of the sandbox the volume is mounted in. Absent when not mounted.", + examples=["baseten-api-review-0916"], + ), + ] = None + labels: Annotated[ + dict[str, str] | None, Field(description="Labels. Omitted from list responses.") + ] = None + created_at: Annotated[ + AwareDatetime | None, Field(examples=["2026-10-12T12:00:00Z"]) + ] = None + updated_at: Annotated[ + AwareDatetime | None, Field(examples=["2026-10-12T12:00:00Z"]) + ] = None + created_by: Annotated[ + str | None, + Field( + description="User or service account that created the volume.", + examples=["sandbox-automation"], + ), + ] = None + updated_by: Annotated[ + str | None, + Field( + description="User or service account that last updated the volume.", + examples=["sandbox-automation"], + ), + ] = None + + +class ListSandboxVolumesResponse(BaseModel): + items: Annotated[list[SandboxVolume], Field(description="Resources on this page.")] + pagination: SandboxApiPagination + + class LoadCheckpointConfig(BaseModel): enabled: Annotated[ bool, @@ -10238,6 +10480,7 @@ class GetAuditLogsParams(BaseModel): "USER_MANAGEMENT", "DIRECTORY_GROUP_MANAGEMENT", "SSH", + "TELEMETRY_EXPORT", ] | str ] @@ -10350,6 +10593,7 @@ class GetModelsAuditLogsParams(BaseModel): "USER_MANAGEMENT", "DIRECTORY_GROUP_MANAGEMENT", "SSH", + "TELEMETRY_EXPORT", ] | str ] @@ -10828,6 +11072,7 @@ class GetChainsAuditLogsParams(BaseModel): "USER_MANAGEMENT", "DIRECTORY_GROUP_MANAGEMENT", "SSH", + "TELEMETRY_EXPORT", ] | str ] @@ -11324,6 +11569,48 @@ class GetSandboxMetricsParams(BaseModel): ] = None +class ListSandboxVolumesParams(BaseModel): + team_id: Annotated[ + str | None, + Field( + description="Optional team ID. Must match X-Team-Id when both are supplied. If neither selector is supplied, defaults to the caller's only accessible team. Callers with multiple accessible teams must select a team. Requests without access to any team are forbidden.", + min_length=1, + ), + ] = None + cursor: Annotated[ + str | None, + Field( + description="Opaque cursor from the previous page; omit for the first page.", + examples=[ + "eyJ2IjoxLCJsYXN0X2tleSI6ImJhc2V0ZW4tYXBpLXJldmlldy0wOTE2Iiwic29ydCI6ImRlc2MifQ" + ], + ), + ] = None + limit: Annotated[ + int, + Field( + description="Maximum number of items to return.", + examples=[20], + ge=1, + le=100, + ), + ] = 20 + q: Annotated[ + str | None, + Field( + description="Search volume names. Search is applied before pagination.", + examples=["datasets"], + ), + ] = None + status: Annotated[ + list[Literal["DEPLOYING", "DEPLOYED", "FAILED", "DELETING"] | str] | None, + Field( + description="Volume statuses. Repeat the query parameter for each status, for example status=DEPLOYED&status=FAILED. Unknown values are rejected.", + min_length=1, + ), + ] = None + + class Volume(BaseModel): namespace: Annotated[ str, @@ -13108,6 +13395,13 @@ class LoopsRun(BaseModel): title="Lora Config Version", ), ] = None + base_checkpoint: Annotated[ + str | None, + Field( + description="The custom base checkpoint the run started from, pinned to a commit (hf:///@). Null when it uses the base model's own weights.", + title="Base Checkpoint", + ), + ] = None base_url: Annotated[str, Field(description="The run's base URL.", title="Base Url")] created_at: Annotated[ AwareDatetime, @@ -14879,6 +15173,9 @@ class AuditLogEntry(BaseModel): "VOLUME_TAG_SET", "VOLUME_TAG_DELETED", "VOLUME_UPDATED", + "TELEMETRY_EXPORT_CREATED", + "TELEMETRY_EXPORT_UPDATED", + "TELEMETRY_EXPORT_DELETED", ] | str, Field( @@ -14939,7 +15236,10 @@ class AuditLogEntry(BaseModel): | AuditLogEventVolumeVersionRestored | AuditLogEventVolumeTagSet | AuditLogEventVolumeTagDeleted - | AuditLogEventVolumeUpdated, + | AuditLogEventVolumeUpdated + | AuditLogEventTelemetryExportCreated + | AuditLogEventTelemetryExportUpdated + | AuditLogEventTelemetryExportDeleted, Field( description="Structured details of the action, discriminated by `event_type`.", discriminator="event_type", @@ -15578,7 +15878,7 @@ class SandboxConfiguration(BaseModel): region: Annotated[ str | None, Field( - description="Region where the sandbox runs (for example us-pdx-1 or eu-lon-1). When omitted at creation, the closest region is selected.", + description="Region where the sandbox runs (for example us-pdx-1 or eu-lon-1). When omitted at creation, the default sandbox region is used.", examples=["us-pdx-1"], ), ] = None @@ -15617,6 +15917,13 @@ class SandboxConfiguration(BaseModel): examples=[{"max_memory_usage": "80%"}], ), ] = None + volumes: Annotated[ + list[SandboxVolumeAttachment] | None, + Field( + description="Volumes mounted in the sandbox. Set at creation only.", + max_length=1, + ), + ] = None ports: Annotated[ list[SandboxPort] | None, Field( @@ -15715,7 +16022,7 @@ class CreateSandboxRequest(BaseModel): region: Annotated[ str | None, Field( - description="Region where the sandbox runs (for example us-pdx-1 or eu-lon-1). When omitted at creation, the closest region is selected.", + description="Region where the sandbox runs (for example us-pdx-1 or eu-lon-1). When omitted at creation, the default sandbox region is used.", examples=["us-pdx-1"], ), ] = None @@ -15754,6 +16061,13 @@ class CreateSandboxRequest(BaseModel): examples=[{"max_memory_usage": "80%"}], ), ] = None + volumes: Annotated[ + list[SandboxVolumeAttachment] | None, + Field( + description="Volumes mounted in the sandbox. Set at creation only.", + max_length=1, + ), + ] = None ports: Annotated[ list[SandboxPort] | None, Field( diff --git a/baseten/sandbox/__init__.py b/baseten/sandbox/__init__.py index 60bffa0..7e7537b 100644 --- a/baseten/sandbox/__init__.py +++ b/baseten/sandbox/__init__.py @@ -55,6 +55,7 @@ ) from baseten.sandbox._images import ( AsyncImageClient, + ImageCleanupProgress, ImageCleanupResult, ImageClient, ImageInfo, @@ -93,6 +94,7 @@ SandboxPort, SandboxPortProtocol, SandboxStatus, + SandboxVolumeMount, ) from baseten.sandbox._previews import ( AsyncSandboxPreviews, @@ -114,6 +116,12 @@ ) from baseten.sandbox._retry import SandboxRetryOptions from baseten.sandbox._sandbox import AsyncSandbox, Sandbox +from baseten.sandbox._volumes import ( + AsyncVolumeClient, + VolumeClient, + VolumeInfo, + VolumeStatus, +) __all__ = [ "AsyncImageClient", @@ -127,8 +135,10 @@ "AsyncSandboxProcess", "AsyncSandboxStream", "AsyncSandboxTokenProvider", + "AsyncVolumeClient", "ImageBuildError", "ImageBuilder", + "ImageCleanupProgress", "ImageCleanupResult", "ImageClient", "ImageIgnoreFileFunc", @@ -196,5 +206,9 @@ "SandboxStream", "SandboxTokenProvider", "SandboxTokenProviderContext", + "SandboxVolumeMount", + "VolumeClient", + "VolumeInfo", + "VolumeStatus", "default_image_ignore_file", ] diff --git a/baseten/sandbox/_client.py b/baseten/sandbox/_client.py index dfab5e7..abb7b90 100644 --- a/baseten/sandbox/_client.py +++ b/baseten/sandbox/_client.py @@ -48,6 +48,7 @@ SandboxNetwork, SandboxPort, SandboxStatus, + SandboxVolumeMount, envs_to_api, filesystem_to_api, lifecycle_to_api, @@ -55,9 +56,16 @@ ports_to_api, sandbox_info_from_api, set_fields, + volumes_to_api, ) from baseten.sandbox._retry import SandboxRetryOptions, resolve_retry_options from baseten.sandbox._sandbox import AsyncSandbox, Sandbox +from baseten.sandbox._volumes import ( + AsyncVolumeClient, + AsyncVolumeClientContext, + VolumeClient, + VolumeClientContext, +) # Timeouts of the HTTP client a sandbox client creates. No read timeout: a # process run that waits for completion sends response headers only when the @@ -248,6 +256,9 @@ def __init__( http_client=self._http_client, ) ) + self._volumes = VolumeClient( + VolumeClientContext(raw_api=self._raw_api, team_id=team_id) + ) @property def options(self) -> SandboxClientOptions: @@ -264,6 +275,11 @@ def images(self) -> ImageClient: """Images that sandboxes are created from.""" return self._images + @property + def volumes(self) -> VolumeClient: + """Persistent volumes that sandboxes mount.""" + return self._volumes + @property def raw_api(self) -> baseten.client.managementapi.ApiClient: """The generated management API client, authenticated for sandboxes. @@ -288,6 +304,7 @@ def create( ports: Sequence[SandboxPort] | None = None, network: SandboxNetwork | None = None, filesystem: SandboxFileSystemOptions | None = None, + volumes: Sequence[SandboxVolumeMount] | None = None, ) -> SandboxCreateResult: """Create a sandbox. @@ -312,6 +329,9 @@ def create( creation. filesystem: Filesystem configuration, which cannot be changed after creation. + volumes: Persistent volumes to mount, each in the sandbox's + region and not mounted elsewhere. At most one is currently + supported. They cannot be changed after creation. """ with self._control_api() as api: sandbox = api.create_sandbox( @@ -336,6 +356,7 @@ def create( filesystem=None if filesystem is None else filesystem_to_api(filesystem), + volumes=None if volumes is None else volumes_to_api(volumes), ) ), ) @@ -639,6 +660,9 @@ def __init__( http_client=self._http_client, ) ) + self._volumes = AsyncVolumeClient( + AsyncVolumeClientContext(raw_api=self._raw_api, team_id=team_id) + ) @property def options(self) -> SandboxClientOptions: @@ -655,6 +679,11 @@ def images(self) -> AsyncImageClient: """Images that sandboxes are created from.""" return self._images + @property + def volumes(self) -> AsyncVolumeClient: + """Persistent volumes that sandboxes mount.""" + return self._volumes + @property def raw_api(self) -> baseten.client.managementapi.AsyncApiClient: """The generated management API client, authenticated for sandboxes. @@ -679,6 +708,7 @@ async def create( ports: Sequence[SandboxPort] | None = None, network: SandboxNetwork | None = None, filesystem: SandboxFileSystemOptions | None = None, + volumes: Sequence[SandboxVolumeMount] | None = None, ) -> AsyncSandboxCreateResult: """Create a sandbox. As :meth:`SandboxClient.create`.""" with self._control_api() as api: @@ -704,6 +734,7 @@ async def create( filesystem=None if filesystem is None else filesystem_to_api(filesystem), + volumes=None if volumes is None else volumes_to_api(volumes), ) ), ) diff --git a/baseten/sandbox/_images.py b/baseten/sandbox/_images.py index 6ba84ba..eeeac0f 100644 --- a/baseten/sandbox/_images.py +++ b/baseten/sandbox/_images.py @@ -7,7 +7,7 @@ import io import tempfile import time -from collections.abc import AsyncIterator, Iterator, Mapping +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping from contextlib import contextmanager from dataclasses import dataclass from datetime import UTC, datetime, timedelta @@ -129,10 +129,24 @@ class ImageCleanupResult: """Result of :meth:`ImageClient.cleanup`.""" deleted: int - """Number of image versions removed.""" + """Number of image versions removed, across calls.""" message: str - """Human-readable description of the result.""" + """Human-readable description of the last call, covering only its removals.""" + + +@dataclass(kw_only=True) +class ImageCleanupProgress: + """A cleanup's state between two server calls, from :meth:`ImageClient.cleanup`.""" + + deleted: int + """Number of image versions removed so far, across calls.""" + + remaining: int + """Number of unused image versions left for the next call.""" + + message: str + """Human-readable description of the call that just finished, covering only its removals.""" @dataclass(kw_only=True) @@ -160,7 +174,7 @@ class ImageLibraryVolume: type: str | None """Volume type, persistent when empty or ``None``.""" - size_mb: int | None + size: int | None """Storage capacity in megabytes of an ephemeral volume.""" read_only: bool @@ -661,15 +675,38 @@ def delete_tag(self, *, name: str, tag: str) -> ImageInfo: ) return _image_info(image) - def cleanup(self) -> ImageCleanupResult: - """Remove image versions that no sandbox uses.""" - with self._context.api() as api: - result = api.cleanup_images( - params=baseten.client.managementapi.CleanupImagesParams( - **set_fields(team_id=self._context.team_id) + def cleanup( + self, *, progress: Callable[[ImageCleanupProgress], None] | None = None + ) -> ImageCleanupResult: + """Remove image versions that no sandbox uses. + + A server call runs for a limited time, so this calls again until none + remain. + + Args: + progress: Called between the server calls: after each call that + stopped on its time limit, before the next one. Not called + after the last call, whose outcome is the result. + """ + deleted = 0 + while True: + with self._context.api() as api: + result = api.cleanup_images( + params=baseten.client.managementapi.CleanupImagesParams( + **set_fields(team_id=self._context.team_id) + ) + ) + deleted += result.deleted + if not result.has_more: + return ImageCleanupResult(deleted=deleted, message=result.message) + if progress is not None: + progress( + ImageCleanupProgress( + deleted=deleted, + remaining=result.remaining or 0, + message=result.message, + ) ) - ) - return ImageCleanupResult(deleted=result.deleted, message=result.message) def list_library(self) -> builtins.list[ImageLibraryInfo]: """List the built-in images available to every team.""" @@ -1058,15 +1095,37 @@ async def delete_tag(self, *, name: str, tag: str) -> ImageInfo: ) return _image_info(image) - async def cleanup(self) -> ImageCleanupResult: - """Remove image versions that no sandbox uses. As :meth:`ImageClient.cleanup`.""" - with self._context.api() as api: - result = await api.cleanup_images( - params=baseten.client.managementapi.CleanupImagesParams( - **set_fields(team_id=self._context.team_id) + async def cleanup( + self, + *, + progress: Callable[[ImageCleanupProgress], None | Awaitable[None]] + | None = None, + ) -> ImageCleanupResult: + """Remove image versions that no sandbox uses. As :meth:`ImageClient.cleanup`. + + *progress* may also be async. + """ + deleted = 0 + while True: + with self._context.api() as api: + result = await api.cleanup_images( + params=baseten.client.managementapi.CleanupImagesParams( + **set_fields(team_id=self._context.team_id) + ) ) - ) - return ImageCleanupResult(deleted=result.deleted, message=result.message) + deleted += result.deleted + if not result.has_more: + return ImageCleanupResult(deleted=deleted, message=result.message) + if progress is not None: + reported = progress( + ImageCleanupProgress( + deleted=deleted, + remaining=result.remaining or 0, + message=result.message, + ) + ) + if inspect.isawaitable(reported): + await reported async def list_library(self) -> builtins.list[ImageLibraryInfo]: """List the built-in images. As :meth:`ImageClient.list_library`.""" @@ -1437,7 +1496,7 @@ def _library_info( name=volume.name, mount_path=volume.mount_path, type=volume.type, - size_mb=volume.size_mb, + size=volume.size, read_only=bool(volume.read_only), ) for volume in options.volumes or [] diff --git a/baseten/sandbox/_info.py b/baseten/sandbox/_info.py index 5a24b90..4171adb 100644 --- a/baseten/sandbox/_info.py +++ b/baseten/sandbox/_info.py @@ -217,6 +217,20 @@ class SandboxFileSystemOptions: """ +@dataclass(kw_only=True) +class SandboxVolumeMount: + """A persistent volume mounted in a sandbox, fixed at creation.""" + + name: str + """Name of the volume.""" + + mount_path: str + """Absolute path the volume is mounted at.""" + + read_only: bool = False + """Whether the volume is mounted read-only.""" + + @dataclass(kw_only=True) class SandboxInfo: """A sandbox as last reported by the control plane.""" @@ -257,6 +271,9 @@ class SandboxInfo: filesystem: SandboxFileSystemOptions | None + volumes: list[SandboxVolumeMount] + """Persistent volumes mounted in the sandbox.""" + created_at: datetime updated_at: datetime | None @@ -293,6 +310,14 @@ def sandbox_info_from_api(sandbox: baseten.client.managementapi.Sandbox) -> Sand else SandboxFileSystemOptions( max_memory_usage=sandbox.filesystem.max_memory_usage ), + volumes=[ + SandboxVolumeMount( + name=volume.name, + mount_path=volume.mount_path, + read_only=volume.read_only, + ) + for volume in sandbox.volumes or [] + ], created_at=sandbox.created_at, updated_at=sandbox.updated_at, created_by=sandbox.created_by, @@ -397,6 +422,20 @@ def filesystem_to_api( ) +def volumes_to_api( + volumes: Sequence[SandboxVolumeMount], +) -> list[baseten.client.managementapi.SandboxVolumeAttachment]: + """Convert volume mounts to the control plane's form.""" + return [ + baseten.client.managementapi.SandboxVolumeAttachment( + name=volume.name, + mount_path=volume.mount_path, + read_only=volume.read_only, + ) + for volume in volumes + ] + + def set_fields(**fields: Any) -> dict[str, Any]: """Return the fields that are not ``None``. diff --git a/baseten/sandbox/_volumes.py b/baseten/sandbox/_volumes.py new file mode 100644 index 0000000..d9c8338 --- /dev/null +++ b/baseten/sandbox/_volumes.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +import builtins +from collections.abc import AsyncIterator, Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime +from typing import Literal, TypeAlias + +import baseten.client.managementapi +from baseten.sandbox._common import paginate, paginate_async +from baseten.sandbox._errors import converted_errors +from baseten.sandbox._info import set_fields + +VolumeStatus: TypeAlias = Literal["DEPLOYING", "DEPLOYED", "FAILED", "DELETING"] | str +"""Status of a volume. + +Other values may be added, so do not treat this list as exhaustive. +""" + + +@dataclass(kw_only=True) +class VolumeInfo: + """A volume as last reported by the control plane.""" + + name: str + """Unique name of the volume.""" + + size: str | int + """Capacity, as a size such as ``"10Gi"`` or a number of megabytes.""" + + region: str + """Region of the volume. Only sandboxes in the same region can mount it.""" + + status: VolumeStatus + + attached_to: str | None + """Name of the sandbox the volume is mounted in, ``None`` when not mounted.""" + + labels: dict[str, str] | None + """Key-value pairs for organizing and filtering. + + ``None`` when not returned, since :meth:`VolumeClient.list` omits them. + """ + + created_at: datetime | None + + updated_at: datetime | None + + created_by: str | None + """Who created the volume.""" + + updated_by: str | None + """Who last updated the volume.""" + + +@dataclass(kw_only=True) +class VolumeClientContext: + """What a :class:`VolumeClient` uses from the client that made it.""" + + raw_api: baseten.client.managementapi.ApiClient + team_id: str | None + + @contextmanager + def api(self) -> Iterator[baseten.client.managementapi.ApiClient]: + """Give the generated client, raising its error responses as ours.""" + with converted_errors("control"): + yield self.raw_api + + +@dataclass(kw_only=True) +class AsyncVolumeClientContext: + """Async form of :class:`VolumeClientContext`.""" + + raw_api: baseten.client.managementapi.AsyncApiClient + team_id: str | None + + @contextmanager + def api(self) -> Iterator[baseten.client.managementapi.AsyncApiClient]: + """Give the generated client, raising its error responses as ours.""" + with converted_errors("control"): + yield self.raw_api + + +class VolumeClient: + """Persistent volumes, which keep their data beyond the sandboxes they are mounted in. + + Mount one when creating a sandbox with its ``volumes`` option. Obtained + from :attr:`SandboxClient.volumes`. + """ + + def __init__(self, context: VolumeClientContext) -> None: + """Internal. Obtained from :attr:`SandboxClient.volumes`. + + :meta private: + """ + self._context = context + + def create( + self, + *, + size: str | int, + name: str | None = None, + region: str | None = None, + labels: Mapping[str, str] | None = None, + ) -> VolumeInfo: + """Create a volume. + + The volume becomes ready asynchronously, so its status may still be + deploying. + + Args: + size: Capacity, as a size such as ``"10Gi"`` or ``"512Mi"``, or a + number of megabytes. It can grow later but never shrink. + name: Unique name of the volume. The server assigns one when + ``None``. + region: Region of the volume. Defaults to the default sandbox + region. Only sandboxes in the same region can mount it. + labels: Key-value pairs for organizing and filtering. + """ + with self._context.api() as api: + volume = api.create_sandbox_volume( + params=baseten.client.managementapi.CreateSandboxVolumeParams( + **set_fields(team_id=self._context.team_id) + ), + request=_create_request( + size=size, name=name, region=region, labels=labels + ), + ) + return _volume_info(volume) + + def get_info(self, *, name: str) -> VolumeInfo: + """Get a volume's current record. + + Args: + name: Name of the volume. + """ + with self._context.api() as api: + volume = api.get_sandbox_volume( + volume_name=name, + params=baseten.client.managementapi.GetSandboxVolumeParams( + **set_fields(team_id=self._context.team_id) + ), + ) + return _volume_info(volume) + + def list( + self, + *, + query: str | None = None, + statuses: builtins.list[VolumeStatus] | None = None, + page_size: int | None = None, + ) -> Iterator[VolumeInfo]: + """List volumes, newest first, fetching further pages as iteration reaches them. + + Records omit labels. + + Args: + query: Searches volume names. + statuses: Only volumes with one of these statuses. + page_size: How many volumes to fetch per underlying request. + """ + + def fetch_page( + cursor: str | None, + ) -> baseten.client.managementapi.ListSandboxVolumesResponse: + with self._context.api() as api: + return api.list_sandbox_volumes( + params=_list_params( + team_id=self._context.team_id, + cursor=cursor, + query=query, + statuses=statuses, + page_size=page_size, + ) + ) + + for volume in paginate("volume list", fetch_page): + yield _volume_info(volume) + + def update( + self, + *, + name: str, + size: str | int | None = None, + labels: Mapping[str, str] | None = None, + ) -> VolumeInfo: + """Update a volume, returning its new record. + + Args: + name: Name of the volume. + size: New capacity, as a size such as ``"20Gi"`` or a number of + megabytes, no less than the current capacity. ``None`` leaves + it unchanged. + labels: Labels replacing all current ones, including when empty. + ``None`` leaves them unchanged. + """ + with self._context.api() as api: + volume = api.update_sandbox_volume( + volume_name=name, + params=baseten.client.managementapi.UpdateSandboxVolumeParams( + **set_fields(team_id=self._context.team_id) + ), + request=_update_request(size=size, labels=labels), + ) + return _volume_info(volume) + + def delete(self, *, name: str) -> VolumeInfo: + """Delete a volume and its data. Fails while a sandbox mounts it. + + Deletion continues after this returns, so the record usually shows the + volume as deleting. + + Args: + name: Name of the volume. + """ + with self._context.api() as api: + volume = api.delete_sandbox_volume( + volume_name=name, + params=baseten.client.managementapi.DeleteSandboxVolumeParams( + **set_fields(team_id=self._context.team_id) + ), + ) + return _volume_info(volume) + + +class AsyncVolumeClient: + """Async form of :class:`VolumeClient`.""" + + def __init__(self, context: AsyncVolumeClientContext) -> None: + """Internal. Obtained from :attr:`AsyncSandboxClient.volumes`. + + :meta private: + """ + self._context = context + + async def create( + self, + *, + size: str | int, + name: str | None = None, + region: str | None = None, + labels: Mapping[str, str] | None = None, + ) -> VolumeInfo: + """Create a volume. As :meth:`VolumeClient.create`.""" + with self._context.api() as api: + volume = await api.create_sandbox_volume( + params=baseten.client.managementapi.CreateSandboxVolumeParams( + **set_fields(team_id=self._context.team_id) + ), + request=_create_request( + size=size, name=name, region=region, labels=labels + ), + ) + return _volume_info(volume) + + async def get_info(self, *, name: str) -> VolumeInfo: + """Get a volume's current record. As :meth:`VolumeClient.get_info`.""" + with self._context.api() as api: + volume = await api.get_sandbox_volume( + volume_name=name, + params=baseten.client.managementapi.GetSandboxVolumeParams( + **set_fields(team_id=self._context.team_id) + ), + ) + return _volume_info(volume) + + async def list( + self, + *, + query: str | None = None, + statuses: builtins.list[VolumeStatus] | None = None, + page_size: int | None = None, + ) -> AsyncIterator[VolumeInfo]: + """List volumes. As :meth:`VolumeClient.list`.""" + + async def fetch_page( + cursor: str | None, + ) -> baseten.client.managementapi.ListSandboxVolumesResponse: + with self._context.api() as api: + return await api.list_sandbox_volumes( + params=_list_params( + team_id=self._context.team_id, + cursor=cursor, + query=query, + statuses=statuses, + page_size=page_size, + ) + ) + + async for volume in paginate_async("volume list", fetch_page): + yield _volume_info(volume) + + async def update( + self, + *, + name: str, + size: str | int | None = None, + labels: Mapping[str, str] | None = None, + ) -> VolumeInfo: + """Update a volume. As :meth:`VolumeClient.update`.""" + with self._context.api() as api: + volume = await api.update_sandbox_volume( + volume_name=name, + params=baseten.client.managementapi.UpdateSandboxVolumeParams( + **set_fields(team_id=self._context.team_id) + ), + request=_update_request(size=size, labels=labels), + ) + return _volume_info(volume) + + async def delete(self, *, name: str) -> VolumeInfo: + """Delete a volume. As :meth:`VolumeClient.delete`.""" + with self._context.api() as api: + volume = await api.delete_sandbox_volume( + volume_name=name, + params=baseten.client.managementapi.DeleteSandboxVolumeParams( + **set_fields(team_id=self._context.team_id) + ), + ) + return _volume_info(volume) + + +def _create_request( + *, + size: str | int, + name: str | None, + region: str | None, + labels: Mapping[str, str] | None, +) -> baseten.client.managementapi.CreateSandboxVolumeRequest: + """Build a create request.""" + return baseten.client.managementapi.CreateSandboxVolumeRequest( + **set_fields( + size=size, + name=name, + region=region, + labels=None if labels is None else dict(labels), + ) + ) + + +def _update_request( + *, size: str | int | None, labels: Mapping[str, str] | None +) -> baseten.client.managementapi.UpdateSandboxVolumeRequest: + """Build an update request.""" + return baseten.client.managementapi.UpdateSandboxVolumeRequest( + **set_fields(size=size, labels=None if labels is None else dict(labels)) + ) + + +def _list_params( + *, + team_id: str | None, + cursor: str | None, + query: str | None, + statuses: list[VolumeStatus] | None, + page_size: int | None, +) -> baseten.client.managementapi.ListSandboxVolumesParams: + """Build a list page's query parameters.""" + return baseten.client.managementapi.ListSandboxVolumesParams( + **set_fields( + team_id=team_id, + cursor=cursor, + limit=page_size, + q=query, + status=statuses, + ) + ) + + +def _volume_info(volume: baseten.client.managementapi.SandboxVolume) -> VolumeInfo: + """Convert a volume record.""" + return VolumeInfo( + name=volume.name, + size=volume.size, + region=volume.region, + status=volume.status, + attached_to=volume.attached_to, + labels=volume.labels, + created_at=volume.created_at, + updated_at=volume.updated_at, + created_by=volume.created_by, + updated_by=volume.updated_by, + ) diff --git a/scripts/apigen/specs/management.json b/scripts/apigen/specs/management.json index 6816c08..d203fde 100644 --- a/scripts/apigen/specs/management.json +++ b/scripts/apigen/specs/management.json @@ -7205,11 +7205,11 @@ "x-codeSamples": [ { "lang": "bash", - "source": "curl --request POST \\\n--url https://api.baseten.co/v1/loops/runs \\\n--header \"Authorization: Bearer $BASETEN_API_KEY\" \\\n--data '{\n \"session_id\": null,\n \"base_model\": null,\n \"name\": null,\n \"max_seq_len\": null,\n \"seed\": null,\n \"availability_model\": \"spot\",\n \"path\": \"bt://loops:k4q95w5/weights/step-100\",\n \"reuse_from_run_id\": null,\n \"reuse_from_session_id\": null,\n \"sampler_num_replicas\": null\n}'" + "source": "curl --request POST \\\n--url https://api.baseten.co/v1/loops/runs \\\n--header \"Authorization: Bearer $BASETEN_API_KEY\" \\\n--data '{\n \"session_id\": null,\n \"base_model\": null,\n \"name\": null,\n \"max_seq_len\": null,\n \"seed\": null,\n \"availability_model\": \"spot\",\n \"path\": \"bt://loops:k4q95w5/weights/step-100\",\n \"reuse_from_run_id\": null,\n \"reuse_from_session_id\": null,\n \"base_checkpoint\": null,\n \"sampler_num_replicas\": null\n}'" }, { "lang": "python", - "source": "import requests\nimport os\nAPI_KEY = os.environ.get(\"BASETEN_API_KEY\", \"\")\nurl = \"https://api.baseten.co/v1/loops/runs\"\n\nheaders = {\"Authorization\": f\"Bearer {API_KEY}\"}\n\nresponse = requests.request(\n \"POST\",\n url,\n headers=headers,\n json={'session_id': None, 'base_model': None, 'name': None, 'max_seq_len': None, 'seed': None, 'availability_model': 'spot', 'path': 'bt://loops:k4q95w5/weights/step-100', 'reuse_from_run_id': None, 'reuse_from_session_id': None, 'sampler_num_replicas': None}\n)\n\nprint(response.text)" + "source": "import requests\nimport os\nAPI_KEY = os.environ.get(\"BASETEN_API_KEY\", \"\")\nurl = \"https://api.baseten.co/v1/loops/runs\"\n\nheaders = {\"Authorization\": f\"Bearer {API_KEY}\"}\n\nresponse = requests.request(\n \"POST\",\n url,\n headers=headers,\n json={'session_id': None, 'base_model': None, 'name': None, 'max_seq_len': None, 'seed': None, 'availability_model': 'spot', 'path': 'bt://loops:k4q95w5/weights/step-100', 'reuse_from_run_id': None, 'reuse_from_session_id': None, 'base_checkpoint': None, 'sampler_num_replicas': None}\n)\n\nprint(response.text)" } ], "description": "Creates a Loops run with an associated sampler in the given session.", @@ -7309,11 +7309,11 @@ "x-codeSamples": [ { "lang": "bash", - "source": "curl --request POST \\\n--url https://api.baseten.co/v1/loops/trainers \\\n--header \"Authorization: Bearer $BASETEN_API_KEY\" \\\n--data '{\n \"session_id\": null,\n \"base_model\": null,\n \"name\": null,\n \"max_seq_len\": null,\n \"seed\": null,\n \"availability_model\": \"spot\",\n \"path\": \"bt://loops:k4q95w5/weights/step-100\",\n \"reuse_from_run_id\": null,\n \"reuse_from_session_id\": null\n}'" + "source": "curl --request POST \\\n--url https://api.baseten.co/v1/loops/trainers \\\n--header \"Authorization: Bearer $BASETEN_API_KEY\" \\\n--data '{\n \"session_id\": null,\n \"base_model\": null,\n \"name\": null,\n \"max_seq_len\": null,\n \"seed\": null,\n \"availability_model\": \"spot\",\n \"path\": \"bt://loops:k4q95w5/weights/step-100\",\n \"reuse_from_run_id\": null,\n \"reuse_from_session_id\": null,\n \"base_checkpoint\": null\n}'" }, { "lang": "python", - "source": "import requests\nimport os\nAPI_KEY = os.environ.get(\"BASETEN_API_KEY\", \"\")\nurl = \"https://api.baseten.co/v1/loops/trainers\"\n\nheaders = {\"Authorization\": f\"Bearer {API_KEY}\"}\n\nresponse = requests.request(\n \"POST\",\n url,\n headers=headers,\n json={'session_id': None, 'base_model': None, 'name': None, 'max_seq_len': None, 'seed': None, 'availability_model': 'spot', 'path': 'bt://loops:k4q95w5/weights/step-100', 'reuse_from_run_id': None, 'reuse_from_session_id': None}\n)\n\nprint(response.text)" + "source": "import requests\nimport os\nAPI_KEY = os.environ.get(\"BASETEN_API_KEY\", \"\")\nurl = \"https://api.baseten.co/v1/loops/trainers\"\n\nheaders = {\"Authorization\": f\"Bearer {API_KEY}\"}\n\nresponse = requests.request(\n \"POST\",\n url,\n headers=headers,\n json={'session_id': None, 'base_model': None, 'name': None, 'max_seq_len': None, 'seed': None, 'availability_model': 'spot', 'path': 'bt://loops:k4q95w5/weights/step-100', 'reuse_from_run_id': None, 'reuse_from_session_id': None, 'base_checkpoint': None}\n)\n\nprint(response.text)" } ], "description": "Creates a trainer-only Loops run in the given session, without a sampler. To sample from it, create a sampler with POST /v1/loops/samplers and pass this run's ID as run_id to pair the two. List and read the trainer through the /v1/loops/runs endpoints.", @@ -8241,11 +8241,11 @@ "x-codeSamples": [ { "lang": "bash", - "source": "curl --request POST \\\n--url https://api.baseten.co/v1/teams/{team_id}/loops/runs \\\n--header \"Authorization: Bearer $BASETEN_API_KEY\" \\\n--data '{\n \"session_id\": null,\n \"base_model\": null,\n \"name\": null,\n \"max_seq_len\": null,\n \"seed\": null,\n \"availability_model\": \"spot\",\n \"path\": \"bt://loops:k4q95w5/weights/step-100\",\n \"reuse_from_run_id\": null,\n \"reuse_from_session_id\": null,\n \"sampler_num_replicas\": null\n}'" + "source": "curl --request POST \\\n--url https://api.baseten.co/v1/teams/{team_id}/loops/runs \\\n--header \"Authorization: Bearer $BASETEN_API_KEY\" \\\n--data '{\n \"session_id\": null,\n \"base_model\": null,\n \"name\": null,\n \"max_seq_len\": null,\n \"seed\": null,\n \"availability_model\": \"spot\",\n \"path\": \"bt://loops:k4q95w5/weights/step-100\",\n \"reuse_from_run_id\": null,\n \"reuse_from_session_id\": null,\n \"base_checkpoint\": null,\n \"sampler_num_replicas\": null\n}'" }, { "lang": "python", - "source": "import requests\nimport os\nAPI_KEY = os.environ.get(\"BASETEN_API_KEY\", \"\")\nurl = \"https://api.baseten.co/v1/teams/{team_id}/loops/runs\"\n\nheaders = {\"Authorization\": f\"Bearer {API_KEY}\"}\n\nresponse = requests.request(\n \"POST\",\n url,\n headers=headers,\n json={'session_id': None, 'base_model': None, 'name': None, 'max_seq_len': None, 'seed': None, 'availability_model': 'spot', 'path': 'bt://loops:k4q95w5/weights/step-100', 'reuse_from_run_id': None, 'reuse_from_session_id': None, 'sampler_num_replicas': None}\n)\n\nprint(response.text)" + "source": "import requests\nimport os\nAPI_KEY = os.environ.get(\"BASETEN_API_KEY\", \"\")\nurl = \"https://api.baseten.co/v1/teams/{team_id}/loops/runs\"\n\nheaders = {\"Authorization\": f\"Bearer {API_KEY}\"}\n\nresponse = requests.request(\n \"POST\",\n url,\n headers=headers,\n json={'session_id': None, 'base_model': None, 'name': None, 'max_seq_len': None, 'seed': None, 'availability_model': 'spot', 'path': 'bt://loops:k4q95w5/weights/step-100', 'reuse_from_run_id': None, 'reuse_from_session_id': None, 'base_checkpoint': None, 'sampler_num_replicas': None}\n)\n\nprint(response.text)" } ], "description": "Creates a Loops run with an associated sampler in the given session; the run's infrastructure belongs to the given team.", @@ -8279,11 +8279,11 @@ "x-codeSamples": [ { "lang": "bash", - "source": "curl --request POST \\\n--url https://api.baseten.co/v1/teams/{team_id}/loops/trainers \\\n--header \"Authorization: Bearer $BASETEN_API_KEY\" \\\n--data '{\n \"session_id\": null,\n \"base_model\": null,\n \"name\": null,\n \"max_seq_len\": null,\n \"seed\": null,\n \"availability_model\": \"spot\",\n \"path\": \"bt://loops:k4q95w5/weights/step-100\",\n \"reuse_from_run_id\": null,\n \"reuse_from_session_id\": null\n}'" + "source": "curl --request POST \\\n--url https://api.baseten.co/v1/teams/{team_id}/loops/trainers \\\n--header \"Authorization: Bearer $BASETEN_API_KEY\" \\\n--data '{\n \"session_id\": null,\n \"base_model\": null,\n \"name\": null,\n \"max_seq_len\": null,\n \"seed\": null,\n \"availability_model\": \"spot\",\n \"path\": \"bt://loops:k4q95w5/weights/step-100\",\n \"reuse_from_run_id\": null,\n \"reuse_from_session_id\": null,\n \"base_checkpoint\": null\n}'" }, { "lang": "python", - "source": "import requests\nimport os\nAPI_KEY = os.environ.get(\"BASETEN_API_KEY\", \"\")\nurl = \"https://api.baseten.co/v1/teams/{team_id}/loops/trainers\"\n\nheaders = {\"Authorization\": f\"Bearer {API_KEY}\"}\n\nresponse = requests.request(\n \"POST\",\n url,\n headers=headers,\n json={'session_id': None, 'base_model': None, 'name': None, 'max_seq_len': None, 'seed': None, 'availability_model': 'spot', 'path': 'bt://loops:k4q95w5/weights/step-100', 'reuse_from_run_id': None, 'reuse_from_session_id': None}\n)\n\nprint(response.text)" + "source": "import requests\nimport os\nAPI_KEY = os.environ.get(\"BASETEN_API_KEY\", \"\")\nurl = \"https://api.baseten.co/v1/teams/{team_id}/loops/trainers\"\n\nheaders = {\"Authorization\": f\"Bearer {API_KEY}\"}\n\nresponse = requests.request(\n \"POST\",\n url,\n headers=headers,\n json={'session_id': None, 'base_model': None, 'name': None, 'max_seq_len': None, 'seed': None, 'availability_model': 'spot', 'path': 'bt://loops:k4q95w5/weights/step-100', 'reuse_from_run_id': None, 'reuse_from_session_id': None, 'base_checkpoint': None}\n)\n\nprint(response.text)" } ], "description": "Creates a trainer-only Loops run in the given session, without a sampler. To sample from it, create a sampler with POST /v1/loops/samplers and pass this run's ID as run_id to pair the two. List and read the trainer through the /v1/loops/runs endpoints.", @@ -11996,7 +11996,7 @@ ], "operationId": "UpdateSandbox", "summary": "Update a sandbox", - "description": "Partially update configuration. Omitted fields remain unchanged; supplied arrays and maps replace their previous values. Structured objects update only supplied fields. The name, memory, network, region, image, and ports are immutable after creation. Supplying any of these fields returns 400, including unchanged, empty, or null values.", + "description": "Partially update configuration. Omitted fields remain unchanged; supplied arrays and maps replace their previous values. Structured objects update only supplied fields. The name, memory, network, region, image, ports, filesystem, and volumes are immutable after creation. Supplying any of these fields returns 400, including unchanged, empty, or null values.", "tags": [ "Sandboxes" ], @@ -12631,6 +12631,330 @@ "x-stability": "unstable" } }, + "/v1/sandboxes/volumes": { + "get": { + "parameters": [ + { + "$ref": "#/components/parameters/TeamId" + }, + { + "$ref": "#/components/parameters/TeamIdHeader" + }, + { + "$ref": "#/components/parameters/Cursor" + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "name": "q", + "in": "query", + "description": "Search volume names. Search is applied before pagination.", + "required": false, + "schema": { + "type": "string", + "example": "datasets" + } + }, + { + "name": "status", + "in": "query", + "description": "Volume statuses. Repeat the query parameter for each status, for example status=DEPLOYED&status=FAILED. Unknown values are rejected.", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/SandboxVolumeStatusV1" + } + } + } + ], + "operationId": "ListSandboxVolumes", + "summary": "List volumes", + "description": "Return a cursor-paginated page of persistent volumes, newest first. Keep q and status unchanged when following a cursor.", + "tags": [ + "Volumes" + ], + "responses": { + "200": { + "description": "Successful operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSandboxVolumesResponseV1" + }, + "examples": { + "page": { + "$ref": "#/components/examples/SandboxVolumesPage" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/SandboxError400" + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + }, + "post": { + "parameters": [ + { + "$ref": "#/components/parameters/TeamId" + }, + { + "$ref": "#/components/parameters/TeamIdHeader" + } + ], + "operationId": "CreateSandboxVolume", + "summary": "Create a volume", + "description": "Create a persistent volume that outlives the sandboxes it is mounted in. Mount it at sandbox creation with the volumes field; a volume is mounted in at most one sandbox at a time and must be in the same region. The name is generated when omitted. Volume size and count are limited by the team's plan.", + "tags": [ + "Volumes" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSandboxVolumeRequestV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/CreateSandboxVolume" + } + } + } + } + }, + "responses": { + "201": { + "description": "Volume created. Provisioning may continue; inspect status for readiness.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxVolumeV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/SandboxVolumeReady" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/SandboxError400" + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "409": { + "$ref": "#/components/responses/SandboxError409" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + } + }, + "/v1/sandboxes/volumes/{volume_name}": { + "parameters": [ + { + "$ref": "#/components/parameters/VolumeName" + } + ], + "get": { + "parameters": [ + { + "$ref": "#/components/parameters/TeamId" + }, + { + "$ref": "#/components/parameters/TeamIdHeader" + } + ], + "operationId": "GetSandboxVolume", + "summary": "Get a volume", + "description": "Return the volume, its status and the sandbox it is mounted in, if any.", + "tags": [ + "Volumes" + ], + "responses": { + "200": { + "description": "Successful operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxVolumeV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/SandboxVolumeReady" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "404": { + "$ref": "#/components/responses/SandboxError404" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + }, + "patch": { + "parameters": [ + { + "$ref": "#/components/parameters/TeamId" + }, + { + "$ref": "#/components/parameters/TeamIdHeader" + } + ], + "operationId": "UpdateSandboxVolume", + "summary": "Update a volume", + "description": "Grow the volume or replace its labels. size can only increase. The name and region are immutable; supplying either returns 400.", + "tags": [ + "Volumes" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSandboxVolumeRequestV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/UpdateSandboxVolume" + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxVolumeV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/SandboxVolumeReady" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/SandboxError400" + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "404": { + "$ref": "#/components/responses/SandboxError404" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + }, + "delete": { + "parameters": [ + { + "$ref": "#/components/parameters/TeamId" + }, + { + "$ref": "#/components/parameters/TeamIdHeader" + } + ], + "operationId": "DeleteSandboxVolume", + "summary": "Delete a volume", + "description": "Permanently delete the volume and its data asynchronously. The returned resource has status DELETING. A volume mounted in a sandbox cannot be deleted (409); delete the sandbox first. Repeating the call while it is deleting returns the same resource.", + "tags": [ + "Volumes" + ], + "responses": { + "202": { + "description": "Request accepted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxVolumeV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/SandboxVolumeDeleting" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "404": { + "$ref": "#/components/responses/SandboxError404" + }, + "409": { + "$ref": "#/components/responses/SandboxError409" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + } + }, "/v1/sandboxes/library_images": { "get": { "operationId": "ListSandboxLibraryImages", @@ -12827,7 +13151,7 @@ ], "operationId": "CleanupImages", "summary": "Clean up unused images", - "description": "Remove unused sandbox image versions. Versions referenced by active deployments are preserved.", + "description": "Remove unused sandbox image versions. Versions referenced by active deployments are preserved. One call runs for a limited time; when has_more is true, only part of the unused versions were removed and the call should be repeated.", "tags": [ "Images" ], @@ -16990,6 +17314,14 @@ "title": "AuditLogActorV1", "type": "object" }, + "AuditLogApiKeyRevocationReasonV1": { + "description": "Reason an API key was revoked.", + "enum": [ + "INACTIVITY" + ], + "title": "AuditLogApiKeyRevocationReasonV1", + "type": "string" + }, "AuditLogApiKeyTypeV1": { "description": "Type of API key recorded on an API-key event.", "enum": [ @@ -17069,6 +17401,9 @@ "SECRET_DELETED": "#/components/schemas/AuditLogEventSecretDeletedV1", "SECRET_UPDATED": "#/components/schemas/AuditLogEventSecretUpdatedV1", "SSH_CERTIFICATE_SIGNED": "#/components/schemas/AuditLogEventSshCertificateSignedV1", + "TELEMETRY_EXPORT_CREATED": "#/components/schemas/AuditLogEventTelemetryExportCreatedV1", + "TELEMETRY_EXPORT_DELETED": "#/components/schemas/AuditLogEventTelemetryExportDeletedV1", + "TELEMETRY_EXPORT_UPDATED": "#/components/schemas/AuditLogEventTelemetryExportUpdatedV1", "USER_INVITED": "#/components/schemas/AuditLogEventUserInvitedV1", "USER_JOINED_ORGANIZATION": "#/components/schemas/AuditLogEventUserJoinedOrganizationV1", "USER_REMOVED": "#/components/schemas/AuditLogEventUserRemovedV1", @@ -17245,6 +17580,15 @@ }, { "$ref": "#/components/schemas/AuditLogEventVolumeUpdatedV1" + }, + { + "$ref": "#/components/schemas/AuditLogEventTelemetryExportCreatedV1" + }, + { + "$ref": "#/components/schemas/AuditLogEventTelemetryExportUpdatedV1" + }, + { + "$ref": "#/components/schemas/AuditLogEventTelemetryExportDeletedV1" } ], "title": "Event Data" @@ -17362,6 +17706,17 @@ "prefix": { "title": "Prefix", "type": "string" + }, + "revocation_reason": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuditLogApiKeyRevocationReasonV1" + }, + { + "type": "null" + } + ], + "default": null } }, "required": [ @@ -20687,6 +21042,83 @@ "title": "AuditLogEventSshCertificateSignedV1", "type": "object" }, + "AuditLogEventTelemetryExportCreatedV1": { + "description": "A telemetry export configuration was created.", + "properties": { + "telemetry_export_configuration_id": { + "title": "Telemetry Export Configuration Id", + "type": "string" + }, + "event_type": { + "const": "TELEMETRY_EXPORT_CREATED", + "title": "Event Type", + "type": "string" + } + }, + "required": [ + "telemetry_export_configuration_id", + "event_type" + ], + "title": "AuditLogEventTelemetryExportCreatedV1", + "type": "object" + }, + "AuditLogEventTelemetryExportDeletedV1": { + "description": "A telemetry export configuration was deleted.", + "properties": { + "telemetry_export_configuration_id": { + "title": "Telemetry Export Configuration Id", + "type": "string" + }, + "event_type": { + "const": "TELEMETRY_EXPORT_DELETED", + "title": "Event Type", + "type": "string" + } + }, + "required": [ + "telemetry_export_configuration_id", + "event_type" + ], + "title": "AuditLogEventTelemetryExportDeletedV1", + "type": "object" + }, + "AuditLogEventTelemetryExportUpdatedV1": { + "description": "A telemetry export configuration was updated. `enabled` is the new value when this update\npaused or resumed exporting and null when only the endpoint, protocol, headers, or credential\nchanged; a pause whose actor is the Baseten system was Baseten's own.", + "properties": { + "telemetry_export_configuration_id": { + "title": "Telemetry Export Configuration Id", + "type": "string" + }, + "event_type": { + "const": "TELEMETRY_EXPORT_UPDATED", + "title": "Event Type", + "type": "string" + }, + "credential_rotated": { + "title": "Credential Rotated", + "type": "boolean" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled" + } + }, + "required": [ + "telemetry_export_configuration_id", + "event_type", + "credential_rotated", + "enabled" + ], + "title": "AuditLogEventTelemetryExportUpdatedV1", + "type": "object" + }, "AuditLogEventTypeV1": { "description": "Type of action recorded by an audit-log entry.", "enum": [ @@ -20742,7 +21174,10 @@ "VOLUME_VERSION_RESTORED", "VOLUME_TAG_SET", "VOLUME_TAG_DELETED", - "VOLUME_UPDATED" + "VOLUME_UPDATED", + "TELEMETRY_EXPORT_CREATED", + "TELEMETRY_EXPORT_UPDATED", + "TELEMETRY_EXPORT_DELETED" ], "title": "AuditLogEventTypeV1", "type": "string" @@ -21235,7 +21670,8 @@ "WEBHOOK_SIGNING_SECRETS", "USER_MANAGEMENT", "DIRECTORY_GROUP_MANAGEMENT", - "SSH" + "SSH", + "TELEMETRY_EXPORT" ], "title": "AuditLogEventTypeGroupV1", "type": "string" @@ -29416,6 +29852,19 @@ "description": "The run's immutable LoRA config version.", "title": "Lora Config Version" }, + "base_checkpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The custom base checkpoint the run started from, pinned to a commit (hf:///@). Null when it uses the base model's own weights.", + "title": "Base Checkpoint" + }, "base_url": { "description": "The run's base URL.", "title": "Base Url", @@ -29547,6 +29996,19 @@ "description": "Number of nodes serving the sampler.", "title": "Node Count", "type": "integer" + }, + "base_checkpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The custom base checkpoint the sampler serves, pinned to a commit (hf:///@). Null when it uses the base model's own weights.", + "title": "Base Checkpoint" } }, "required": [ @@ -29759,6 +30221,19 @@ "description": "Optional ID of a prior Loops session whose trainer and/or sampler should be reused for this run. Deprecated in favor of reuse_from_run_id.", "title": "Reuse From Session Id" }, + "base_checkpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional Hugging Face checkpoint to start training from instead of the base model's weights. Form: hf:///[@]. It must be a complete, merged checkpoint in safetensors format with the same architecture as base_model, which still selects the hardware and training configuration. It must include its tokenizer files: the run's sampler and the SDK load the tokenizer and chat template from the checkpoint. A private or gated repository needs a Hugging Face token in the team secret hf_access_token. The revision is pinned to a commit when the run is created. Cannot be combined with path, reuse_from_run_id, or reuse_from_session_id.", + "title": "Base Checkpoint" + }, "sampler_num_replicas": { "anyOf": [ { @@ -29957,6 +30432,19 @@ "default": null, "description": "Optional ID of a prior Loops session whose trainer and/or sampler should be reused for this run. Deprecated in favor of reuse_from_run_id.", "title": "Reuse From Session Id" + }, + "base_checkpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional Hugging Face checkpoint to start training from instead of the base model's weights. Form: hf:///[@]. It must be a complete, merged checkpoint in safetensors format with the same architecture as base_model, which still selects the hardware and training configuration. It must include its tokenizer files: the run's sampler and the SDK load the tokenizer and chat template from the checkpoint. A private or gated repository needs a Hugging Face token in the team secret hf_access_token. The revision is pinned to a commit when the run is created. Cannot be combined with path, reuse_from_run_id, or reuse_from_session_id.", + "title": "Base Checkpoint" } }, "required": [ @@ -30672,6 +31160,19 @@ "title": "Base Model", "type": "string" }, + "base_checkpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The custom base checkpoint the deployment's trainer loaded, pinned to a commit (hf:///@). Null when it uses the base model's own weights.", + "title": "Base Checkpoint" + }, "base_url": { "description": "The run's base URL.", "title": "Base Url", @@ -39983,7 +40484,7 @@ }, "region": { "type": "string", - "description": "Region where the sandbox runs (for example us-pdx-1 or eu-lon-1). When omitted at creation, the closest region is selected.", + "description": "Region where the sandbox runs (for example us-pdx-1 or eu-lon-1). When omitted at creation, the default sandbox region is used.", "example": "us-pdx-1" }, "envs": { @@ -40028,6 +40529,14 @@ "max_memory_usage": "80%" } }, + "volumes": { + "type": "array", + "description": "Volumes mounted in the sandbox. Set at creation only.", + "maxItems": 1, + "items": { + "$ref": "#/components/schemas/SandboxVolumeAttachmentV1" + } + }, "ports": { "description": "Set of ports for a resource", "allOf": [ @@ -40235,7 +40744,7 @@ }, "region": { "type": "string", - "description": "Region where the sandbox runs (for example us-pdx-1 or eu-lon-1). When omitted at creation, the closest region is selected.", + "description": "Region where the sandbox runs (for example us-pdx-1 or eu-lon-1). When omitted at creation, the default sandbox region is used.", "example": "us-pdx-1" }, "envs": { @@ -40282,6 +40791,14 @@ "max_memory_usage": "80%" } }, + "volumes": { + "type": "array", + "description": "Volumes mounted in the sandbox. Set at creation only.", + "maxItems": 1, + "items": { + "$ref": "#/components/schemas/SandboxVolumeAttachmentV1" + } + }, "ports": { "description": "Set of ports for a resource", "allOf": [ @@ -40406,7 +40923,7 @@ }, "UpdateSandboxRequestV1": { "type": "object", - "description": "Partial sandbox update. Omitted fields remain unchanged. Supplied arrays and maps (including labels) replace their previous values; supplied structured objects update only their supplied fields. Null is not accepted. The name, memory, network, region, image, and ports are immutable after creation. Supplying any of these fields returns 400, including unchanged, empty, or null values.", + "description": "Partial sandbox update. Omitted fields remain unchanged. Supplied arrays and maps (including labels) replace their previous values; supplied structured objects update only their supplied fields. Null is not accepted. The name, memory, network, region, image, ports, filesystem, and volumes are immutable after creation. Supplying any of these fields returns 400, including unchanged, empty, or null values.", "properties": { "lifecycle": { "description": "Lifecycle configuration controlling automatic sandbox deletion based on idle time, max age, or specific dates", @@ -41242,7 +41759,7 @@ "description": "Volume type, persistent when empty.", "example": "ephemeral" }, - "size_mb": { + "size": { "type": "integer", "description": "Storage capacity in megabytes for ephemeral volumes.", "example": 10240 @@ -41398,6 +41915,17 @@ "type": "string", "description": "Human-readable cleanup result.", "example": "Removed 3 unused image versions. Image versions used by active sandboxes were retained." + }, + "remaining": { + "type": "integer", + "description": "Number of unused image versions left for a next call.", + "minimum": 0, + "example": 0 + }, + "has_more": { + "type": "boolean", + "description": "True when the cleanup stopped on its time limit before every unused version was removed. Call it again to continue.", + "example": false } }, "required": [ @@ -41687,6 +42215,213 @@ "items", "pagination" ] + }, + "SandboxVolumeStatusV1": { + "type": "string", + "description": "Volume status, always uppercase.", + "enum": [ + "DEPLOYING", + "DEPLOYED", + "FAILED", + "DELETING" + ], + "example": "DEPLOYED" + }, + "SandboxVolumeAttachmentV1": { + "type": "object", + "description": "A persistent volume mounted in the sandbox. The volume must exist, be in the sandbox's region and not be mounted elsewhere. A sandbox mounts at most one persistent volume.", + "properties": { + "name": { + "type": "string", + "description": "Name of the volume to mount.", + "minLength": 1, + "example": "datasets" + }, + "mount_path": { + "type": "string", + "description": "Absolute path where the volume is mounted in the sandbox.", + "pattern": "^/", + "example": "/data" + }, + "read_only": { + "type": "boolean", + "description": "Mount the volume read-only. Defaults to false.", + "default": false, + "example": false + } + }, + "required": [ + "name", + "mount_path" + ], + "example": { + "name": "datasets", + "mount_path": "/data", + "read_only": false + } + }, + "CreateSandboxVolumeRequestV1": { + "type": "object", + "description": "Volume to create. Only size is required.", + "properties": { + "name": { + "type": "string", + "description": "Volume name, unique in the team. Generated when omitted.", + "minLength": 1, + "maxLength": 49, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "example": "datasets" + }, + "size": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ], + "x-go-type": "VolumeSize", + "description": "Capacity, as a size (e.g. \"10Gi\", \"512Mi\") or a number of Mi (e.g. 1024). Can grow later, never shrink.", + "example": "10Gi" + }, + "region": { + "type": "string", + "description": "Region of the volume. Defaults to the default sandbox region. Sandboxes mounting it must be in the same region.", + "example": "us-pdx-1" + }, + "labels": { + "$ref": "#/components/schemas/SandboxMetadataLabelsV1" + } + }, + "required": [ + "size" + ] + }, + "UpdateSandboxVolumeRequestV1": { + "type": "object", + "description": "Volume fields to change. name and region cannot be changed.", + "properties": { + "size": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ], + "x-go-type": "VolumeSize", + "description": "New capacity, as a size (e.g. \"20Gi\") or a number of Mi, greater than or equal to the current size.", + "example": "20Gi" + }, + "labels": { + "description": "Labels replacing all current labels, not merged with them. Omit to keep them.", + "allOf": [ + { + "$ref": "#/components/schemas/SandboxMetadataLabelsV1" + } + ] + } + }, + "minProperties": 1 + }, + "SandboxVolumeV1": { + "type": "object", + "description": "Persistent volume.", + "properties": { + "name": { + "type": "string", + "description": "Volume name, used in volume_name path parameters and sandbox volumes.", + "readOnly": true, + "example": "datasets" + }, + "size": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ], + "x-go-type": "VolumeSize", + "description": "Capacity, as a size (e.g. \"10Gi\", \"512Mi\") or a number of Mi (e.g. 1024).", + "example": "10Gi" + }, + "region": { + "type": "string", + "description": "Region of the volume.", + "example": "us-pdx-1" + }, + "status": { + "$ref": "#/components/schemas/SandboxVolumeStatusV1" + }, + "attached_to": { + "type": "string", + "description": "Name of the sandbox the volume is mounted in. Absent when not mounted.", + "readOnly": true, + "example": "baseten-api-review-0916" + }, + "labels": { + "description": "Labels. Omitted from list responses.", + "allOf": [ + { + "$ref": "#/components/schemas/SandboxMetadataLabelsV1" + } + ] + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "example": "2026-10-12T12:00:00Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "example": "2026-10-12T12:00:00Z" + }, + "created_by": { + "type": "string", + "description": "User or service account that created the volume.", + "readOnly": true, + "example": "sandbox-automation" + }, + "updated_by": { + "type": "string", + "description": "User or service account that last updated the volume.", + "readOnly": true, + "example": "sandbox-automation" + } + }, + "required": [ + "name", + "size", + "region", + "status" + ] + }, + "ListSandboxVolumesResponseV1": { + "type": "object", + "description": "One page of volumes.", + "properties": { + "items": { + "type": "array", + "description": "Resources on this page.", + "items": { + "$ref": "#/components/schemas/SandboxVolumeV1" + } + }, + "pagination": { + "$ref": "#/components/schemas/SandboxApiPaginationV1" + } + }, + "required": [ + "items", + "pagination" + ] } }, "parameters": { @@ -42027,6 +42762,17 @@ "example": "7f3k2m9q1x8c4v6b0n5z" } }, + "VolumeName": { + "name": "volume_name", + "in": "path", + "required": true, + "description": "Volume name returned by creation.", + "schema": { + "type": "string", + "minLength": 1, + "example": "datasets" + } + }, "SandboxImageName": { "name": "image_name", "in": "path", @@ -43041,6 +43787,74 @@ "has_more": false } } + }, + "CreateSandboxVolume": { + "summary": "One gigabyte volume", + "value": { + "name": "datasets", + "size": "10Gi", + "region": "us-pdx-1", + "labels": { + "project": "api-review" + } + } + }, + "UpdateSandboxVolume": { + "summary": "Grow a volume", + "value": { + "size": "20Gi" + } + }, + "SandboxVolumeReady": { + "summary": "Volume mounted in a sandbox", + "value": { + "name": "datasets", + "size": "10Gi", + "region": "us-pdx-1", + "status": "DEPLOYED", + "attached_to": "baseten-api-review-0916", + "labels": { + "project": "api-review" + }, + "created_at": "2026-10-12T12:00:00Z", + "updated_at": "2026-10-12T12:00:00Z", + "created_by": "sandbox-automation", + "updated_by": "sandbox-automation" + } + }, + "SandboxVolumeDeleting": { + "summary": "Volume being deleted", + "value": { + "name": "datasets", + "size": "10Gi", + "region": "us-pdx-1", + "status": "DELETING", + "created_at": "2026-10-12T12:00:00Z", + "updated_at": "2026-10-12T12:30:00Z", + "created_by": "sandbox-automation", + "updated_by": "sandbox-automation" + } + }, + "SandboxVolumesPage": { + "summary": "Last page of volumes", + "value": { + "items": [ + { + "name": "datasets", + "size": "10Gi", + "region": "us-pdx-1", + "status": "DEPLOYED", + "attached_to": "baseten-api-review-0916", + "created_at": "2026-10-12T12:00:00Z", + "updated_at": "2026-10-12T12:00:00Z", + "created_by": "sandbox-automation", + "updated_by": "sandbox-automation" + } + ], + "pagination": { + "has_more": false + } + } } } } diff --git a/tests/sandbox/test_client.py b/tests/sandbox/test_client.py index a6e3c78..c724002 100644 --- a/tests/sandbox/test_client.py +++ b/tests/sandbox/test_client.py @@ -22,6 +22,7 @@ SandboxLifecycle, SandboxNetwork, SandboxPort, + SandboxVolumeMount, ) _SANDBOX_URL = "https://sbx.example.dev/" @@ -147,6 +148,7 @@ def test_creates_with_every_field_mapped(self) -> None: ports=[SandboxPort(target=80)], network=SandboxNetwork(subnet="default"), filesystem=SandboxFileSystemOptions(max_memory_usage="80%"), + volumes=[SandboxVolumeMount(name="data", mount_path="/data")], ) assert _body(api.requests[0]) == { "name": "sbx", @@ -165,8 +167,31 @@ def test_creates_with_every_field_mapped(self) -> None: "ports": [{"target": 80}], "network": {"subnet": "default"}, "filesystem": {"max_memory_usage": "80%"}, + "volumes": [{"name": "data", "mount_path": "/data", "read_only": False}], } + def test_maps_mounted_volumes(self) -> None: + api = _Api() + api.route( + "POST", + "/v1/sandboxes/instances", + _record( + volumes=[{"name": "data", "mount_path": "/data", "read_only": True}] + ), + status=201, + ) + result = api.client().create( + volumes=[ + SandboxVolumeMount(name="data", mount_path="/data", read_only=True) + ] + ) + assert _body(api.requests[0]) == { + "volumes": [{"name": "data", "mount_path": "/data", "read_only": True}] + } + assert result.info.volumes == [ + SandboxVolumeMount(name="data", mount_path="/data", read_only=True) + ] + def test_sends_team_id_on_every_call_when_set(self) -> None: api = _Api() api.route("POST", "/v1/sandboxes/instances", _record(), status=201) @@ -462,6 +487,7 @@ def _info() -> SandboxInfo: ports=[], network=None, filesystem=None, + volumes=[], created_at=datetime(2026, 10, 6, tzinfo=UTC), updated_at=None, created_by=None, diff --git a/tests/sandbox/test_e2e.py b/tests/sandbox/test_e2e.py index 0e61aaf..8bc3e4f 100644 --- a/tests/sandbox/test_e2e.py +++ b/tests/sandbox/test_e2e.py @@ -13,8 +13,10 @@ but DOMAIN is missing, the tests error. Every test shares one sandbox, created once per run and always deleted. Each -test runs on both the sync and the async client. One image is pushed per run, -except on Python 3.11, and a sandbox created from it; both are always deleted. +test runs on both the sync and the async client, except the volume tests, which +split between them and create their own volumes and sandbox. One image is +pushed per run, except on Python 3.11, and a sandbox created from it; both are +always deleted. """ from __future__ import annotations @@ -55,6 +57,8 @@ SandboxProcessExecEventExit, SandboxProcessExecEventOutput, SandboxProcessWaitTimeoutError, + SandboxVolumeMount, + VolumeInfo, ) API_KEY = os.environ.get("BASETEN_E2E_TEST_API_KEY", "") @@ -65,6 +69,10 @@ # Labels on every sandbox the suite creates. _LABELS = {"created_by": "e2e"} +# Region of everything the suite creates. +# TODO: Temporary, while other regions' exec planes reject our tokens. +_REGION = "us-was-1" + # Env on the shared sandbox, by name. _SHARED_ENVS = { "E2E_PLAIN": SandboxEnvValue(value="plain-value", secret=False), @@ -101,9 +109,7 @@ def shared() -> Iterator[_Shared]: try: created = client.create( name=name, - # TODO: Temporary, while other regions' exec planes reject our - # tokens. - region="us-was-1", + region=_REGION, labels=_LABELS, envs=_SHARED_ENVS, ) @@ -1104,8 +1110,7 @@ def pushed_image() -> Iterator[_PushedImage]: created = client.create( name=sandbox_name, image=f"{image_name}:latest", - # TODO: Same temporary region pin as the shared sandbox. - region="us-was-1", + region=_REGION, labels=_LABELS, ) assert created.info.status == "DEPLOYED" @@ -1270,7 +1275,8 @@ async def test_previews(sandbox: Sandbox | AsyncSandbox) -> None: await _fetch_preview(created.url, "rejected without a token", _rejected) - expires_at = datetime.now(UTC) + timedelta(hours=1) + # A token cannot outlive its preview, which expires an hour after creation. + expires_at = datetime.now(UTC) + timedelta(minutes=45) if isinstance(sandbox, Sandbox): token = await asyncio.to_thread( sandbox.previews.create_token, preview_name=name, expires_at=expires_at @@ -1397,6 +1403,177 @@ async def _fetch_preview( await asyncio.sleep(0.2) +# Bound on each volume state change the volume tests wait for: deploying, +# resizing, becoming deletable once unmounted. Each has taken about 3s. +_VOLUME_WAIT = 30 +_VOLUME_POLL = 0.2 + + +# The volume tests split the volume calls between the two clients rather than +# each running on both, since every volume and sandbox they create takes time. +# This one covers the volume calls without a sandbox, on the async client. +@pytest.mark.asyncio +async def test_volume_crud() -> None: + _require_env() + started = datetime.now(UTC) + name = _unique() + print(f"volume {name}") + labels = {**_LABELS, "step": "create"} + deleted: VolumeInfo | None = None + async with _async_client() as client: + volumes = client.volumes + # The name is known before creating, so even a partial create is + # removed. + try: + created = await volumes.create( + name=name, size=512, region=_REGION, labels=labels + ) + assert (created.name, created.region) == (name, _REGION) + # The server normalizes either size form to a size string. + assert created.size == "512Mi" + assert created.attached_to is None + assert created.created_by + assert created.created_at is not None + _assert_within_run(started, created.created_at) + begun = time.monotonic() + while (info := await volumes.get_info(name=name)).status != "DEPLOYED": + assert info.status != "FAILED" + if time.monotonic() - begun > _VOLUME_WAIT: + pytest.fail(f"volume still {info.status} after {_VOLUME_WAIT}s") + await asyncio.sleep(_VOLUME_POLL) + print(f"volume deployed {time.monotonic() - begun:.1f}s after create") + assert info.labels == labels + + listed = [volume async for volume in volumes.list(query=name)] + # List omits labels. + assert [(v.name, v.labels) for v in listed if v.name == name] == [ + (name, None) + ] + labels = {**_LABELS, "step": "update"} + updated = await volumes.update(name=name, labels=labels) + assert (updated.size, updated.labels) == ("512Mi", labels) + with pytest.raises(SandboxAPIError) as shrunk: + await volumes.update(name=name, size=256) + assert shrunk.value.status == 400 + + deleted = await volumes.delete(name=name) + assert deleted.status == "DELETING" + finally: + # Cleanup errors are printed rather than raised, so they never hide + # the test's own failure. + if deleted is None: + try: + await volumes.delete(name=name) + except SandboxAPIError as err: + if err.status != 404: + print(f"cleanup of volume {name} failed: {err!r}") + + +# Covers mounting a volume and the volume calls that interact with it, on the +# sync client. +def test_volume_mount() -> None: + _require_env() + volume_name, sandbox_name = _unique(), _unique() + print(f"volume {volume_name}, sandbox {sandbox_name}") + volume_live = sandbox_live = False + deleted: VolumeInfo | None = None + with _client() as client: + volumes = client.volumes + + def deployed() -> bool: + info = volumes.get_info(name=volume_name) + assert info.status != "FAILED" + return info.status == "DEPLOYED" + + def deletable() -> bool: + # Fails until the deleted sandbox has let go of the volume. + nonlocal deleted + try: + deleted = volumes.delete(name=volume_name) + except SandboxAPIError as err: + if err.status == 409: + return False + raise + return True + + # Each is marked live before creating, so even a partial create is + # removed. + try: + volume_live = True + volumes.create(name=volume_name, size=512, region=_REGION, labels=_LABELS) + elapsed = _volume_eventually("volume deployed", deployed) + print(f"volume deployed {elapsed:.1f}s after create") + + mount = SandboxVolumeMount(name=volume_name, mount_path="/data") + sandbox_live = True + sandbox = client.create( + name=sandbox_name, region=_REGION, labels=_LABELS, volumes=[mount] + ) + assert sandbox.info.status == "DEPLOYED" + assert sandbox.info.volumes == [mount] + assert volumes.get_info(name=volume_name).attached_to == sandbox_name + sandbox.fs.write(path="/data/written.txt", content="written") + assert sandbox.fs.read(path="/data/written.txt") == "written" + initial = _volume_megabytes(sandbox) + print(f"df reports {initial} MB for a 512 MB volume") + + with pytest.raises(SandboxAPIError) as mounted: + volumes.delete(name=volume_name) + assert mounted.value.status == 409 + + assert volumes.update(name=volume_name, size="1Gi").size == "1Gi" + elapsed = _volume_eventually( + "sandbox sees the grown volume", + lambda: _volume_megabytes(sandbox) > initial, + ) + print(f"sandbox saw the grown volume {elapsed:.1f}s after update") + + client.delete(name=sandbox_name) + sandbox_live = False + elapsed = _volume_eventually("volume deletable", deletable) + volume_live = False + print(f"volume deletable {elapsed:.1f}s after sandbox delete") + assert deleted is not None + assert deleted.status == "DELETING" + finally: + # The sandbox goes first, since a mounted volume cannot be deleted. + # Cleanup errors are printed rather than raised, so they never hide + # the test's own failure. + if sandbox_live: + try: + client.delete(name=sandbox_name) + except SandboxAPIError as err: + if err.status != 404: + print(f"cleanup of sandbox {sandbox_name} failed: {err!r}") + if volume_live: + try: + _volume_eventually("volume deletable in cleanup", deletable) + except SandboxAPIError as err: + if err.status != 404: + print(f"cleanup of volume {volume_name} failed: {err!r}") + except pytest.fail.Exception as err: + print(f"cleanup of volume {volume_name} failed: {err}") + + +def _volume_eventually(what: str, check: Callable[[], bool]) -> float: + # Polls until check passes, within _VOLUME_WAIT; any error check raises ends + # the wait. Returns how long it took. + begun = time.monotonic() + while not check(): + if time.monotonic() - begun > _VOLUME_WAIT: + pytest.fail(f"{what}: not within {_VOLUME_WAIT}s") + time.sleep(_VOLUME_POLL) + return time.monotonic() - begun + + +def _volume_megabytes(sandbox: Sandbox) -> int: + process = sandbox.process.exec( + command="sh -c 'df -m /data | tail -n 1'", wait_for_completion=True + ) + assert process.exit_code == 0 + return int(process.stdout.split()[1]) + + def test_uses_http2_by_default(shared: _Shared) -> None: versions: list[str] = [] with _client() as client: diff --git a/tests/sandbox/test_images.py b/tests/sandbox/test_images.py index 6151afa..3410a50 100644 --- a/tests/sandbox/test_images.py +++ b/tests/sandbox/test_images.py @@ -19,6 +19,7 @@ AsyncSandboxClient, ImageBuilder, ImageBuildError, + ImageCleanupProgress, ImageCleanupResult, ImageIgnoreFileOptions, ImageIgnoreFileProcessorOptions, @@ -648,6 +649,24 @@ def test_lists_tags_deletes_a_tag_gets_deletes_and_cleans_up(self) -> None: assert images.delete(name="img").tag_count == 0 assert images.cleanup() == ImageCleanupResult(deleted=3, message="done") + def test_cleanup_calls_again_while_more_remain(self) -> None: + api = _Api() + api.route( + "POST", + "/v1/sandboxes/cleanup_images", + {"deleted": 2, "message": "m1", "remaining": 5, "has_more": True}, + {"deleted": 4, "message": "m2", "remaining": 1, "has_more": True}, + {"deleted": 1, "message": "m3", "remaining": 0, "has_more": False}, + ) + reported: list[ImageCleanupProgress] = [] + result = api.client().images.cleanup(progress=reported.append) + assert result == ImageCleanupResult(deleted=7, message="m3") + assert reported == [ + ImageCleanupProgress(deleted=2, remaining=5, message="m1"), + ImageCleanupProgress(deleted=6, remaining=1, message="m2"), + ] + assert len(api.requests) == 3 + def test_lists_library_images(self) -> None: api = _Api() api.route( @@ -690,7 +709,7 @@ def test_lists_library_images(self) -> None: name="v", mount_path="/data", type=None, - size_mb=None, + size=None, read_only=False, ) ], @@ -858,6 +877,24 @@ async def test_imports_a_registry_image_with_build_sizes(self) -> None: } assert api.paths()[1:] == ["GET /v1/sandboxes/images/img"] + @pytest.mark.asyncio + async def test_cleanup_awaits_an_async_progress(self) -> None: + api = _Api() + api.route( + "POST", + "/v1/sandboxes/cleanup_images", + {"deleted": 2, "message": "m1", "remaining": 1, "has_more": True}, + {"deleted": 1, "message": "m2", "remaining": 0, "has_more": False}, + ) + reported: list[ImageCleanupProgress] = [] + + async def progress(update: ImageCleanupProgress) -> None: + reported.append(update) + + result = await api.async_client().images.cleanup(progress=progress) + assert result == ImageCleanupResult(deleted=3, message="m2") + assert reported == [ImageCleanupProgress(deleted=2, remaining=1, message="m1")] + @pytest.mark.asyncio async def test_times_out_waiting_as_an_image_build_error(self) -> None: api = _Api() diff --git a/tests/sandbox/test_parity.py b/tests/sandbox/test_parity.py index a4219f5..fc6a5c9 100644 --- a/tests/sandbox/test_parity.py +++ b/tests/sandbox/test_parity.py @@ -19,6 +19,8 @@ AsyncSandboxPreviews, AsyncSandboxProcess, AsyncSandboxStream, + AsyncVolumeClient, + ImageCleanupProgress, ImageCleanupResult, ImageClient, ImageInfo, @@ -53,6 +55,9 @@ SandboxProcessInfo, SandboxProcessLogs, SandboxStream, + SandboxVolumeMount, + VolumeClient, + VolumeInfo, ) # Methods of an async class that are plain functions, since they make no call. @@ -69,6 +74,7 @@ (SandboxFileSystem, AsyncSandboxFileSystem), (SandboxPreviews, AsyncSandboxPreviews), (ImageClient, AsyncImageClient), + (VolumeClient, AsyncVolumeClient), (SandboxStream, AsyncSandboxStream), ], ) @@ -288,6 +294,15 @@ def _kwargs(method: Callable[..., Any]) -> set[str]: "ImageCleanupResult": _Parity( ours=_dataclass_fields(ImageCleanupResult), generated=baseten.client.managementapi.CleanupSandboxImagesResponse, + generated_only={ + "remaining": "reported to the progress callback", + "has_more": "called again until false", + }, + ), + "ImageCleanupProgress": _Parity( + ours=_dataclass_fields(ImageCleanupProgress), + generated=baseten.client.managementapi.CleanupSandboxImagesResponse, + generated_only={"has_more": "always true between calls"}, ), "ImageLibraryInfo": _Parity( ours=_dataclass_fields(ImageLibraryInfo), @@ -311,6 +326,31 @@ def _kwargs(method: Callable[..., Any]) -> set[str]: "cursor": "paged internally", }, ), + "SandboxVolumeMount": _Parity( + ours=_dataclass_fields(SandboxVolumeMount), + generated=baseten.client.managementapi.SandboxVolumeAttachment, + ), + "VolumeInfo": _Parity( + ours=_dataclass_fields(VolumeInfo), + generated=baseten.client.managementapi.SandboxVolume, + ), + "VolumeClient.create": _Parity( + ours=_kwargs(VolumeClient.create), + generated=baseten.client.managementapi.CreateSandboxVolumeRequest, + ), + "VolumeClient.update": _Parity( + ours=_kwargs(VolumeClient.update) - {"name"}, + generated=baseten.client.managementapi.UpdateSandboxVolumeRequest, + ), + "VolumeClient.list": _Parity( + ours=_kwargs(VolumeClient.list), + generated=baseten.client.managementapi.ListSandboxVolumesParams, + renamed={"limit": "page_size", "q": "query", "status": "statuses"}, + generated_only={ + "team_id": "the client's team", + "cursor": "paged internally", + }, + ), "SandboxFileSystem.find": _Parity( ours=_kwargs(SandboxFileSystem.find) - {"path"}, generated=baseten.client.sandboxapi.GetFilesystemFindParams, diff --git a/tests/sandbox/test_volumes.py b/tests/sandbox/test_volumes.py new file mode 100644 index 0000000..e038289 --- /dev/null +++ b/tests/sandbox/test_volumes.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import json +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from typing import Any + +import httpx +import pytest + +from baseten.sandbox import ( + AsyncSandboxClient, + SandboxAPIError, + SandboxClient, + VolumeInfo, +) + +_VOLUMES = "/v1/sandboxes/volumes" + +_VOLUME = { + "name": "data", + "size": 1024, + "region": "us-pdx-1", + "status": "DEPLOYED", + "attached_to": "sb", + "labels": {"k": "v"}, + "created_at": "2026-10-01T00:00:00Z", + "created_by": "me", +} + + +class _Api: + """Fake control plane and token exchange, recording requests.""" + + def __init__(self) -> None: + self.requests: list[httpx.Request] = [] + self.routes: dict[ + tuple[str, str], Callable[[httpx.Request], httpx.Response] + ] = {} + + def route(self, method: str, path: str, body: Any, *, status: int = 200) -> None: + self.routes[(method, path)] = lambda request: httpx.Response(status, json=body) + + def handle(self, request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/token": + return httpx.Response( + 200, + json={ + "token": "token", + "expires_at": (datetime.now(UTC) + timedelta(hours=2)).isoformat(), + }, + ) + self.requests.append(request) + return self.routes[(request.method, request.url.path)](request) + + def client(self, **options: Any) -> SandboxClient: + return SandboxClient( + api_key="key", + base_url_override="https://api.test", + http_client_override=httpx.Client( + transport=httpx.MockTransport(self.handle) + ), + **options, + ) + + def async_client(self) -> AsyncSandboxClient: + async def handle(request: httpx.Request) -> httpx.Response: + return self.handle(request) + + return AsyncSandboxClient( + api_key="key", + base_url_override="https://api.test", + http_client_override=httpx.AsyncClient( + transport=httpx.MockTransport(handle) + ), + ) + + +def _body(request: httpx.Request) -> Any: + return json.loads(request.content) + + +class TestVolumeClient: + def test_creates_sending_only_set_fields_and_maps_record(self) -> None: + api = _Api() + api.route("POST", _VOLUMES, _VOLUME, status=201) + info = api.client(team_id="team-1").volumes.create( + name="data", size=1024, labels={} + ) + request = api.requests[0] + assert dict(request.url.params) == {"team_id": "team-1"} + assert _body(request) == {"name": "data", "size": 1024, "labels": {}} + assert info == VolumeInfo( + name="data", + size=1024, + region="us-pdx-1", + status="DEPLOYED", + attached_to="sb", + labels={"k": "v"}, + created_at=datetime(2026, 10, 1, tzinfo=UTC), + updated_at=None, + created_by="me", + updated_by=None, + ) + + def test_creates_with_a_string_size(self) -> None: + api = _Api() + api.route( + "POST", + _VOLUMES, + {"name": "data", "size": "10Gi", "region": "r", "status": "DEPLOYING"}, + status=201, + ) + info = api.client().volumes.create(size="10Gi", region="r") + assert _body(api.requests[0]) == {"size": "10Gi", "region": "r"} + assert info.size == "10Gi" + + def test_gets_info(self) -> None: + api = _Api() + api.route("GET", f"{_VOLUMES}/data", _VOLUME) + assert api.client().volumes.get_info(name="data").name == "data" + assert "team_id" not in api.requests[0].url.params + + def test_lists_across_pages_with_filters_omitting_labels(self) -> None: + api = _Api() + pages = iter( + [ + { + "items": [ + {"name": "a", "size": 1, "region": "r", "status": "DEPLOYED"} + ], + "pagination": {"has_more": True, "cursor": "c1"}, + }, + { + "items": [ + {"name": "b", "size": 1, "region": "r", "status": "FAILED"} + ], + "pagination": {"has_more": False}, + }, + ] + ) + api.routes[("GET", _VOLUMES)] = lambda request: httpx.Response( + 200, json=next(pages) + ) + volumes = list( + api.client().volumes.list( + query="x", statuses=["DEPLOYED", "FAILED"], page_size=1 + ) + ) + assert [volume.name for volume in volumes] == ["a", "b"] + # List omits labels, distinct from a volume with none. + assert [volume.labels for volume in volumes] == [None, None] + first, second = api.requests + assert first.url.params.get_list("status") == ["DEPLOYED", "FAILED"] + assert (first.url.params["q"], first.url.params["limit"]) == ("x", "1") + assert "cursor" not in first.url.params + assert second.url.params["cursor"] == "c1" + + def test_updates_sending_only_set_fields(self) -> None: + api = _Api() + api.route("PATCH", f"{_VOLUMES}/data", _VOLUME) + volumes = api.client().volumes + volumes.update(name="data", size="20Gi", labels={}) + volumes.update(name="data", labels={"k": "v"}) + volumes.update(name="data") + assert _body(api.requests[0]) == {"size": "20Gi", "labels": {}} + assert _body(api.requests[1]) == {"labels": {"k": "v"}} + assert _body(api.requests[2]) == {} + + def test_deletes_returning_deleting_record(self) -> None: + api = _Api() + api.route( + "DELETE", + f"{_VOLUMES}/data", + {"name": "data", "size": 1024, "region": "r", "status": "DELETING"}, + status=202, + ) + assert api.client().volumes.delete(name="data").status == "DELETING" + + def test_raises_api_errors(self) -> None: + api = _Api() + api.route( + "DELETE", + f"{_VOLUMES}/data", + {"code": "RESOURCE_IN_USE", "message": "mounted"}, + status=409, + ) + with pytest.raises(SandboxAPIError) as raised: + api.client().volumes.delete(name="data") + assert raised.value.status == 409 + + +class TestAsyncVolumeClient: + @pytest.mark.asyncio + async def test_creates_gets_lists_updates_and_deletes(self) -> None: + api = _Api() + api.route("POST", _VOLUMES, _VOLUME, status=201) + api.route("GET", f"{_VOLUMES}/data", _VOLUME) + api.route( + "GET", _VOLUMES, {"items": [_VOLUME], "pagination": {"has_more": False}} + ) + api.route("PATCH", f"{_VOLUMES}/data", _VOLUME) + api.route( + "DELETE", f"{_VOLUMES}/data", {**_VOLUME, "status": "DELETING"}, status=202 + ) + volumes = api.async_client().volumes + assert (await volumes.create(size="1Gi")).name == "data" + assert (await volumes.get_info(name="data")).name == "data" + assert [volume.name async for volume in volumes.list()] == ["data"] + assert (await volumes.update(name="data", size=2048)).name == "data" + assert (await volumes.delete(name="data")).status == "DELETING" + assert _body(api.requests[0]) == {"size": "1Gi"} + assert _body(api.requests[3]) == {"size": 2048} diff --git a/uv.lock b/uv.lock index 87985aa..c81f061 100644 --- a/uv.lock +++ b/uv.lock @@ -143,7 +143,7 @@ wheels = [ [[package]] name = "black" -version = "26.5.1" +version = "26.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -153,38 +153,38 @@ dependencies = [ { name = "platformdirs" }, { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, - { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, - { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, - { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, - { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, - { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, - { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, - { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, - { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, - { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, - { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, - { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, - { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, - { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f8/65/a9611a6ec0a8c88d86e59385da02d68d9533f7e86a05913d20c67be54029/black-26.10.1.tar.gz", hash = "sha256:5f9f83beae62437e060dafd53d7f1fc327e3d3494f74d72ee5c2b73eb90fc4e7", size = 741979, upload-time = "2026-10-10T04:13:40.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/1e/c1d4f71299d45f4ff54c9c42ef9214d6b228223f196bc1bb2ee196aeaeee/black-26.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9a0219b29cd70e49f920acb7081e6ce5025c719008447c521d0200dcad93206a", size = 2121821, upload-time = "2026-10-10T04:18:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e2/43944049c0945199bbd5c8b291b85306bfd0eeccbc908bf65538a69ff1b0/black-26.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7bdade400bfe24d78a7762896acc2f9a8e1a17fb0fd0536bf6b7c7097cf3eec7", size = 1961908, upload-time = "2026-10-10T04:18:38.942Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/9616d58f69f0e4bfa89ebe8686b6ef50f4b4f0f91237ae4c76f34dabf8aa/black-26.10.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff57f63029aa1353fa8b1b0c8971fd88a6c92dc766608d2eee33ad2deb23270e", size = 2019042, upload-time = "2026-10-10T04:18:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/0415cce69e2402a5c186cdb5462a0edd3fa44d69d1a1630e68e58daaa6a4/black-26.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:3414a0c52901964dceabd98c7c56beac0f964115a116ecedcce7247359b14017", size = 1608383, upload-time = "2026-10-10T04:18:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/a9/61/af18e716432e932f166ce9dfa419ce3f05fd5ef8c52c0510d27ae41aaa51/black-26.10.1-cp311-cp311-win_arm64.whl", hash = "sha256:1935b32f5326028019856e18cb42b4da63db23765dc84464cec723e0de478a9b", size = 1466048, upload-time = "2026-10-10T04:18:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/357ccdd12e8f8429539f8f52edc153de58acba84f6670ac36c254a276691/black-26.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe85fc4019bee59bc495c0f2a8ee76c5cd02c7015508d94a967ba2376f39a52c", size = 2167369, upload-time = "2026-10-10T04:18:45.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a2/4709110a7ca326ba7b4f81a669fbebc20653c79a972b0f8de4ba5b5c5e68/black-26.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:182f6c32be38074b16d378498c498b32cb51928178ee611485344972c35ec9c6", size = 1990732, upload-time = "2026-10-10T04:18:48.252Z" }, + { url = "https://files.pythonhosted.org/packages/1e/96/9f8fa839c169d19c38ce6582d9449f2d7d70dabd313ac28f15a930260c1f/black-26.10.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5347d760f0c02bb00dd249384cab71c3bf828b4f68d5b401eb116e0390f147d", size = 2065235, upload-time = "2026-10-10T04:18:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ee/929eaf7de6f05a3141d6df72acf9248118f49a5e48f9375be3180e9babf7/black-26.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:4d9a90516db1d99c25dbb20cc0998e0e01531dd903466c7744e56d66f864220a", size = 1621579, upload-time = "2026-10-10T04:18:51.59Z" }, + { url = "https://files.pythonhosted.org/packages/90/3a/8b6a44abf9648b087311003b91298226c1763172cd8d223ece5b7cf8f795/black-26.10.1-cp312-cp312-win_arm64.whl", hash = "sha256:2ffbc023a12d0c729408823b8f10514490bd0baa301d0d4e21a7240249f9507f", size = 1469662, upload-time = "2026-10-10T04:18:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/99/6b/bc0d39990bd7a71457d669639fa06acf6191bec28bafe8b8103f597cce76/black-26.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b6272cfd7e1e8e271f5b0e0207259fe2834687e5cb9b5f620b34a44db9754993", size = 2153812, upload-time = "2026-10-10T04:18:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/85/0e/cc83b88a6b1a51fc051aa4e24663918f0bf4fa9fca98552ee93a125a6780/black-26.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:978113a40223a6aaefc17364176a809a320e6b288683841427fff04c6d7b4130", size = 1980352, upload-time = "2026-10-10T04:18:56.849Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0f/4dd24ea0ebadbe05e293c3ea7fbebd4389f50508f714640c9b309a0849d2/black-26.10.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03c0ddd93bb392e71209903a691767eb366fe1a76deb9509ccbaae9e1f14bb52", size = 2055268, upload-time = "2026-10-10T04:18:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/a2/06/221f8e81891ecf5e4df9c258a11037997f1f218533ffe4fa1dc4db37f19c/black-26.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6dba8138cdc99061ef07b958ac082d2aa057b6961d1936f9717c350f02bab5f", size = 1625938, upload-time = "2026-10-10T04:19:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/2b/3b/763d2dd073fc1e2cbf0fe5e584fe96913bf0d14cd725d1116f4b38453adf/black-26.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:d42dd2fac7c342ae67e64ee99c9532e20b2a84e92c79ed3317fa2ef54c801d93", size = 1470752, upload-time = "2026-10-10T04:19:03.009Z" }, + { url = "https://files.pythonhosted.org/packages/76/7b/e8d275b23f3023881c0b0d8ce3bbda0d100c79bdef87fb6518bf9e0c5040/black-26.10.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8375962579d537364cc0efa19b1474481915d3a793f9fc0774901814c5e5b5f4", size = 2147731, upload-time = "2026-10-10T04:19:04.643Z" }, + { url = "https://files.pythonhosted.org/packages/ee/62/e44f86ee5fc7b893ee0ee935a79c785932da7758d2c339749d90a20ef213/black-26.10.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d8b3a9074a680b3c5749633714e9ae3992a1e5a23343a97ad61cd9b119b444d2", size = 1981714, upload-time = "2026-10-10T04:19:06.287Z" }, + { url = "https://files.pythonhosted.org/packages/89/2f/e12ca76edfcd037fe9e7e7635d9468d1d51255cf25a01b2f8f97a38f147d/black-26.10.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:289282aa2e09d3162312a3be1788ff21b08e9ea9cc4a81e656024728b32428fb", size = 2047807, upload-time = "2026-10-10T04:19:08.193Z" }, + { url = "https://files.pythonhosted.org/packages/c3/af/676d3c5cbb2ab0f17c8459b3ae8ea15ebdcc5a2f1c1c586228ec85b8897f/black-26.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:5cd88fd7b444ca51f3fc883b6f6657ea53a258b0b2eef6d9f2dfcfa17ce0e27b", size = 1652537, upload-time = "2026-10-10T04:19:09.817Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7b/860022d369fdd5fe14280b1e7109f58753c371ff91ce2311d68a056e488a/black-26.10.1-cp314-cp314-win_arm64.whl", hash = "sha256:2520037aa62f8a1454d0811b8f5c88b444445b03a4bfba480d8d220893b64c34", size = 1504226, upload-time = "2026-10-10T04:19:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b1/28/9dd29175c1db777e6189e2a0bb5f37101adf19c3e509b236c8920f778d0b/black-26.10.1-py3-none-any.whl", hash = "sha256:28842f9a8207cc1df6eb983a35a14c5a0dfcd603d214fe82d84bef552afd2e3a", size = 239267, upload-time = "2026-10-10T04:13:38.808Z" }, ] [[package]] name = "certifi" -version = "2026.7.22" +version = "2026.10.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, ] [[package]] From db6d41cc84890b580519f2921bcbf3ca9f1d86be Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Wed, 28 Oct 2026 10:39:45 -0500 Subject: [PATCH 2/6] Wait for e2e sandboxes to deploy after create --- tests/sandbox/test_e2e.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/sandbox/test_e2e.py b/tests/sandbox/test_e2e.py index 8bc3e4f..7a5f193 100644 --- a/tests/sandbox/test_e2e.py +++ b/tests/sandbox/test_e2e.py @@ -113,9 +113,7 @@ def shared() -> Iterator[_Shared]: labels=_LABELS, envs=_SHARED_ENVS, ) - # Create returns once the sandbox is up, so there is nothing to wait - # for. - assert created.info.status == "DEPLOYED" + _wait_deployed(client, name) except BaseException: try: client.delete(name=name) @@ -1107,13 +1105,13 @@ def pushed_image() -> Iterator[_PushedImage]: pushed = client.images.push(name=image_name, builder=builder) assert (pushed.name, pushed.status) == (image_name, "BUILT") sandbox_created = True - created = client.create( + client.create( name=sandbox_name, image=f"{image_name}:latest", region=_REGION, labels=_LABELS, ) - assert created.info.status == "DEPLOYED" + _wait_deployed(client, sandbox_name) yield _PushedImage(name=image_name, builder=builder, sandbox_name=sandbox_name) finally: # The image cannot be deleted while a sandbox uses it. Cleanup errors @@ -1509,8 +1507,8 @@ def deletable() -> bool: sandbox = client.create( name=sandbox_name, region=_REGION, labels=_LABELS, volumes=[mount] ) - assert sandbox.info.status == "DEPLOYED" assert sandbox.info.volumes == [mount] + _wait_deployed(client, sandbox_name) assert volumes.get_info(name=volume_name).attached_to == sandbox_name sandbox.fs.write(path="/data/written.txt", content="written") assert sandbox.fs.read(path="/data/written.txt") == "written" @@ -1685,6 +1683,18 @@ def write() -> None: writer.join() +def _wait_deployed(client: SandboxClient, name: str) -> None: + # Create waits only briefly for deployment, so it may return the sandbox + # still deploying. + deadline = time.monotonic() + 120 + while (info := client.get_info(name=name)).status != "DEPLOYED": + if info.status != "DEPLOYING": + pytest.fail(f"sandbox {name} is {info.status}, not deploying") + if time.monotonic() > deadline: + pytest.fail(f"sandbox {name} is still deploying") + time.sleep(1) + + def _delete_and_wait(client: SandboxClient, name: str) -> None: # Cleanup only: a sandbox must be gone before its image can be deleted. client.delete(name=name) From ae5fb9a2ca4d8e06ccd4a085766be4c4cffc8ed6 Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Wed, 28 Oct 2026 11:26:39 -0500 Subject: [PATCH 3/6] Revert "Wait for e2e sandboxes to deploy after create" This reverts commit db6d41cc84890b580519f2921bcbf3ca9f1d86be. --- tests/sandbox/test_e2e.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/tests/sandbox/test_e2e.py b/tests/sandbox/test_e2e.py index 7a5f193..8bc3e4f 100644 --- a/tests/sandbox/test_e2e.py +++ b/tests/sandbox/test_e2e.py @@ -113,7 +113,9 @@ def shared() -> Iterator[_Shared]: labels=_LABELS, envs=_SHARED_ENVS, ) - _wait_deployed(client, name) + # Create returns once the sandbox is up, so there is nothing to wait + # for. + assert created.info.status == "DEPLOYED" except BaseException: try: client.delete(name=name) @@ -1105,13 +1107,13 @@ def pushed_image() -> Iterator[_PushedImage]: pushed = client.images.push(name=image_name, builder=builder) assert (pushed.name, pushed.status) == (image_name, "BUILT") sandbox_created = True - client.create( + created = client.create( name=sandbox_name, image=f"{image_name}:latest", region=_REGION, labels=_LABELS, ) - _wait_deployed(client, sandbox_name) + assert created.info.status == "DEPLOYED" yield _PushedImage(name=image_name, builder=builder, sandbox_name=sandbox_name) finally: # The image cannot be deleted while a sandbox uses it. Cleanup errors @@ -1507,8 +1509,8 @@ def deletable() -> bool: sandbox = client.create( name=sandbox_name, region=_REGION, labels=_LABELS, volumes=[mount] ) + assert sandbox.info.status == "DEPLOYED" assert sandbox.info.volumes == [mount] - _wait_deployed(client, sandbox_name) assert volumes.get_info(name=volume_name).attached_to == sandbox_name sandbox.fs.write(path="/data/written.txt", content="written") assert sandbox.fs.read(path="/data/written.txt") == "written" @@ -1683,18 +1685,6 @@ def write() -> None: writer.join() -def _wait_deployed(client: SandboxClient, name: str) -> None: - # Create waits only briefly for deployment, so it may return the sandbox - # still deploying. - deadline = time.monotonic() + 120 - while (info := client.get_info(name=name)).status != "DEPLOYED": - if info.status != "DEPLOYING": - pytest.fail(f"sandbox {name} is {info.status}, not deploying") - if time.monotonic() > deadline: - pytest.fail(f"sandbox {name} is still deploying") - time.sleep(1) - - def _delete_and_wait(client: SandboxClient, name: str) -> None: # Cleanup only: a sandbox must be gone before its image can be deleted. client.delete(name=name) From 7ba1e479fcd3e9f04b31aedc23952262ecedbf59 Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Wed, 28 Oct 2026 16:29:30 -0500 Subject: [PATCH 4/6] Regenerate from the deployed specs and drop library image volume type and size --- baseten/client/managementapi/_models.py | 33 +++++++++++-------------- baseten/client/modelconfig/_models.py | 2 +- baseten/client/sandboxapi/_models.py | 2 +- baseten/sandbox/_images.py | 10 +------- scripts/apigen/specs/config.schema.json | 2 +- scripts/apigen/specs/management.json | 21 +++++----------- scripts/apigen/specs/sandbox.yml | 4 +-- tests/sandbox/test_images.py | 2 -- 8 files changed, 27 insertions(+), 49 deletions(-) diff --git a/baseten/client/managementapi/_models.py b/baseten/client/managementapi/_models.py index 376c4a2..f1d1936 100644 --- a/baseten/client/managementapi/_models.py +++ b/baseten/client/managementapi/_models.py @@ -7590,11 +7590,21 @@ class LibraryListingMetadata(BaseModel): parameter_count: Annotated[int | None, Field(title="Parameter Count")] = None context_length: Annotated[int | None, Field(title="Context Length")] = None input_modalities: Annotated[ - list[Literal["text", "image", "audio", "video", "embedding", "rerank"] | str], + list[ + Literal[ + "text", "image", "audio", "video", "embedding", "rerank", "decision" + ] + | str + ], Field(title="Input Modalities"), ] = ["text"] output_modalities: Annotated[ - list[Literal["text", "image", "audio", "video", "embedding", "rerank"] | str], + list[ + Literal[ + "text", "image", "audio", "video", "embedding", "rerank", "decision" + ] + | str + ], Field(title="Output Modalities"), ] = ["text"] license: Annotated[str, Field(title="License")] @@ -10035,8 +10045,8 @@ class SandboxLibraryImageVolume(BaseModel): name: Annotated[ str, Field( - description="Volume name, or an internal identifier for ephemeral volumes.", - examples=["scratch"], + description="Volume name, used in volume_name path parameters and sandbox volumes.", + examples=["datasets"], ), ] mount_path: Annotated[ @@ -10046,19 +10056,6 @@ class SandboxLibraryImageVolume(BaseModel): examples=["/mnt/data"], ), ] - type: Annotated[ - str | None, - Field( - description="Volume type, persistent when empty.", examples=["ephemeral"] - ), - ] = None - size: Annotated[ - int | None, - Field( - description="Storage capacity in megabytes for ephemeral volumes.", - examples=[10240], - ), - ] = None read_only: Annotated[ bool | None, Field(description="Whether the volume is mounted read-only.", examples=[False]), @@ -10121,7 +10118,7 @@ class SandboxPreviewConfiguration(BaseModel): request_headers: Annotated[ dict[str, str] | None, Field( - description='Headers added to every request forwarded to the sandbox, for example an Authorization header expected by the application. Responses return each name with the value "****", except a GET with show_secrets=true by a workspace administrator. Sending "****" back as a value returns 400. A PATCH replaces the whole set.', + description='Headers added to every request forwarded to the sandbox, for example an Authorization header expected by the application. Responses return each name with the value "****", except a GET with show_secrets=true by a workspace administrator. Sending "****" back as a value returns 400. A PATCH replaces the whole set. Some header names are reserved by the platform.', examples=[{"Authorization": "Bearer app-internal-token"}], ), ] = None diff --git a/baseten/client/modelconfig/_models.py b/baseten/client/modelconfig/_models.py index 795af1a..64ca410 100644 --- a/baseten/client/modelconfig/_models.py +++ b/baseten/client/modelconfig/_models.py @@ -127,7 +127,7 @@ class DockerServer(BaseModel): rebuild_free: Annotated[ bool | None, Field( - description="Reject requirements, requirements_file, system_packages, and build_commands. The base image must already contain them.", + description="Do not install packages or rewrite the image entrypoint. Rejects requirements, requirements_file, system_packages, and build_commands. The base image must already contain them.", title="Rebuild Free", ), ] = None diff --git a/baseten/client/sandboxapi/_models.py b/baseten/client/sandboxapi/_models.py index e9640b2..5b631b6 100644 --- a/baseten/client/sandboxapi/_models.py +++ b/baseten/client/sandboxapi/_models.py @@ -613,7 +613,7 @@ class GetWatchFilesystemParams(BaseModel): ignore: Annotated[ str | None, Field( - description="Comma-separated substrings; events whose full path contains one are skipped" + description="Comma-separated substrings; events whose full path contains one are skipped, and matching directories are not watched at all" ), ] = None diff --git a/baseten/sandbox/_images.py b/baseten/sandbox/_images.py index eeeac0f..f80c0fa 100644 --- a/baseten/sandbox/_images.py +++ b/baseten/sandbox/_images.py @@ -166,17 +166,11 @@ class ImageLibraryVolume: """A volume attachment a built-in image suggests, in :class:`ImageLibraryInfo`.""" name: str - """Volume name, or an internal identifier for an ephemeral volume.""" + """Volume name.""" mount_path: str """Absolute path the volume is mounted at.""" - type: str | None - """Volume type, persistent when empty or ``None``.""" - - size: int | None - """Storage capacity in megabytes of an ephemeral volume.""" - read_only: bool """Whether the volume is mounted read-only.""" @@ -1495,8 +1489,6 @@ def _library_info( ImageLibraryVolume( name=volume.name, mount_path=volume.mount_path, - type=volume.type, - size=volume.size, read_only=bool(volume.read_only), ) for volume in options.volumes or [] diff --git a/scripts/apigen/specs/config.schema.json b/scripts/apigen/specs/config.schema.json index aac4076..b9fbb1d 100644 --- a/scripts/apigen/specs/config.schema.json +++ b/scripts/apigen/specs/config.schema.json @@ -574,7 +574,7 @@ } ], "default": null, - "description": "Reject requirements, requirements_file, system_packages, and build_commands. The base image must already contain them.", + "description": "Do not install packages or rewrite the image entrypoint. Rejects requirements, requirements_file, system_packages, and build_commands. The base image must already contain them.", "title": "Rebuild Free" } }, diff --git a/scripts/apigen/specs/management.json b/scripts/apigen/specs/management.json index d203fde..8972135 100644 --- a/scripts/apigen/specs/management.json +++ b/scripts/apigen/specs/management.json @@ -33840,7 +33840,8 @@ "audio", "video", "embedding", - "rerank" + "rerank", + "decision" ], "title": "LibraryListingModality", "type": "string" @@ -41746,24 +41747,14 @@ "properties": { "name": { "type": "string", - "description": "Volume name, or an internal identifier for ephemeral volumes.", - "example": "scratch" + "description": "Volume name, used in volume_name path parameters and sandbox volumes.", + "example": "datasets" }, "mount_path": { "type": "string", "description": "Absolute filesystem path where the volume is mounted.", "example": "/mnt/data" }, - "type": { - "type": "string", - "description": "Volume type, persistent when empty.", - "example": "ephemeral" - }, - "size": { - "type": "integer", - "description": "Storage capacity in megabytes for ephemeral volumes.", - "example": 10240 - }, "read_only": { "type": "boolean", "description": "Whether the volume is mounted read-only.", @@ -41958,7 +41949,7 @@ }, "SandboxPreviewHeadersV1": { "type": "object", - "description": "HTTP headers as name-value pairs.", + "description": "HTTP headers as name-value pairs. At most 20 headers and 8 KiB of names and values in total unless your quota allows more.", "additionalProperties": { "type": "string" }, @@ -41984,7 +41975,7 @@ ] }, "request_headers": { - "description": "Headers added to every request forwarded to the sandbox, for example an Authorization header expected by the application. Responses return each name with the value \"****\", except a GET with show_secrets=true by a workspace administrator. Sending \"****\" back as a value returns 400. A PATCH replaces the whole set.", + "description": "Headers added to every request forwarded to the sandbox, for example an Authorization header expected by the application. Responses return each name with the value \"****\", except a GET with show_secrets=true by a workspace administrator. Sending \"****\" back as a value returns 400. A PATCH replaces the whole set. Some header names are reserved by the platform.", "allOf": [ { "$ref": "#/components/schemas/SandboxPreviewHeadersV1" diff --git a/scripts/apigen/specs/sandbox.yml b/scripts/apigen/specs/sandbox.yml index 58e5a2f..1a0e0dd 100644 --- a/scripts/apigen/specs/sandbox.yml +++ b/scripts/apigen/specs/sandbox.yml @@ -1795,11 +1795,11 @@ paths: Streams change events for a directory until the client disconnects. - The body is JSON lines (sent with `Content-Type: text/plain`): one event object per line, e.g. `{"op":"WRITE","name":"main.go","path":"/app/src","error":null}`. `op` is the fsnotify operation (CREATE, WRITE, REMOVE, RENAME or CHMOD, several can be joined with `|`), `name` the base name of the changed entry, `path` the directory containing it, and `error` is always null. A `[keepalive]` line (not JSON) is sent every 30 seconds. + The body is JSON lines (sent with `Content-Type: text/plain`): one event object per line, e.g. `{"op":"WRITE","name":"main.go","path":"/app/src","error":null}`. `op` is the fsnotify operation (CREATE, WRITE, REMOVE, RENAME or CHMOD, several can be joined with `|`), `name` the base name of the changed entry, `path` the directory containing it, and `error` is always null. A `[keepalive]` line (not JSON) is sent every 30 seconds. The response starts once the watch is registered, and the stream ends when the watched directory is removed or renamed, or when events were dropped because the kernel queue overflowed (reopen and resync). Only the directory's direct entries are watched. To also watch every subdirectory, including ones created later, end the path with `/**`. The stream reports subsequent changes without an initial snapshot. When a new directory is detected, synthetic CREATE events are emitted for entries already present inside it to cover changes before its watch was registered. parameters: - - description: Comma-separated substrings; events whose full path contains one are skipped + - description: Comma-separated substrings; events whose full path contains one are skipped, and matching directories are not watched at all in: query name: ignore schema: diff --git a/tests/sandbox/test_images.py b/tests/sandbox/test_images.py index 3410a50..0c211fe 100644 --- a/tests/sandbox/test_images.py +++ b/tests/sandbox/test_images.py @@ -708,8 +708,6 @@ def test_lists_library_images(self) -> None: ImageLibraryVolume( name="v", mount_path="/data", - type=None, - size=None, read_only=False, ) ], From b0c956c3a433a94eb3b0217de8b87d6f25b3d8d9 Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Thu, 29 Oct 2026 08:47:09 -0500 Subject: [PATCH 5/6] Time out tests after two minutes --- .github/workflows/ci.yml | 1 + pyproject.toml | 5 +++++ uv.lock | 14 ++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76c6b6d..a7540a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: git diff --exit-code - name: Test + timeout-minutes: 15 env: # Forks won't have these secrets so e2e tests skip, but pushes # to main will run them. diff --git a/pyproject.toml b/pyproject.toml index ccaf76e..bb28a5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dev = [ "poethepoet>=0.48", "pytest>=9.1.1", "pytest-asyncio>=1.4.0", + "pytest-timeout>=2.4.0", "pyyaml>=6.0.3", "ruff>=0.16.7", # Floor stays low: newer sphinx requires Python >=3.12, we support >=3.11. @@ -75,3 +76,7 @@ packages = ["baseten"] [tool.pytest.ini_options] testpaths = ["tests"] +# Fails any test, including the fixture setup it triggers, that runs over two +# minutes, dumping every thread's stack. Thread mode works on Windows too. +timeout = 120 +timeout_method = "thread" diff --git a/uv.lock b/uv.lock index c81f061..4140de1 100644 --- a/uv.lock +++ b/uv.lock @@ -100,6 +100,7 @@ dev = [ { name = "poethepoet" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-timeout" }, { name = "pyyaml" }, { name = "ruff" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, @@ -122,6 +123,7 @@ dev = [ { name = "poethepoet", specifier = ">=0.48" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "ruff", specifier = ">=0.16.7" }, { name = "sphinx", specifier = ">=8.1.3" }, @@ -855,6 +857,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.3" From a5ea51c45f0cc45a18c4fe0303faf0f1e7192c91 Mon Sep 17 00:00:00 2001 From: Chad Retz Date: Thu, 29 Oct 2026 09:27:24 -0500 Subject: [PATCH 6/6] Run CI tests verbosely --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7540a8..3c0b50d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,4 +47,4 @@ jobs: BASETEN_E2E_TEST_API_KEY: ${{ secrets.BASETEN_E2E_TEST_API_KEY }} BASETEN_E2E_TEST_DOMAIN: ${{ secrets.BASETEN_E2E_TEST_DOMAIN }} BASETEN_E2E_TEST_MODEL_ID: ${{ secrets.BASETEN_E2E_TEST_MODEL_ID }} - run: uv run poe test + run: uv run poe test -v