From c03834662eea749601d4dfaef627ba64d8acc307 Mon Sep 17 00:00:00 2001 From: porter-support Date: Fri, 21 Aug 2026 21:36:27 +0000 Subject: [PATCH] release: Python SDK v0.1.55 --- porter_sandbox/__init__.py | 22 +++- porter_sandbox/_async_base_client.py | 92 ++++++++++++-- porter_sandbox/_base_client.py | 85 +++++++++++-- porter_sandbox/_binary.py | 8 ++ porter_sandbox/_models.py | 40 ++++++- porter_sandbox/enums.py | 27 ++++- porter_sandbox/resources/sandboxes.py | 35 +++++- porter_sandbox/resources/volumes.py | 6 +- porter_sandbox/volume.py | 165 +++++++++++++++++++------- porter_sandbox/volumes.py | 44 ++++--- pyproject.toml | 2 +- tests/test_models_round_trip.py | 40 +++++++ uv.lock | 2 +- 13 files changed, 479 insertions(+), 89 deletions(-) diff --git a/porter_sandbox/__init__.py b/porter_sandbox/__init__.py index 0070731..a97ce99 100644 --- a/porter_sandbox/__init__.py +++ b/porter_sandbox/__init__.py @@ -32,6 +32,10 @@ ReadinessResponse, SandboxDomainSpec, SandboxEgressSpec, + SandboxMetricsPoint, + SandboxMetricsResponse, + SandboxMetricsResult, + SandboxMetricsSeries, SandboxNetworkingSpec, SandboxResourcesSpec, SandboxSpec, @@ -40,6 +44,7 @@ VolumeFileListResponse, VolumeFileMoveRequest, VolumeListResponse, + VolumeObjectSpec, VolumeSpec, ) from ._models import Volume as VolumeRecord @@ -48,20 +53,25 @@ LogLineLevel, SandboxDomainSpecVisibility, SandboxesPhase, + SandboxMetric, StatusResponsePhase, VolumeFileEntryType, + VolumeObjectSpecAccess, VolumePhase, + VolumeSpecType, + VolumeType, ) from .healthz import AsyncHealthz, Healthz from .porter import AsyncPorter, Porter from .readyz import AsyncReadyz, Readyz from .sandbox import AsyncSandbox, Sandbox from .sandboxes import AsyncSandboxes, Sandboxes -from .volume import AsyncVolume, Volume, VolumeFile +from .volume import AsyncObjectVolume, AsyncVolume, ObjectVolume, Volume, VolumeFile from .volumes import AsyncVolumes, Volumes __all__ = [ "AsyncHealthz", + "AsyncObjectVolume", "AsyncPorter", "AsyncPorterSandboxApiClient", "AsyncReadyz", @@ -90,6 +100,7 @@ "LookupResult", "MetricSummaryResponse", "NotFoundError", + "ObjectVolume", "Pagination", "Porter", "PorterSandboxApiClient", @@ -101,6 +112,11 @@ "SandboxDomainSpecVisibility", "SandboxEgressSpec", "SandboxError", + "SandboxMetric", + "SandboxMetricsPoint", + "SandboxMetricsResponse", + "SandboxMetricsResult", + "SandboxMetricsSeries", "SandboxNetworkingSpec", "SandboxResourcesSpec", "SandboxSpec", @@ -117,8 +133,12 @@ "VolumeFileListResponse", "VolumeFileMoveRequest", "VolumeListResponse", + "VolumeObjectSpec", + "VolumeObjectSpecAccess", "VolumePhase", "VolumeRecord", "VolumeSpec", + "VolumeSpecType", + "VolumeType", "Volumes", ] diff --git a/porter_sandbox/_async_base_client.py b/porter_sandbox/_async_base_client.py index 9664976..5af5e9c 100644 --- a/porter_sandbox/_async_base_client.py +++ b/porter_sandbox/_async_base_client.py @@ -3,14 +3,22 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import AsyncIterator, Mapping from typing import Any import httpx from httpx._client import UseClientDefault -from ._base_client import _decode_body, _error_message, _request_headers -from ._binary import BinaryContent, _binary_content +from ._base_client import ( + MAX_REDIRECTS, + _decode_body, + _error_message, + _origin, + _redirect_target, + _request_headers, + _rewind, +) +from ._binary import BinaryBody, BinaryContent, _binary_content from ._config import Config from ._errors import SandboxError, SandboxTimeoutError, error_for_status from ._retries import DEFAULT_MAX_RETRIES, should_retry, sleep_for_attempt @@ -18,6 +26,27 @@ USER_AGENT = "porter-sandbox-python/0.0.1" +# An async client cannot send a file object directly, so a stream body is read +# through this. The caller's file is rewound first, so each hop reads all of it. +_STREAM_CHUNK_BYTES = 1024 * 1024 + + +def _as_async_stream(content: BinaryBody | None) -> Any: + """Wrap a file object so an async client can send it, and pass bytes through.""" + read = getattr(content, "read", None) + if read is None: + return content + + async def chunks() -> AsyncIterator[bytes]: + while True: + chunk = read(_STREAM_CHUNK_BYTES) + if not chunk: + break + yield chunk + + return chunks() + + class _AsyncBaseClient: """Async HTTP transport shared by all generated async resource classes. @@ -44,10 +73,9 @@ def __init__( timeout=config.timeout, verify=verify, headers=headers, - # Some endpoints redirect to the service that owns the data. httpx - # does not follow redirects by default, so callers would get the - # 3xx instead of the response. - follow_redirects=True, + # _send_following_redirects follows them instead, so a stream + # body starts over on each hop. + follow_redirects=False, ) async def __aenter__(self) -> _AsyncBaseClient: @@ -59,6 +87,50 @@ async def __aexit__(self, *_exc: object) -> None: async def aclose(self) -> None: await self._http.aclose() + async def _send_following_redirects( + self, + *, + method: str, + url: str, + params: Mapping[str, Any] | None, + json: Any, + content: BinaryBody | None, + headers: Mapping[str, str], + timeout: float | None | UseClientDefault, + ) -> httpx.Response: + """Send one attempt, and follow any redirect it answers with. + + The API answers a volume file write with a 307, which keeps the method + and the body. A hop that leaves the origin drops the credentials that + were meant for that origin. + """ + target: str | httpx.URL = url + hop_params = params + + for _ in range(MAX_REDIRECTS + 1): + _rewind(content) + request = self._http.build_request( + method=method, + url=target, + params=hop_params, + json=json, + content=_as_async_stream(content), + headers=headers, + timeout=timeout, + ) + if isinstance(target, httpx.URL) and _origin(target) != _origin(self._http.base_url): + request.headers.pop("Authorization", None) + response = await self._http.send(request) + + redirect = _redirect_target(response) + if redirect is None: + return response + # The redirect target carries its own query already. + hop_params = None + target = redirect + + raise SandboxError(f"Too many redirects for {url}") + async def _request( self, *, @@ -66,7 +138,7 @@ async def _request( path: str, params: Mapping[str, Any] | None = None, json: Any = None, - content: bytes | None = None, + content: BinaryBody | None = None, content_type: str | None = None, headers: Mapping[str, str | None] | None = None, timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT, @@ -115,7 +187,7 @@ async def _send( json: Any, headers: Mapping[str, str | None] | None, accept: str, - content: bytes | None = None, + content: BinaryBody | None = None, content_type: str | None = None, timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT, retry: bool = True, @@ -129,7 +201,7 @@ async def _send( for attempt in range(max_retries + 1): try: - response = await self._http.request( + response = await self._send_following_redirects( method=method, url=path, params=params, diff --git a/porter_sandbox/_base_client.py b/porter_sandbox/_base_client.py index 61cdea0..3061a74 100644 --- a/porter_sandbox/_base_client.py +++ b/porter_sandbox/_base_client.py @@ -10,7 +10,7 @@ import httpx from httpx._client import UseClientDefault -from ._binary import BinaryContent, _binary_content +from ._binary import BinaryBody, BinaryContent, _binary_content from ._config import Config from ._errors import SandboxError, SandboxTimeoutError, error_for_status from ._retries import DEFAULT_MAX_RETRIES, should_retry, sleep_for_attempt_sync @@ -18,6 +18,32 @@ USER_AGENT = "porter-sandbox-python/0.0.1" +# httpx cannot restart a stream body on its own redirect path, so the client +# follows redirects itself and starts the body over on each hop. +_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308}) +MAX_REDIRECTS = 5 + + +def _rewind(content: BinaryBody | None) -> None: + """Put a stream body back to the start, so the next send reads all of it.""" + seek = getattr(content, "seek", None) + if seek is not None: + seek(0) + + +def _origin(url: httpx.URL) -> tuple[str, str | None, int | None]: + """The scheme, host and port a credential belongs to.""" + return (url.scheme, url.host, url.port) + + +def _redirect_target(response: httpx.Response) -> httpx.URL | None: + """The URL a response redirects to, or None when it does not redirect.""" + if response.status_code not in _REDIRECT_STATUSES: + return None + location = response.headers.get("location") + return response.url.join(location) if location else None + + def _decode_body(response: httpx.Response) -> Any: if response.status_code == 204 or not response.content: return None @@ -75,10 +101,9 @@ def __init__( timeout=config.timeout, verify=verify, headers=headers, - # Some endpoints redirect to the service that owns the data. httpx - # does not follow redirects by default, so callers would get the - # 3xx instead of the response. - follow_redirects=True, + # _send_following_redirects follows them instead, so a stream + # body starts over on each hop. + follow_redirects=False, ) def __enter__(self) -> _BaseClient: @@ -90,6 +115,50 @@ def __exit__(self, *_exc: object) -> None: def close(self) -> None: self._http.close() + def _send_following_redirects( + self, + *, + method: str, + url: str, + params: Mapping[str, Any] | None, + json: Any, + content: BinaryBody | None, + headers: Mapping[str, str], + timeout: float | None | UseClientDefault, + ) -> httpx.Response: + """Send one attempt, and follow any redirect it answers with. + + The API answers a volume file write with a 307, which keeps the method + and the body. A hop that leaves the origin drops the credentials that + were meant for that origin. + """ + target: str | httpx.URL = url + hop_params = params + + for _ in range(MAX_REDIRECTS + 1): + _rewind(content) + request = self._http.build_request( + method=method, + url=target, + params=hop_params, + json=json, + content=content, + headers=headers, + timeout=timeout, + ) + if isinstance(target, httpx.URL) and _origin(target) != _origin(self._http.base_url): + request.headers.pop("Authorization", None) + response = self._http.send(request) + + redirect = _redirect_target(response) + if redirect is None: + return response + # The redirect target carries its own query already. + hop_params = None + target = redirect + + raise SandboxError(f"Too many redirects for {url}") + def _request( self, *, @@ -97,7 +166,7 @@ def _request( path: str, params: Mapping[str, Any] | None = None, json: Any = None, - content: bytes | None = None, + content: BinaryBody | None = None, content_type: str | None = None, headers: Mapping[str, str | None] | None = None, timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT, @@ -146,7 +215,7 @@ def _send( json: Any, headers: Mapping[str, str | None] | None, accept: str, - content: bytes | None = None, + content: BinaryBody | None = None, content_type: str | None = None, timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT, retry: bool = True, @@ -160,7 +229,7 @@ def _send( for attempt in range(max_retries + 1): try: - response = self._http.request( + response = self._send_following_redirects( method=method, url=path, params=params, diff --git a/porter_sandbox/_binary.py b/porter_sandbox/_binary.py index 363b5ff..1b614e4 100644 --- a/porter_sandbox/_binary.py +++ b/porter_sandbox/_binary.py @@ -7,9 +7,17 @@ from dataclasses import dataclass from datetime import datetime from email.utils import parsedate_to_datetime +from typing import IO import httpx +# The body a byte-bodied endpoint accepts. +# +# A retry or a redirect sends the body again, so a stream has to start over. A +# file object must therefore be seekable, which `open(path, "rb")` gives. Pass +# one to upload a large file without holding all of it in memory. +BinaryBody = bytes | IO[bytes] + # `bytes 0-1023/8192`. Absent on a whole-file response. _CONTENT_RANGE_RE = re.compile(r"^bytes (\d+)-(\d+)/(\d+)$") diff --git a/porter_sandbox/_models.py b/porter_sandbox/_models.py index 336a511..885bdc2 100644 --- a/porter_sandbox/_models.py +++ b/porter_sandbox/_models.py @@ -11,7 +11,10 @@ SandboxDomainSpecVisibility, StatusResponsePhase, VolumeFileEntryType, + VolumeObjectSpecAccess, VolumePhase, + VolumeSpecType, + VolumeType, ) @@ -121,6 +124,26 @@ class SandboxEgressSpec(BaseModel): allowed_destinations: list[str] = Field(description="Destinations the sandbox may reach; all other outbound traffic is\ndenied. An entry is a hostname (api.example.com), a wildcard\n(*.example.com) matching any host under that domain but not the\ndomain itself, an IP literal, a CIDR range (203.0.113.0/24), or the\ncluster-internal hostname of a Service on the sandbox's cluster\n(name.namespace.svc.cluster.local), which allows the Service's\nbacking pods as they change. Enforcement is transparent at the\nnetwork layer, so any protocol and client works without proxy\nconfiguration. An empty list denies all egress; omit egress entirely\nto leave the sandbox's internet access unrestricted.\n") +class SandboxMetricsPoint(BaseModel): + timestamp_utc: str = Field(description="Timestamp for the data point, in UTC.") + value: float = Field(description="Metric value at this timestamp.") + + +class SandboxMetricsResponse(BaseModel): + """Time series for one sandbox metric over a range, shaped like the app metrics response so the dashboard reuses the same chart selectors.""" + results: list[SandboxMetricsResult] = Field(description="One entry per query; this endpoint returns a single entry.") + + +class SandboxMetricsResult(BaseModel): + series: list[SandboxMetricsSeries] = Field(description="One entry per Prometheus series, normally the single sandbox pod.") + + +class SandboxMetricsSeries(BaseModel): + labels: dict[str, str] | None = Field(default=None, description="Prometheus labels identifying the series.") + unit: str | None = Field(default=None, description="Unit of the series values (e.g. cores, bytes, bytes/sec).") + time_series: list[SandboxMetricsPoint] = Field(description="Data points in chronological order.") + + class SandboxNetworkingSpec(BaseModel): port: int = Field(description="Port the workload listens on; the per-sandbox Service targets it on\nthe pod. Privileged ports (1-1023) are not allowed.\n") domains: list[SandboxDomainSpec] | None = Field(default=None, description="Domains the port is served on through a sandbox ingress. Omit to\nserve the port at the default hostname through the default ingress.\nCurrently only one entry is supported.\n") @@ -147,7 +170,7 @@ class SandboxSpec(BaseModel): networking: list[SandboxNetworkingSpec] | None = Field(default=None, description="Network exposure for the sandbox. Omit to expose nothing. Currently\nonly one entry is supported.\n") egress: SandboxEgressSpec | None = Field(default=None) resources: SandboxResourcesSpec | None = Field(default=None) - ttl_seconds: int | None = Field(default=None, description="Maximum lifetime in seconds, counted from creation. The sandbox is\nterminated once it elapses. Omit for no limit.\n") + ttl_seconds: int | None = Field(default=None, description="Maximum lifetime in seconds, counted from when the sandbox starts\nrunning (from creation while it waits to start). The sandbox is\nterminated once it elapses. Omit for no limit.\n") class StatusResponse(BaseModel): @@ -159,6 +182,7 @@ class StatusResponse(BaseModel): exit_code: int | None = Field(default=None, description="Exit code if completed") created_at: str = Field(description="When the sandbox was created") started_at: str | None = Field(default=None, description="When the sandbox pod started running") + finished_at: str | None = Field(default=None, description="When the sandbox reached a terminal phase (succeeded, failed, or terminated)") host: str = Field(description="Public hostname the sandbox is reachable at. Empty when the sandbox\nexposes no port or the cluster has no sandbox ingress configured.\n") volume_mounts: dict[str, str] | None = Field(default=None, description="Volumes the sandbox mounts, keyed by mount path") exec_target: ExecTarget | None = Field(default=None, description="Where a client addresses an interactive exec into the running sandbox. Absent until the sandbox has a pod.") @@ -167,7 +191,9 @@ class StatusResponse(BaseModel): class Volume(BaseModel): id: str = Field(description="Volume ID, assigned when the volume is created. All volume\noperations address volumes by ID; the name is informational.\n") name: str = Field(description="Volume name") - path: str = Field(description="Subdirectory, relative to the shared sandbox volumes mount, where this\nvolume's data lives. An app that mounts the cluster's sandbox volumes\nreads this volume at /.\n") + type: VolumeType = Field(description="Kind of volume. A disk volume is persistent file storage owned by\nthe volume; an object volume exposes a registered bucket.\n") + object: VolumeObjectSpec | None = Field(default=None) + path: str = Field(description="Subdirectory, relative to the shared sandbox volumes mount, where this\nvolume's data lives. An app that mounts the cluster's sandbox volumes\nreads this volume at /. Empty for object volumes, which\nread straight from their bucket.\n") phase: VolumePhase = Field(description="Current lifecycle phase of the volume") attached_to: list[str] = Field(description="IDs of sandboxes the volume is attached to") created_at: str = Field(description="When the volume was created") @@ -199,8 +225,16 @@ class VolumeListResponse(BaseModel): volumes: list[Volume] = Field(description="All volumes in the cluster") +class VolumeObjectSpec(BaseModel): + bucket: str = Field(description="Bucket the volume exposes. Must be registered on the cluster.\n") + prefix: str | None = Field(default=None, description="Key prefix the volume is scoped to, without leading or trailing\nslashes. Sandboxes see only objects under the prefix, at paths\nrelative to it. Omit to expose the whole bucket.\n") + access: VolumeObjectSpecAccess | None = Field(default=None, description="How sandboxes that attach the volume can use the bucket. read_only\nforbids all writes; write_only_new_files allows only new objects, with no\noverwrites or deletes of existing ones. Defaults to read_write. A\nbucket registered with read_only accepts only read_only volumes.\n") + + class VolumeSpec(BaseModel): name: str | None = Field(default=None, description="Volume name, unique within the cluster. Must be a valid DNS label\n(lowercase alphanumeric and dashes). Defaults to the volume's id\nwhen omitted.\n") + type: VolumeSpecType | None = Field(default=None, description="Kind of volume to create. A disk volume is persistent file storage\nowned by the volume; an object volume exposes a bucket registered\non the cluster. Defaults to disk when omitted.\n") + object: VolumeObjectSpec | None = Field(default=None) -__all__ = ["CountPoint", "CountResponse", "CreateResponse", "Error", "ExecRequest", "ExecResponse", "ExecTarget", "FilterValuesResponse", "HealthResponse", "ListResponse", "LogLine", "LogsResponse", "LookupResult", "MetricSummaryResponse", "Pagination", "ReadinessResponse", "SandboxDomainSpec", "SandboxEgressSpec", "SandboxNetworkingSpec", "SandboxResourcesSpec", "SandboxSpec", "StatusResponse", "Volume", "VolumeFileEntry", "VolumeFileListResponse", "VolumeFileMoveRequest", "VolumeListResponse", "VolumeSpec"] +__all__ = ["CountPoint", "CountResponse", "CreateResponse", "Error", "ExecRequest", "ExecResponse", "ExecTarget", "FilterValuesResponse", "HealthResponse", "ListResponse", "LogLine", "LogsResponse", "LookupResult", "MetricSummaryResponse", "Pagination", "ReadinessResponse", "SandboxDomainSpec", "SandboxEgressSpec", "SandboxMetricsPoint", "SandboxMetricsResponse", "SandboxMetricsResult", "SandboxMetricsSeries", "SandboxNetworkingSpec", "SandboxResourcesSpec", "SandboxSpec", "StatusResponse", "Volume", "VolumeFileEntry", "VolumeFileListResponse", "VolumeFileMoveRequest", "VolumeListResponse", "VolumeObjectSpec", "VolumeSpec"] diff --git a/porter_sandbox/enums.py b/porter_sandbox/enums.py index 20ef16a..0e2edc5 100644 --- a/porter_sandbox/enums.py +++ b/porter_sandbox/enums.py @@ -35,6 +35,15 @@ class SandboxesPhase(str, Enum): TERMINATED = "terminated" +class SandboxMetric(str, Enum): + CPU_USAGE = "cpu_usage" + CPU_RESERVED = "cpu_reserved" + MEMORY_USAGE = "memory_usage" + MEMORY_RESERVED = "memory_reserved" + NETWORK_RX = "network_rx" + NETWORK_TX = "network_tx" + + class StatusResponsePhase(str, Enum): QUEUED = "queued" CREATING = "creating" @@ -49,10 +58,26 @@ class VolumeFileEntryType(str, Enum): DIRECTORY = "directory" +class VolumeObjectSpecAccess(str, Enum): + READ_WRITE = "read_write" + READ_ONLY = "read_only" + WRITE_ONLY_NEW_FILES = "write_only_new_files" + + class VolumePhase(str, Enum): PENDING = "pending" READY = "ready" FAILED = "failed" -__all__ = ["FilterValuesResponsePhases", "LogLineLevel", "SandboxDomainSpecVisibility", "SandboxesPhase", "StatusResponsePhase", "VolumeFileEntryType", "VolumePhase"] +class VolumeSpecType(str, Enum): + DISK = "disk" + OBJECT = "object" + + +class VolumeType(str, Enum): + DISK = "disk" + OBJECT = "object" + + +__all__ = ["FilterValuesResponsePhases", "LogLineLevel", "SandboxDomainSpecVisibility", "SandboxesPhase", "SandboxMetric", "StatusResponsePhase", "VolumeFileEntryType", "VolumeObjectSpecAccess", "VolumePhase", "VolumeSpecType", "VolumeType"] diff --git a/porter_sandbox/resources/sandboxes.py b/porter_sandbox/resources/sandboxes.py index 16707a1..f68ae25 100644 --- a/porter_sandbox/resources/sandboxes.py +++ b/porter_sandbox/resources/sandboxes.py @@ -18,10 +18,11 @@ LogsResponse, LookupResult, MetricSummaryResponse, + SandboxMetricsResponse, SandboxSpec, StatusResponse, ) -from ..enums import SandboxesPhase +from ..enums import SandboxesPhase, SandboxMetric _M = TypeVar("_M", bound=BaseModel) @@ -173,6 +174,22 @@ def get_sandbox_metrics_summary(self, id: str, since: str | None = None) -> Metr response = self._client._request(method="GET", path=path, params=params) return _coerce(MetricSummaryResponse, response) + def get_sandbox_metrics(self, id: str, metric: SandboxMetric, start_time_utc: str, end_time_utc: str) -> SandboxMetricsResponse: + """ + Get sandbox time-series metrics + + Return the time series for a single metric of the sandbox over a range, + queried against the cluster's Prometheus. Shaped like the app metrics + response so the dashboard reuses the same chart selectors. + """ + path = f"/v1/sandbox/{id}/metrics" + params: dict[str, Any] = {} + params["metric"] = metric + params["start_time_utc"] = start_time_utc + params["end_time_utc"] = end_time_utc + response = self._client._request(method="GET", path=path, params=params) + return _coerce(SandboxMetricsResponse, response) + class AsyncSandboxes: """Sandboxes resource.""" @@ -315,3 +332,19 @@ async def get_sandbox_metrics_summary(self, id: str, since: str | None = None) - params["since"] = since response = await self._client._request(method="GET", path=path, params=params) return _coerce(MetricSummaryResponse, response) + + async def get_sandbox_metrics(self, id: str, metric: SandboxMetric, start_time_utc: str, end_time_utc: str) -> SandboxMetricsResponse: + """ + Get sandbox time-series metrics + + Return the time series for a single metric of the sandbox over a range, + queried against the cluster's Prometheus. Shaped like the app metrics + response so the dashboard reuses the same chart selectors. + """ + path = f"/v1/sandbox/{id}/metrics" + params: dict[str, Any] = {} + params["metric"] = metric + params["start_time_utc"] = start_time_utc + params["end_time_utc"] = end_time_utc + response = await self._client._request(method="GET", path=path, params=params) + return _coerce(SandboxMetricsResponse, response) diff --git a/porter_sandbox/resources/volumes.py b/porter_sandbox/resources/volumes.py index afe3b15..604fe2c 100644 --- a/porter_sandbox/resources/volumes.py +++ b/porter_sandbox/resources/volumes.py @@ -9,7 +9,7 @@ from .._async_base_client import _AsyncBaseClient from .._base_client import _BaseClient -from .._binary import BinaryContent +from .._binary import BinaryBody, BinaryContent from .._models import ( LookupResult, Volume, @@ -122,7 +122,7 @@ def read_volume_file(self, id: str, path: str, range: str | None = None, timeout response: BinaryContent = self._client._request_binary(method="GET", path=path_, params=params, headers={"Range": range}, timeout=timeout) return response - def write_volume_file(self, id: str, body: bytes, path: str, timeout: float | None = None) -> None: + def write_volume_file(self, id: str, body: BinaryBody, path: str, timeout: float | None = None) -> None: """ Write volume file @@ -249,7 +249,7 @@ async def read_volume_file(self, id: str, path: str, range: str | None = None, t response: BinaryContent = await self._client._request_binary(method="GET", path=path_, params=params, headers={"Range": range}, timeout=timeout) return response - async def write_volume_file(self, id: str, body: bytes, path: str, timeout: float | None = None) -> None: + async def write_volume_file(self, id: str, body: BinaryBody, path: str, timeout: float | None = None) -> None: """ Write volume file diff --git a/porter_sandbox/volume.py b/porter_sandbox/volume.py index 840812e..0497b17 100644 --- a/porter_sandbox/volume.py +++ b/porter_sandbox/volume.py @@ -7,9 +7,11 @@ from collections.abc import AsyncIterator, Iterator from datetime import datetime +from porter_sandbox._binary import BinaryBody +from porter_sandbox._errors import SandboxError from porter_sandbox._models import Volume as VolumeRecord -from porter_sandbox._models import VolumeFileEntry, VolumeFileMoveRequest -from porter_sandbox.enums import VolumeFileEntryType, VolumePhase +from porter_sandbox._models import VolumeFileEntry, VolumeFileMoveRequest, VolumeObjectSpec +from porter_sandbox.enums import VolumeFileEntryType, VolumePhase, VolumeType from porter_sandbox.resources.volumes import AsyncVolumes as AsyncVolumesResource from porter_sandbox.resources.volumes import Volumes as VolumesResource @@ -77,19 +79,10 @@ def __repr__(self) -> str: return f"VolumeFile(path={self.path!r}, type={self.type!r}, size_bytes={self.size_bytes})" -class Volume: - """Ergonomic handle for a single volume (sync). +class _BaseVolume: + """Record-backed properties every volume handle shares.""" - Constructed by `Porter().volumes`. Holds a back-reference to the generated - volume resource so file reads and lifecycle calls do not need a client to be - re-passed. - - For async usage, see `AsyncVolume` (same surface, async methods). - """ - - def __init__(self, *, record: VolumeRecord, resource: VolumesResource) -> None: - self._record = record - self._volumes = resource + _record: VolumeRecord @property def id(self) -> str: @@ -100,16 +93,13 @@ def name(self) -> str: return self._record.name @property - def phase(self) -> VolumePhase: - return self._record.phase + def type(self) -> VolumeType: + """Kind of volume. Use `isinstance(volume, ObjectVolume)` to narrow the handle.""" + return self._record.type @property - def path(self) -> str: - """Subdirectory, relative to the shared sandbox volumes mount, where this - volume's data lives. An app that mounts the cluster's sandbox volumes - reads this volume at `/`. - """ - return self._record.path + def phase(self) -> VolumePhase: + return self._record.phase @property def attached_to(self) -> builtins.list[str]: @@ -120,6 +110,30 @@ def attached_to(self) -> builtins.list[str]: def created_at(self) -> datetime | None: return _parse_timestamp(self._record.created_at) + +class Volume(_BaseVolume): + """Ergonomic handle for a single disk volume (sync). + + Constructed by `Porter().volumes`. Holds a back-reference to the generated + volume resource so file reads and lifecycle calls do not need a client to be + re-passed. + + For async usage, see `AsyncVolume` (same surface, async methods). Object + volumes get `ObjectVolume` instead, which carries no file methods. + """ + + def __init__(self, *, record: VolumeRecord, resource: VolumesResource) -> None: + self._record = record + self._volumes = resource + + @property + def path(self) -> str: + """Subdirectory, relative to the shared sandbox volumes mount, where this + volume's data lives. An app that mounts the cluster's sandbox volumes + reads this volume at `/`. + """ + return self._record.path + def refresh(self) -> VolumeRecord: """Refetch and cache the volume record.""" self._record = self._volumes.get_volume(id=self.id) @@ -184,11 +198,14 @@ def read_text( """Read a file as text.""" return self.read_file(path, offset=offset, length=length).decode(encoding) - def write_file(self, path: str, content: bytes) -> None: + def write_file(self, path: str, content: BinaryBody) -> None: """Write bytes to a file, replacing whatever is there and creating parent directories as needed. The file appears at `path` only once every byte has been written, so an interrupted write leaves the previous content in place. + + Pass a seekable file object to stream a large upload instead of holding + it in memory: `volume.write_file("/big.bin", open(local_path, "rb"))`. """ self._volumes.write_volume_file(id=self.id, body=content, path=_normalize_path(path)) @@ -258,28 +275,53 @@ def _walk_entries( yield from self.iterdir(file.path) -class AsyncVolume: - """Ergonomic handle for a single volume (async). +class ObjectVolume(_BaseVolume): + """Ergonomic handle for a single object volume (sync). - Same surface as `Volume`, but every method is awaitable and the walks are - async iterators. + An object volume exposes a registered bucket, so it carries no file + methods: manage its contents in the bucket. For async usage, see + `AsyncObjectVolume`. """ - def __init__(self, *, record: VolumeRecord, resource: AsyncVolumesResource) -> None: + def __init__(self, *, record: VolumeRecord, resource: VolumesResource) -> None: self._record = record self._volumes = resource @property - def id(self) -> str: - return self._record.id + def object(self) -> VolumeObjectSpec: + """Bucket, key prefix, and access mode the volume exposes.""" + spec = self._record.object + if spec is None: + raise SandboxError("volume record is missing its object spec") + return spec - @property - def name(self) -> str: - return self._record.name + def refresh(self) -> VolumeRecord: + """Refetch and cache the volume record.""" + self._record = self._volumes.get_volume(id=self.id) + return self._record - @property - def phase(self) -> VolumePhase: - return self._record.phase + def delete(self) -> None: + """Delete the volume. Fails while it is attached to a sandbox.""" + self._volumes.delete_volume(id=self.id) + + +def _wrap_volume(record: VolumeRecord, resource: VolumesResource) -> Volume | ObjectVolume: + """Pick the handle for a record's type. Anything but an object volume gets the disk handle.""" + if record.type == VolumeType.OBJECT: + return ObjectVolume(record=record, resource=resource) + return Volume(record=record, resource=resource) + + +class AsyncVolume(_BaseVolume): + """Ergonomic handle for a single disk volume (async). + + Same surface as `Volume`, but every method is awaitable and the walks are + async iterators. + """ + + def __init__(self, *, record: VolumeRecord, resource: AsyncVolumesResource) -> None: + self._record = record + self._volumes = resource @property def path(self) -> str: @@ -289,15 +331,6 @@ def path(self) -> str: """ return self._record.path - @property - def attached_to(self) -> builtins.list[str]: - """IDs of the sandboxes the volume is attached to.""" - return self._record.attached_to - - @property - def created_at(self) -> datetime | None: - return _parse_timestamp(self._record.created_at) - async def refresh(self) -> VolumeRecord: """Refetch and cache the volume record.""" self._record = await self._volumes.get_volume(id=self.id) @@ -365,11 +398,14 @@ async def read_text( """Read a file as text.""" return (await self.read_file(path, offset=offset, length=length)).decode(encoding) - async def write_file(self, path: str, content: bytes) -> None: + async def write_file(self, path: str, content: BinaryBody) -> None: """Write bytes to a file, replacing whatever is there and creating parent directories as needed. The file appears at `path` only once every byte has been written, so an interrupted write leaves the previous content in place. + + Pass a seekable file object to stream a large upload instead of holding + it in memory: `await volume.write_file("/big.bin", open(local_path, "rb"))`. """ await self._volumes.write_volume_file(id=self.id, body=content, path=_normalize_path(path)) @@ -441,3 +477,40 @@ async def _walk_entries( elif file.truncated and follow_truncated: async for nested in self.iterdir(file.path): yield nested + + +class AsyncObjectVolume(_BaseVolume): + """Ergonomic handle for a single object volume (async). + + Same surface as `ObjectVolume`, but every method is awaitable. + """ + + def __init__(self, *, record: VolumeRecord, resource: AsyncVolumesResource) -> None: + self._record = record + self._volumes = resource + + @property + def object(self) -> VolumeObjectSpec: + """Bucket, key prefix, and access mode the volume exposes.""" + spec = self._record.object + if spec is None: + raise SandboxError("volume record is missing its object spec") + return spec + + async def refresh(self) -> VolumeRecord: + """Refetch and cache the volume record.""" + self._record = await self._volumes.get_volume(id=self.id) + return self._record + + async def delete(self) -> None: + """Delete the volume. Fails while it is attached to a sandbox.""" + await self._volumes.delete_volume(id=self.id) + + +def _wrap_async_volume( + record: VolumeRecord, resource: AsyncVolumesResource +) -> AsyncVolume | AsyncObjectVolume: + """Pick the handle for a record's type. Anything but an object volume gets the disk handle.""" + if record.type == VolumeType.OBJECT: + return AsyncObjectVolume(record=record, resource=resource) + return AsyncVolume(record=record, resource=resource) diff --git a/porter_sandbox/volumes.py b/porter_sandbox/volumes.py index d08bf72..26fa045 100644 --- a/porter_sandbox/volumes.py +++ b/porter_sandbox/volumes.py @@ -5,10 +5,18 @@ import builtins -from porter_sandbox._models import VolumeSpec +from porter_sandbox._models import VolumeObjectSpec, VolumeSpec +from porter_sandbox.enums import VolumeSpecType from porter_sandbox.resources.volumes import AsyncVolumes as AsyncVolumesResource from porter_sandbox.resources.volumes import Volumes as VolumesResource -from porter_sandbox.volume import AsyncVolume, Volume +from porter_sandbox.volume import ( + AsyncObjectVolume, + AsyncVolume, + ObjectVolume, + Volume, + _wrap_async_volume, + _wrap_volume, +) class Volumes: @@ -26,21 +34,25 @@ def create( self, *, name: str | None = None, - ) -> Volume: + type: VolumeSpecType | None = None, + object: VolumeObjectSpec | None = None, + ) -> Volume | ObjectVolume: spec = VolumeSpec( name=name, + type=type, + object=object, ) record = self._resource.create_volume(body=spec) - return Volume(record=record, resource=self._resource) + return _wrap_volume(record, self._resource) - def list(self) -> builtins.list[Volume]: + def list(self) -> builtins.list[Volume | ObjectVolume]: response = self._resource.list_volumes() - return [Volume(record=r, resource=self._resource) for r in response.volumes] + return [_wrap_volume(r, self._resource) for r in response.volumes] - def get(self, name: str) -> Volume: + def get(self, name: str) -> Volume | ObjectVolume: ref = self._resource.lookup_volume(name=name) record = self._resource.get_volume(id=ref.id) - return Volume(record=record, resource=self._resource) + return _wrap_volume(record, self._resource) def delete(self, name: str) -> None: ref = self._resource.lookup_volume(name=name) @@ -63,21 +75,25 @@ async def create( self, *, name: str | None = None, - ) -> AsyncVolume: + type: VolumeSpecType | None = None, + object: VolumeObjectSpec | None = None, + ) -> AsyncVolume | AsyncObjectVolume: spec = VolumeSpec( name=name, + type=type, + object=object, ) record = await self._resource.create_volume(body=spec) - return AsyncVolume(record=record, resource=self._resource) + return _wrap_async_volume(record, self._resource) - async def list(self) -> builtins.list[AsyncVolume]: + async def list(self) -> builtins.list[AsyncVolume | AsyncObjectVolume]: response = await self._resource.list_volumes() - return [AsyncVolume(record=r, resource=self._resource) for r in response.volumes] + return [_wrap_async_volume(r, self._resource) for r in response.volumes] - async def get(self, name: str) -> AsyncVolume: + async def get(self, name: str) -> AsyncVolume | AsyncObjectVolume: ref = await self._resource.lookup_volume(name=name) record = await self._resource.get_volume(id=ref.id) - return AsyncVolume(record=record, resource=self._resource) + return _wrap_async_volume(record, self._resource) async def delete(self, name: str) -> None: ref = await self._resource.lookup_volume(name=name) diff --git a/pyproject.toml b/pyproject.toml index 1e4df0f..d0f03c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "porter-sandbox" -version = "0.1.47" +version = "0.1.55" description = "Python SDK for the Porter Sandbox API" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_models_round_trip.py b/tests/test_models_round_trip.py index b2a52b8..7e47b84 100644 --- a/tests/test_models_round_trip.py +++ b/tests/test_models_round_trip.py @@ -20,12 +20,17 @@ ReadinessResponse, SandboxDomainSpec, SandboxEgressSpec, + SandboxMetricsPoint, + SandboxMetricsResponse, + SandboxMetricsResult, + SandboxMetricsSeries, SandboxNetworkingSpec, SandboxResourcesSpec, SandboxSpec, VolumeFileListResponse, VolumeFileMoveRequest, VolumeListResponse, + VolumeObjectSpec, VolumeSpec, ) @@ -142,6 +147,34 @@ def test_sandbox_egress_spec_round_trip() -> None: assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized +def test_sandbox_metrics_point_round_trip() -> None: + instance = SandboxMetricsPoint(timestamp_utc="x", value=1.0) + serialized = instance.model_dump(by_alias=True, exclude_none=True) + round_tripped = SandboxMetricsPoint.model_validate(serialized) + assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized + + +def test_sandbox_metrics_response_round_trip() -> None: + instance = SandboxMetricsResponse(results=[]) + serialized = instance.model_dump(by_alias=True, exclude_none=True) + round_tripped = SandboxMetricsResponse.model_validate(serialized) + assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized + + +def test_sandbox_metrics_result_round_trip() -> None: + instance = SandboxMetricsResult(series=[]) + serialized = instance.model_dump(by_alias=True, exclude_none=True) + round_tripped = SandboxMetricsResult.model_validate(serialized) + assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized + + +def test_sandbox_metrics_series_round_trip() -> None: + instance = SandboxMetricsSeries(time_series=[]) + serialized = instance.model_dump(by_alias=True, exclude_none=True) + round_tripped = SandboxMetricsSeries.model_validate(serialized) + assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized + + def test_sandbox_networking_spec_round_trip() -> None: instance = SandboxNetworkingSpec(port=1) serialized = instance.model_dump(by_alias=True, exclude_none=True) @@ -184,6 +217,13 @@ def test_volume_list_response_round_trip() -> None: assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized +def test_volume_object_spec_round_trip() -> None: + instance = VolumeObjectSpec(bucket="x") + serialized = instance.model_dump(by_alias=True, exclude_none=True) + round_tripped = VolumeObjectSpec.model_validate(serialized) + assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized + + def test_volume_spec_round_trip() -> None: instance = VolumeSpec() serialized = instance.model_dump(by_alias=True, exclude_none=True) diff --git a/uv.lock b/uv.lock index 7588e86..7b88538 100644 --- a/uv.lock +++ b/uv.lock @@ -345,7 +345,7 @@ wheels = [ [[package]] name = "porter-sandbox" -version = "0.1.47" +version = "0.1.55" source = { editable = "." } dependencies = [ { name = "httpx" },