Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions porter_sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
StatusResponse,
VolumeFileEntry,
VolumeFileListResponse,
VolumeFileMoveRequest,
VolumeListResponse,
VolumeSpec,
)
Expand Down Expand Up @@ -110,6 +111,7 @@
"VolumeFileEntry",
"VolumeFileEntryType",
"VolumeFileListResponse",
"VolumeFileMoveRequest",
"VolumeListResponse",
"VolumePhase",
"VolumeRecord",
Expand Down
20 changes: 14 additions & 6 deletions porter_sandbox/_async_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -97,6 +102,7 @@ async def _request_binary(
json=None,
headers=headers,
accept="application/octet-stream",
timeout=timeout,
)
return _binary_content(response)

Expand All @@ -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

Expand All @@ -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:
Expand Down
26 changes: 19 additions & 7 deletions porter_sandbox/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -124,6 +133,7 @@ def _request_binary(
json=None,
headers=headers,
accept="application/octet-stream",
timeout=timeout,
)
return _binary_content(response)

Expand All @@ -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

Expand All @@ -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:
Expand Down
11 changes: 9 additions & 2 deletions porter_sandbox/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from __future__ import annotations

from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field

from .enums import (
FilterValuesResponsePhases,
Expand Down Expand Up @@ -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")

Expand All @@ -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"]
79 changes: 74 additions & 5 deletions porter_sandbox/resources/volumes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand All @@ -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."""
Expand Down Expand Up @@ -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

Expand All @@ -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
61 changes: 60 additions & 1 deletion porter_sandbox/volume.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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]:
Expand Down
Loading
Loading