From d0f3fe4dd5a72450c886b2fed1cd711515d5ee16 Mon Sep 17 00:00:00 2001 From: porter-support Date: Thu, 13 Aug 2026 16:25:52 +0000 Subject: [PATCH] release: Python SDK v0.1.47 --- porter_sandbox/__init__.py | 4 ++++ porter_sandbox/_models.py | 30 +++++++++++++++++++++++- porter_sandbox/resources/sandboxes.py | 33 +++++++++++++++++++++++++++ porter_sandbox/sandboxes.py | 11 ++++++++- pyproject.toml | 2 +- tests/test_models_round_trip.py | 16 +++++++++++++ uv.lock | 2 +- 7 files changed, 94 insertions(+), 4 deletions(-) diff --git a/porter_sandbox/__init__.py b/porter_sandbox/__init__.py index 11bb5db..0070731 100644 --- a/porter_sandbox/__init__.py +++ b/porter_sandbox/__init__.py @@ -27,11 +27,13 @@ LogLine, LogsResponse, LookupResult, + MetricSummaryResponse, Pagination, ReadinessResponse, SandboxDomainSpec, SandboxEgressSpec, SandboxNetworkingSpec, + SandboxResourcesSpec, SandboxSpec, StatusResponse, VolumeFileEntry, @@ -86,6 +88,7 @@ "LogLineLevel", "LogsResponse", "LookupResult", + "MetricSummaryResponse", "NotFoundError", "Pagination", "Porter", @@ -99,6 +102,7 @@ "SandboxEgressSpec", "SandboxError", "SandboxNetworkingSpec", + "SandboxResourcesSpec", "SandboxSpec", "SandboxTimeoutError", "Sandboxes", diff --git a/porter_sandbox/_models.py b/porter_sandbox/_models.py index 1e303e1..336a511 100644 --- a/porter_sandbox/_models.py +++ b/porter_sandbox/_models.py @@ -83,6 +83,24 @@ class LookupResult(BaseModel): id: str = Field(description="The resolved resource id") +class MetricSummaryResponse(BaseModel): + """p50/p90 CPU and memory usage over a lookback window with utilization against the sandbox's limits. Memory values include page cache, so they slightly overestimate resident memory.""" + model_config = ConfigDict(populate_by_name=True) + + window_seconds: int = Field(description="Lookback window the summary was computed over.") + has_data: bool = Field(description="False when Prometheus returned no series for the sandbox's pod, which\nhappens for freshly-started sandboxes (Prometheus scrapes at ~1m) and\nfor pods shorter-lived than the [3m, 24h] window. When false, callers\nshould render \"no data yet\" rather than the zeroed metric fields.\n") + cpu_cores_p_50: float = Field(alias="cpu_cores_p50", description="50th percentile CPU usage in cores.") + cpu_cores_p_90: float = Field(alias="cpu_cores_p90", description="90th percentile CPU usage in cores.") + cpu_limit_cores: float = Field(description="CPU limit configured on the sandbox pod, in cores. Zero when no limit is set.") + cpu_util_p_50_pct: float = Field(alias="cpu_util_p50_pct", description="p50 CPU usage as a percentage of the CPU limit. Zero when no limit is set.") + cpu_util_p_90_pct: float = Field(alias="cpu_util_p90_pct", description="p90 CPU usage as a percentage of the CPU limit. Zero when no limit is set.") + mem_bytes_p_50: int = Field(alias="mem_bytes_p50", description="50th percentile memory usage in bytes. Includes page cache.") + mem_bytes_p_90: int = Field(alias="mem_bytes_p90", description="90th percentile memory usage in bytes. Includes page cache.") + mem_limit_bytes: int = Field(description="Memory limit configured on the sandbox pod, in bytes. Zero when no limit is set.") + mem_util_p_50_pct: float = Field(alias="mem_util_p50_pct", description="p50 memory usage as a percentage of the memory limit. Zero when no limit is set.") + mem_util_p_90_pct: float = Field(alias="mem_util_p90_pct", description="p90 memory usage as a percentage of the memory limit. Zero when no limit is set.") + + class Pagination(BaseModel): current_page: int = Field(description="Current page number (1-based)") total_pages: int = Field(description="Total number of pages") @@ -108,6 +126,15 @@ class SandboxNetworkingSpec(BaseModel): 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") +class SandboxResourcesSpec(BaseModel): + """CPU and memory for the sandbox, as Kubernetes quantities. An omitted +field keeps the cluster's default sandbox size for that resource. The +sandbox can use up to the given amount. +""" + cpu: str | None = Field(default=None, description="CPU cores, e.g. \"2\", \"500m\".") + memory: str | None = Field(default=None, description="Memory, e.g. \"2Gi\", \"512Mi\".") + + class SandboxSpec(BaseModel): image: str = Field(description="Container image to run") name: str | None = Field(default=None, description="Sandbox name, unique within the cluster. Must be a valid DNS label\n(lowercase alphanumeric and dashes). Defaults to the sandbox's id\nwhen omitted.\n") @@ -119,6 +146,7 @@ class SandboxSpec(BaseModel): volume_mounts: dict[str, str] | None = Field(default=None, description="Volumes to mount, keyed by the absolute mount path inside the\nsandbox; values are volume IDs.\n") 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") @@ -175,4 +203,4 @@ 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") -__all__ = ["CountPoint", "CountResponse", "CreateResponse", "Error", "ExecRequest", "ExecResponse", "ExecTarget", "FilterValuesResponse", "HealthResponse", "ListResponse", "LogLine", "LogsResponse", "LookupResult", "Pagination", "ReadinessResponse", "SandboxDomainSpec", "SandboxEgressSpec", "SandboxNetworkingSpec", "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", "SandboxNetworkingSpec", "SandboxResourcesSpec", "SandboxSpec", "StatusResponse", "Volume", "VolumeFileEntry", "VolumeFileListResponse", "VolumeFileMoveRequest", "VolumeListResponse", "VolumeSpec"] diff --git a/porter_sandbox/resources/sandboxes.py b/porter_sandbox/resources/sandboxes.py index 3c4a90d..16707a1 100644 --- a/porter_sandbox/resources/sandboxes.py +++ b/porter_sandbox/resources/sandboxes.py @@ -17,6 +17,7 @@ ListResponse, LogsResponse, LookupResult, + MetricSummaryResponse, SandboxSpec, StatusResponse, ) @@ -156,6 +157,22 @@ def exec_sandbox(self, id: str, body: ExecRequest, timeout: float | None = None) response = self._client._request(method="POST", path=path, json=body.model_dump(by_alias=True, exclude_none=True) if hasattr(body, "model_dump") else body, timeout=timeout, retry=False) return _coerce(ExecResponse, response) + def get_sandbox_metrics_summary(self, id: str, since: str | None = None) -> MetricSummaryResponse: + """ + Get sandbox CPU and memory percentile summary + + Return p50/p90 CPU and memory usage over a lookback window for the + sandbox, with utilization computed against the sandbox pod's limits. + Memory values include page cache, so they slightly overestimate + resident memory. + """ + path = f"/v1/sandbox/{id}/metrics-summary" + params: dict[str, Any] = {} + if since is not None: + params["since"] = since + response = self._client._request(method="GET", path=path, params=params) + return _coerce(MetricSummaryResponse, response) + class AsyncSandboxes: """Sandboxes resource.""" @@ -282,3 +299,19 @@ async def exec_sandbox(self, id: str, body: ExecRequest, timeout: float | None = path = f"/v1/sandbox/{id}/exec" response = await self._client._request(method="POST", path=path, json=body.model_dump(by_alias=True, exclude_none=True) if hasattr(body, "model_dump") else body, timeout=timeout, retry=False) return _coerce(ExecResponse, response) + + async def get_sandbox_metrics_summary(self, id: str, since: str | None = None) -> MetricSummaryResponse: + """ + Get sandbox CPU and memory percentile summary + + Return p50/p90 CPU and memory usage over a lookback window for the + sandbox, with utilization computed against the sandbox pod's limits. + Memory values include page cache, so they slightly overestimate + resident memory. + """ + path = f"/v1/sandbox/{id}/metrics-summary" + params: dict[str, Any] = {} + if since is not None: + params["since"] = since + response = await self._client._request(method="GET", path=path, params=params) + return _coerce(MetricSummaryResponse, response) diff --git a/porter_sandbox/sandboxes.py b/porter_sandbox/sandboxes.py index 52019e0..7d9bd03 100644 --- a/porter_sandbox/sandboxes.py +++ b/porter_sandbox/sandboxes.py @@ -5,7 +5,12 @@ import builtins -from porter_sandbox._models import SandboxEgressSpec, SandboxNetworkingSpec, SandboxSpec +from porter_sandbox._models import ( + SandboxEgressSpec, + SandboxNetworkingSpec, + SandboxResourcesSpec, + SandboxSpec, +) from porter_sandbox.enums import SandboxesPhase from porter_sandbox.resources.sandboxes import AsyncSandboxes as AsyncSandboxesResource from porter_sandbox.resources.sandboxes import Sandboxes as SandboxesResource @@ -36,6 +41,7 @@ def create( volume_mounts: dict[str, str] | None = None, networking: list[SandboxNetworkingSpec] | None = None, egress: SandboxEgressSpec | None = None, + resources: SandboxResourcesSpec | None = None, ttl_seconds: int | None = None, ) -> Sandbox: spec = SandboxSpec( @@ -49,6 +55,7 @@ def create( volume_mounts=volume_mounts, networking=networking, egress=egress, + resources=resources, ttl_seconds=ttl_seconds, ) created = self._resource.create_sandbox(body=spec) @@ -100,6 +107,7 @@ async def create( volume_mounts: dict[str, str] | None = None, networking: list[SandboxNetworkingSpec] | None = None, egress: SandboxEgressSpec | None = None, + resources: SandboxResourcesSpec | None = None, ttl_seconds: int | None = None, ) -> AsyncSandbox: spec = SandboxSpec( @@ -113,6 +121,7 @@ async def create( volume_mounts=volume_mounts, networking=networking, egress=egress, + resources=resources, ttl_seconds=ttl_seconds, ) created = await self._resource.create_sandbox(body=spec) diff --git a/pyproject.toml b/pyproject.toml index 96d1159..1e4df0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "porter-sandbox" -version = "0.1.44" +version = "0.1.47" 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 77c5f79..b2a52b8 100644 --- a/tests/test_models_round_trip.py +++ b/tests/test_models_round_trip.py @@ -15,11 +15,13 @@ HealthResponse, LogsResponse, LookupResult, + MetricSummaryResponse, Pagination, ReadinessResponse, SandboxDomainSpec, SandboxEgressSpec, SandboxNetworkingSpec, + SandboxResourcesSpec, SandboxSpec, VolumeFileListResponse, VolumeFileMoveRequest, @@ -105,6 +107,13 @@ def test_lookup_result_round_trip() -> None: assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized +def test_metric_summary_response_round_trip() -> None: + instance = MetricSummaryResponse(window_seconds=1, has_data=True, cpu_cores_p_50=1.0, cpu_cores_p_90=1.0, cpu_limit_cores=1.0, cpu_util_p_50_pct=1.0, cpu_util_p_90_pct=1.0, mem_bytes_p_50=1, mem_bytes_p_90=1, mem_limit_bytes=1, mem_util_p_50_pct=1.0, mem_util_p_90_pct=1.0) + serialized = instance.model_dump(by_alias=True, exclude_none=True) + round_tripped = MetricSummaryResponse.model_validate(serialized) + assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized + + def test_pagination_round_trip() -> None: instance = Pagination(current_page=1, total_pages=1, has_next_page=True) serialized = instance.model_dump(by_alias=True, exclude_none=True) @@ -140,6 +149,13 @@ def test_sandbox_networking_spec_round_trip() -> None: assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized +def test_sandbox_resources_spec_round_trip() -> None: + instance = SandboxResourcesSpec() + serialized = instance.model_dump(by_alias=True, exclude_none=True) + round_tripped = SandboxResourcesSpec.model_validate(serialized) + assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized + + def test_sandbox_spec_round_trip() -> None: instance = SandboxSpec(image="x") serialized = instance.model_dump(by_alias=True, exclude_none=True) diff --git a/uv.lock b/uv.lock index c1237d3..7588e86 100644 --- a/uv.lock +++ b/uv.lock @@ -345,7 +345,7 @@ wheels = [ [[package]] name = "porter-sandbox" -version = "0.1.44" +version = "0.1.47" source = { editable = "." } dependencies = [ { name = "httpx" },