From e36e039897361b97dcbbf4a4f8a62117f196f8a6 Mon Sep 17 00:00:00 2001 From: porter-support Date: Tue, 4 Aug 2026 22:34:25 +0000 Subject: [PATCH] release: Python SDK v0.1.44 --- porter_sandbox/__init__.py | 2 + porter_sandbox/_async_base_client.py | 20 ++++--- porter_sandbox/_base_client.py | 26 ++++++--- porter_sandbox/_models.py | 11 +++- porter_sandbox/resources/volumes.py | 79 ++++++++++++++++++++++++++-- porter_sandbox/volume.py | 61 ++++++++++++++++++++- pyproject.toml | 2 +- tests/test_models_round_trip.py | 8 +++ uv.lock | 2 +- 9 files changed, 188 insertions(+), 23 deletions(-) diff --git a/porter_sandbox/__init__.py b/porter_sandbox/__init__.py index 1a7ace6..11bb5db 100644 --- a/porter_sandbox/__init__.py +++ b/porter_sandbox/__init__.py @@ -36,6 +36,7 @@ StatusResponse, VolumeFileEntry, VolumeFileListResponse, + VolumeFileMoveRequest, VolumeListResponse, VolumeSpec, ) @@ -110,6 +111,7 @@ "VolumeFileEntry", "VolumeFileEntryType", "VolumeFileListResponse", + "VolumeFileMoveRequest", "VolumeListResponse", "VolumePhase", "VolumeRecord", diff --git a/porter_sandbox/_async_base_client.py b/porter_sandbox/_async_base_client.py index 4e11666..9664976 100644 --- a/porter_sandbox/_async_base_client.py +++ b/porter_sandbox/_async_base_client.py @@ -66,6 +66,8 @@ async def _request( path: str, params: Mapping[str, Any] | None = None, json: Any = None, + content: bytes | None = None, + content_type: str | None = None, headers: Mapping[str, str | None] | None = None, timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT, retry: bool = True, @@ -75,6 +77,8 @@ async def _request( path=path, params=params, json=json, + content=content, + content_type=content_type, headers=headers, accept="application/json", timeout=timeout, @@ -89,6 +93,7 @@ async def _request_binary( path: str, params: Mapping[str, Any] | None = None, headers: Mapping[str, str | None] | None = None, + timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT, ) -> BinaryContent: response = await self._send( method=method, @@ -97,6 +102,7 @@ async def _request_binary( json=None, headers=headers, accept="application/octet-stream", + timeout=timeout, ) return _binary_content(response) @@ -109,14 +115,15 @@ async def _send( json: Any, headers: Mapping[str, str | None] | None, accept: str, + content: bytes | None = None, + content_type: str | None = None, timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT, retry: bool = True, ) -> httpx.Response: - # `timeout=None` disables the timeout entirely, used for long-running - # calls like exec, where the API works for the full duration of the - # request. `retry=False` is for calls that must not be re-sent (exec): - # a failed attempt may have executed server-side, so retrying could - # run the command again. + # `timeout=None` disables the timeout, for a call the API works on for + # the full duration of the request. `retry=False` is for a call that + # must not be re-sent, because a failed attempt may have run + # server-side. max_retries = self._max_retries if retry else 0 last_error: Exception | None = None @@ -127,7 +134,8 @@ async def _send( url=path, params=params, json=json, - headers=_request_headers(headers, accept), + content=content, + headers=_request_headers(headers, accept, content_type), timeout=timeout, ) except httpx.TimeoutException as exc: diff --git a/porter_sandbox/_base_client.py b/porter_sandbox/_base_client.py index 1bca67b..61cdea0 100644 --- a/porter_sandbox/_base_client.py +++ b/porter_sandbox/_base_client.py @@ -38,8 +38,12 @@ def _error_message(body: Any, status_code: int) -> str: return f"HTTP {status_code}" -def _request_headers(headers: Mapping[str, str | None] | None, accept: str) -> dict[str, str]: +def _request_headers( + headers: Mapping[str, str | None] | None, accept: str, content_type: str | None = None +) -> dict[str, str]: sent = {"Accept": accept} + if content_type is not None: + sent["Content-Type"] = content_type for name, value in (headers or {}).items(): if value is not None: sent[name] = value @@ -93,6 +97,8 @@ def _request( path: str, params: Mapping[str, Any] | None = None, json: Any = None, + content: bytes | None = None, + content_type: str | None = None, headers: Mapping[str, str | None] | None = None, timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT, retry: bool = True, @@ -102,6 +108,8 @@ def _request( path=path, params=params, json=json, + content=content, + content_type=content_type, headers=headers, accept="application/json", timeout=timeout, @@ -116,6 +124,7 @@ def _request_binary( path: str, params: Mapping[str, Any] | None = None, headers: Mapping[str, str | None] | None = None, + timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT, ) -> BinaryContent: response = self._send( method=method, @@ -124,6 +133,7 @@ def _request_binary( json=None, headers=headers, accept="application/octet-stream", + timeout=timeout, ) return _binary_content(response) @@ -136,14 +146,15 @@ def _send( json: Any, headers: Mapping[str, str | None] | None, accept: str, + content: bytes | None = None, + content_type: str | None = None, timeout: float | None | UseClientDefault = httpx.USE_CLIENT_DEFAULT, retry: bool = True, ) -> httpx.Response: - # `timeout=None` disables the timeout entirely, used for long-running - # calls like exec, where the API works for the full duration of the - # request. `retry=False` is for calls that must not be re-sent (exec): - # a failed attempt may have executed server-side, so retrying could - # run the command again. + # `timeout=None` disables the timeout, for a call the API works on for + # the full duration of the request. `retry=False` is for a call that + # must not be re-sent, because a failed attempt may have run + # server-side. max_retries = self._max_retries if retry else 0 last_error: Exception | None = None @@ -154,7 +165,8 @@ def _send( url=path, params=params, json=json, - headers=_request_headers(headers, accept), + content=content, + headers=_request_headers(headers, accept, content_type), timeout=timeout, ) except httpx.TimeoutException as exc: diff --git a/porter_sandbox/_models.py b/porter_sandbox/_models.py index 1b906a7..1e303e1 100644 --- a/porter_sandbox/_models.py +++ b/porter_sandbox/_models.py @@ -3,7 +3,7 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from .enums import ( FilterValuesResponsePhases, @@ -160,6 +160,13 @@ class VolumeFileListResponse(BaseModel): truncated: bool = Field(description="Whether the walk stopped at its entry budget before reading every\nentry. Directories left unread or partially read carry their own\ntruncated marker.\n") +class VolumeFileMoveRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + from_: str = Field(alias="from", description="File or directory to move, relative to the volume root, with or\nwithout a leading slash. A directory moves with everything under it.\n") + to: str = Field(description="Where to move it to, relative to the volume root, with or without a\nleading slash. This is the entry's full new path rather than the\ndirectory to place it in, so a move renames and relocates in one call.\nThe parent directory must already exist.\n") + + class VolumeListResponse(BaseModel): volumes: list[Volume] = Field(description="All volumes in the cluster") @@ -168,4 +175,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", "VolumeListResponse", "VolumeSpec"] +__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"] diff --git a/porter_sandbox/resources/volumes.py b/porter_sandbox/resources/volumes.py index b7bbadb..afe3b15 100644 --- a/porter_sandbox/resources/volumes.py +++ b/porter_sandbox/resources/volumes.py @@ -10,7 +10,14 @@ from .._async_base_client import _AsyncBaseClient from .._base_client import _BaseClient from .._binary import BinaryContent -from .._models import LookupResult, Volume, VolumeFileListResponse, VolumeListResponse, VolumeSpec +from .._models import ( + LookupResult, + Volume, + VolumeFileListResponse, + VolumeFileMoveRequest, + VolumeListResponse, + VolumeSpec, +) _M = TypeVar("_M", bound=BaseModel) @@ -100,7 +107,7 @@ def list_volume_files(self, id: str, path: str | None = None, search: str | None response = self._client._request(method="GET", path=path_, params=params) return _coerce(VolumeFileListResponse, response) - def read_volume_file(self, id: str, path: str, range: str | None = None) -> BinaryContent: + def read_volume_file(self, id: str, path: str, range: str | None = None, timeout: float | None = None) -> BinaryContent: """ Read volume file @@ -112,9 +119,40 @@ def read_volume_file(self, id: str, path: str, range: str | None = None) -> Bina path_ = f"/v1/volume/{id}/files/content" params: dict[str, Any] = {} params["path"] = path - response: BinaryContent = self._client._request_binary(method="GET", path=path_, params=params, headers={"Range": range}) + 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: + """ + Write volume file + + Write a file to a volume, replacing whatever is at the path and creating + parent directories as needed. The request body is the file's raw bytes, + of any size. The write is only visible at the path once the whole body + has been received, so an upload that fails partway leaves the previous + content in place rather than a truncated file. + """ + path_ = f"/v1/volume/{id}/files/content" + params: dict[str, Any] = {} + params["path"] = path + self._client._request(method="PUT", path=path_, params=params, content=body, content_type="application/octet-stream", timeout=timeout) + return None + + def move_volume_file(self, id: str, body: VolumeFileMoveRequest) -> None: + """ + Move volume file + + Move or rename a file or directory inside a volume. The destination is + the entry's full new path, so one call covers both renaming in place and + relocating into another directory, and a directory moves with everything + under it. The destination's parent directory must already exist, and a + move onto a path something is already at is refused rather than + overwriting it. + """ + path = f"/v1/volume/{id}/files/move" + self._client._request(method="POST", path=path, json=body.model_dump(by_alias=True, exclude_none=True) if hasattr(body, "model_dump") else body) + return None + class AsyncVolumes: """Volumes resource.""" @@ -196,7 +234,7 @@ async def list_volume_files(self, id: str, path: str | None = None, search: str response = await self._client._request(method="GET", path=path_, params=params) return _coerce(VolumeFileListResponse, response) - async def read_volume_file(self, id: str, path: str, range: str | None = None) -> BinaryContent: + async def read_volume_file(self, id: str, path: str, range: str | None = None, timeout: float | None = None) -> BinaryContent: """ Read volume file @@ -208,5 +246,36 @@ async def read_volume_file(self, id: str, path: str, range: str | None = None) - path_ = f"/v1/volume/{id}/files/content" params: dict[str, Any] = {} params["path"] = path - response: BinaryContent = await self._client._request_binary(method="GET", path=path_, params=params, headers={"Range": range}) + 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: + """ + Write volume file + + Write a file to a volume, replacing whatever is at the path and creating + parent directories as needed. The request body is the file's raw bytes, + of any size. The write is only visible at the path once the whole body + has been received, so an upload that fails partway leaves the previous + content in place rather than a truncated file. + """ + path_ = f"/v1/volume/{id}/files/content" + params: dict[str, Any] = {} + params["path"] = path + await self._client._request(method="PUT", path=path_, params=params, content=body, content_type="application/octet-stream", timeout=timeout) + return None + + async def move_volume_file(self, id: str, body: VolumeFileMoveRequest) -> None: + """ + Move volume file + + Move or rename a file or directory inside a volume. The destination is + the entry's full new path, so one call covers both renaming in place and + relocating into another directory, and a directory moves with everything + under it. The destination's parent directory must already exist, and a + move onto a path something is already at is refused rather than + overwriting it. + """ + path = f"/v1/volume/{id}/files/move" + 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) + return None diff --git a/porter_sandbox/volume.py b/porter_sandbox/volume.py index 1dc062c..840812e 100644 --- a/porter_sandbox/volume.py +++ b/porter_sandbox/volume.py @@ -8,7 +8,7 @@ from datetime import datetime from porter_sandbox._models import Volume as VolumeRecord -from porter_sandbox._models import VolumeFileEntry +from porter_sandbox._models import VolumeFileEntry, VolumeFileMoveRequest from porter_sandbox.enums import VolumeFileEntryType, VolumePhase from porter_sandbox.resources.volumes import AsyncVolumes as AsyncVolumesResource from porter_sandbox.resources.volumes import Volumes as VolumesResource @@ -32,6 +32,15 @@ def _byte_range_header(offset: int, length: int | None) -> str: return f"bytes={offset}-" if length is None else f"bytes={offset}-{offset + length - 1}" +def _move_request(from_path: str, to_path: str) -> VolumeFileMoveRequest: + # "from" is a Python keyword, so the model names the field "from_" and + # aliases it. A dict keys off the alias, which is the name the model + # declares. + return VolumeFileMoveRequest.model_validate( + {"from": _normalize_path(from_path), "to": _normalize_path(to_path)} + ) + + def _parse_timestamp(value: str | None) -> datetime | None: if not value: return None @@ -175,6 +184,31 @@ 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: + """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. + """ + self._volumes.write_volume_file(id=self.id, body=content, path=_normalize_path(path)) + + def write_text(self, path: str, content: str, *, encoding: str = "utf-8") -> None: + """Write text to a file. See `write_file`.""" + self.write_file(path, content.encode(encoding)) + + def move_file(self, from_path: str, to_path: str) -> None: + """Move or rename a file or directory. + + `to_path` is the entry's full new path rather than a directory to place + it in, so the same call renames in place and relocates, and a directory + moves with everything under it. Nothing is replaced: a destination + something is already at fails and leaves the entry where it was. + """ + self._volumes.move_volume_file( + id=self.id, + body=_move_request(from_path, to_path), + ) + def stream(self, path: str, *, chunk_size: int = DEFAULT_CHUNK_BYTES) -> Iterator[bytes]: """Read a file in chunks, so a large one never lands in memory whole. @@ -331,6 +365,31 @@ 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: + """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. + """ + await self._volumes.write_volume_file(id=self.id, body=content, path=_normalize_path(path)) + + async def write_text(self, path: str, content: str, *, encoding: str = "utf-8") -> None: + """Write text to a file. See `write_file`.""" + await self.write_file(path, content.encode(encoding)) + + async def move_file(self, from_path: str, to_path: str) -> None: + """Move or rename a file or directory. + + `to_path` is the entry's full new path rather than a directory to place + it in, so the same call renames in place and relocates, and a directory + moves with everything under it. Nothing is replaced: a destination + something is already at fails and leaves the entry where it was. + """ + await self._volumes.move_volume_file( + id=self.id, + body=_move_request(from_path, to_path), + ) + async def stream( self, path: str, *, chunk_size: int = DEFAULT_CHUNK_BYTES ) -> AsyncIterator[bytes]: diff --git a/pyproject.toml b/pyproject.toml index b3604bc..96d1159 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "porter-sandbox" -version = "0.1.41" +version = "0.1.44" 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 8d48089..77c5f79 100644 --- a/tests/test_models_round_trip.py +++ b/tests/test_models_round_trip.py @@ -22,6 +22,7 @@ SandboxNetworkingSpec, SandboxSpec, VolumeFileListResponse, + VolumeFileMoveRequest, VolumeListResponse, VolumeSpec, ) @@ -153,6 +154,13 @@ def test_volume_file_list_response_round_trip() -> None: assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized +def test_volume_file_move_request_round_trip() -> None: + instance = VolumeFileMoveRequest(from_="x", to="x") + serialized = instance.model_dump(by_alias=True, exclude_none=True) + round_tripped = VolumeFileMoveRequest.model_validate(serialized) + assert round_tripped.model_dump(by_alias=True, exclude_none=True) == serialized + + def test_volume_list_response_round_trip() -> None: instance = VolumeListResponse(volumes=[]) serialized = instance.model_dump(by_alias=True, exclude_none=True) diff --git a/uv.lock b/uv.lock index 7e91466..c1237d3 100644 --- a/uv.lock +++ b/uv.lock @@ -345,7 +345,7 @@ wheels = [ [[package]] name = "porter-sandbox" -version = "0.1.41" +version = "0.1.44" source = { editable = "." } dependencies = [ { name = "httpx" },