diff --git a/docs/sandboxes.md b/docs/sandboxes.md index 0877a3a9..2f31edcf 100644 --- a/docs/sandboxes.md +++ b/docs/sandboxes.md @@ -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 diff --git a/src/volcano_sdk/_sandbox.py b/src/volcano_sdk/_sandbox.py index 74cf1723..69558ace 100644 --- a/src/volcano_sdk/_sandbox.py +++ b/src/volcano_sdk/_sandbox.py @@ -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. diff --git a/src/volcano_sdk/_tests/fixtures/invalid_arguments.py b/src/volcano_sdk/_tests/fixtures/invalid_arguments.py index fcbaba4b..cbdf236d 100644 --- a/src/volcano_sdk/_tests/fixtures/invalid_arguments.py +++ b/src/volcano_sdk/_tests/fixtures/invalid_arguments.py @@ -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 @@ -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" diff --git a/src/volcano_sdk/_tests/test_sandboxes.py b/src/volcano_sdk/_tests/test_sandboxes.py index 73650073..ca2b28bb 100644 --- a/src/volcano_sdk/_tests/test_sandboxes.py +++ b/src/volcano_sdk/_tests/test_sandboxes.py @@ -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 @@ -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 @@ -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( @@ -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", @@ -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()) diff --git a/src/volcano_sdk/sandbox_models.py b/src/volcano_sdk/sandbox_models.py index 252a7e88..277ce1b2 100644 --- a/src/volcano_sdk/sandbox_models.py +++ b/src/volcano_sdk/sandbox_models.py @@ -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] diff --git a/src/volcano_sdk/sandbox_session.py b/src/volcano_sdk/sandbox_session.py index e9c8ec5f..9725e098 100644 --- a/src/volcano_sdk/sandbox_session.py +++ b/src/volcano_sdk/sandbox_session.py @@ -15,6 +15,7 @@ command_body, command_result, identifier, + nullable_timestamp, record, request_id, state, @@ -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 @@ -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 @@ -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 diff --git a/src/volcano_sdk/sandboxes.py b/src/volcano_sdk/sandboxes.py index 674e0c18..75894674 100644 --- a/src/volcano_sdk/sandboxes.py +++ b/src/volcano_sdk/sandboxes.py @@ -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",