Skip to content
Open
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: 1 addition & 1 deletion docs/sandboxes.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Context exit skips an already terminating or terminated session and treats a
cleanup 404 as already removed. Other cleanup failures preserve an existing body
exception and attach a note. Session identity, lifecycle state, expiry, and file
access are read-only properties. `state` is a `SandboxState` literal value;
`session.expires_at` and `access.expires_at` are timezone-aware `datetime` values.
`access.expires_at` is a timezone-aware `datetime`. `session.expires_at` is too, or `None` when the session has no lifetime limit (a local-mode session), so check for `None` before using it as a time.

## Access a background HTTP service

Expand Down
12 changes: 12 additions & 0 deletions src/volcano_sdk/_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,18 @@ def timestamp(value: object) -> datetime:
return result


def nullable_timestamp(value: object) -> datetime | None:
"""Parse an aware timestamp that the Sandbox API may return as null.

A session with no lifetime limit, such as a local-mode session, has no expiry.

Returns:
The aware response timestamp, or None for a null value.

"""
return None if value is None else timestamp(value)


def grant_expiry(value: object) -> str:
"""Serialize an aware grant expiry.

Expand Down
10 changes: 10 additions & 0 deletions src/volcano_sdk/_tests/fixtures/invalid_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

if TYPE_CHECKING:
from collections.abc import Mapping
from datetime import datetime
from typing import BinaryIO

from volcano_sdk.auth import Auth
Expand Down Expand Up @@ -39,6 +40,15 @@ def one_shot_idle_timeout(sandboxes: Sandboxes) -> None:
sandboxes.exec("project", "run", region="r", idle_timeout_seconds=60) # type: ignore[call-arg] # pyright: ignore[reportCallIssue]


def session_idle_timeout(sandboxes: Sandboxes) -> None:
project_id = "00000000-0000-4000-8000-000000000001"
sandboxes.create(project_id, region="r", preset="p", idle_timeout_seconds=60) # type: ignore[call-arg] # pyright: ignore[reportCallIssue]


def session_expiry_is_optional(session: SandboxSession) -> datetime:
return session.expires_at # type: ignore[return-value] # pyright: ignore[reportReturnType]


def non_datetime_sandbox_grant(sandboxes: Sandboxes, value: object) -> None:
session_id = "00000000-0000-4000-8000-000000000002"
subject_id = "00000000-0000-4000-8000-000000000003"
Expand Down
59 changes: 55 additions & 4 deletions src/volcano_sdk/_tests/test_sandboxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@
)
from volcano_sdk._transport import GeneratedTransport

from .fixtures.invalid_arguments import assign_sandbox_state, non_datetime_sandbox_grant
from .fixtures.invalid_arguments import (
assign_sandbox_state,
non_datetime_sandbox_grant,
session_idle_timeout,
)
from .session_fixtures import access_token
from .transport_fixtures import RejectingTransport

Expand Down Expand Up @@ -105,7 +109,6 @@ def test_sandbox_selectors_and_mutation_identity() -> None:
sandbox_id=SUBJECT,
request_id=KEY,
max_duration_seconds=300,
idle_timeout_seconds=30,
)
assert session.id == SESSION
first, second = server.requests
Expand All @@ -116,11 +119,21 @@ def test_sandbox_selectors_and_mutation_identity() -> None:
"sandbox_id": SUBJECT,
"region": "aws-us-east-1",
"max_duration_seconds": 300,
"idle_timeout_seconds": 30,
}
assert first.extensions["timeout"]["read"] == 60


def test_sandbox_create_never_sends_the_removed_idle_timeout() -> None:
# The API rejects unknown fields, so a leftover option must not be sent.
server = SandboxHTTP()
server.reply(session_body(), 201)
session_idle_timeout(server.client.sandboxes)
assert json.loads(server.requests[-1].content) == {
"preset": "p",
"region": "r",
}


def test_sandbox_one_shot_and_session_execution() -> None:
server = SandboxHTTP()
server.reply(
Expand Down Expand Up @@ -406,7 +419,7 @@ def test_sandbox_rejects_invalid_command_response(field: str, value: object) ->
("field", "value", "message"),
[
("state", "invalid", "Invalid Sandbox state"),
("expires_at", None, "Invalid Sandbox text field"),
("expires_at", 3600, "Invalid Sandbox text field"),
("expires_at", "tomorrow", "Invalid Sandbox timestamp"),
(
"expires_at",
Expand Down Expand Up @@ -684,6 +697,44 @@ def test_sandbox_cleanup_accepts_an_already_removed_session() -> None:
assert not hasattr(caught.value, "__notes__")


def expiry_of(session: SandboxSession) -> datetime | None:
return session.expires_at


def test_sandbox_with_no_lifetime_limit_has_no_expiry() -> None:
# A local-mode session has no lifetime limit, so the API returns expires_at: null.
server = SandboxHTTP()
unlimited = session_body() | {"expires_at": None}
server.reply(unlimited, 201)
created = server.client.sandboxes.create(
PROJECT, region="aws-us-east-1", preset="python3.12"
)
assert expiry_of(created) is None
server.reply(unlimited)
assert expiry_of(server.client.sandboxes.get(SESSION)) is None
server.reply(session_body())
session = server.client.sandboxes.get(SESSION)
assert expiry_of(session) == datetime(2026, 9, 25, tzinfo=UTC)
server.reply(unlimited)
assert session.refresh() is session
assert expiry_of(session) is None
server.reply(session_body())
assert session.refresh() is session
assert expiry_of(session) == datetime(2026, 9, 25, tzinfo=UTC)


def test_sandbox_access_expiry_stays_required() -> None:
# Only the session's expiry may be null; an access credential always expires.
server = SandboxHTTP()
server.reply(session_body())
session = server.client.sandboxes.get(SESSION)
server.reply(
{"url": "https://sandbox.test/access", "token": "secret", "expires_at": None}
)
with pytest.raises(TypeError, match=r"^Invalid Sandbox text field$"):
_ = session.access(8080)


def test_sandbox_metadata_is_read_only_and_expires_at_is_aware() -> None:
server = SandboxHTTP()
server.reply(session_body())
Expand Down
1 change: 0 additions & 1 deletion src/volcano_sdk/sandbox_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ class SandboxCreateOptions(TypedDict):
sandbox_id: NotRequired[str]
memory_mb: NotRequired[int]
max_duration_seconds: NotRequired[int]
idle_timeout_seconds: NotRequired[int]
request_id: NotRequired[str]


Expand Down
9 changes: 5 additions & 4 deletions src/volcano_sdk/sandbox_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
command_body,
command_result,
identifier,
nullable_timestamp,
record,
request_id,
state,
Expand Down Expand Up @@ -86,7 +87,7 @@ def __init__(self, requests: SandboxRequests, value: object) -> None:
self._project_id: str = identifier(text(data.get("project_id")))
self._region: str = text(data.get("region"))
self._state: SandboxState = state(data.get("state"))
self._expires_at: datetime = timestamp(data.get("expires_at"))
self._expires_at: datetime | None = nullable_timestamp(data.get("expires_at"))
self._files: SandboxFiles = SandboxFiles(requests, self._id)

@property
Expand All @@ -110,8 +111,8 @@ def state(self) -> SandboxState:
return self._state

@property
def expires_at(self) -> datetime:
"""The aware expiration timestamp observed from the API."""
def expires_at(self) -> datetime | None:
"""The aware expiry, or None when the session has no lifetime limit."""
return self._expires_at

@property
Expand Down Expand Up @@ -163,7 +164,7 @@ def _update(self, operation: str, status: int = 200) -> SandboxSession:
message = "Sandbox session identity changed"
raise TypeError(message)
next_state = state(response.get("state"))
expiry = timestamp(response.get("expires_at"))
expiry = nullable_timestamp(response.get("expires_at"))
self._state, self._expires_at = next_state, expiry
return self

Expand Down
5 changes: 2 additions & 3 deletions src/volcano_sdk/sandboxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,8 @@ def create(
"""
requests = self._requests.management()
body = selector(options)
for key in ("max_duration_seconds", "idle_timeout_seconds"):
if key in options:
body[key] = integer(options.get(key))
if "max_duration_seconds" in options:
body["max_duration_seconds"] = integer(options.get("max_duration_seconds"))
response = requests.send(
SandboxRequest(
"create_sandbox_session",
Expand Down
Loading