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
21 changes: 15 additions & 6 deletions sdks/sandbox/javascript/src/api/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,8 +468,11 @@ export interface components {
metadata?: {
[key: string]: string;
};
/** @description Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled. */
expiresAt?: string | null;
/**
* Format: date-time
* @description Timestamp when sandbox will auto-terminate. Omitted when manual cleanup is enabled.
*/
expiresAt?: string;
/**
* Format: date-time
* @description Sandbox creation timestamp
Expand Down Expand Up @@ -498,8 +501,11 @@ export interface components {
* Always present in responses since entrypoint is required in creation requests.
*/
entrypoint: string[];
/** @description Timestamp when sandbox will auto-terminate. Null when manual cleanup is enabled. */
expiresAt?: string | null;
/**
* Format: date-time
* @description Timestamp when sandbox will auto-terminate. Omitted when manual cleanup is enabled.
*/
expiresAt?: string;
/**
* Format: date-time
* @description Sandbox creation timestamp
Expand Down Expand Up @@ -583,9 +589,9 @@ export interface components {
/**
* @description Sandbox timeout in seconds. The sandbox will automatically terminate after this duration.
* The maximum is controlled by the server configuration (`server.max_sandbox_timeout_seconds`).
* Omit or set null to disable automatic expiration and require explicit cleanup.
* Omit this field or set it to null to disable automatic expiration and require explicit cleanup.
* Note: manual cleanup support is runtime-dependent; Kubernetes providers may reject
* null timeout when the underlying workload provider does not support non-expiring sandboxes.
* omitted or null timeout when the underlying workload provider does not support non-expiring sandboxes.
*/
timeout?: number | null;
/**
Expand Down Expand Up @@ -656,6 +662,9 @@ export interface components {
* **Best Practices**:
* - **Namespacing**: Use prefixed keys (e.g., `storage.id`) to prevent collisions.
* - **Pass-through**: SDKs and middleware must treat this object as opaque and pass it through transparently.
*
* **Well-known keys**:
* - `access.renew.extend.seconds` (optional): Decimal integer string from **300** to **86400** (5 minutes to 24 hours inclusive). Opts the sandbox into OSEP-0009 renew-on-access and sets per-renewal extension seconds. Omit to disable. Invalid values are rejected at creation with HTTP 400 (validated on the lifecycle create endpoint via `validate_extensions` in server `src/extensions/validation.py`).
*/
extensions?: {
[key: string]: string;
Expand Down
5 changes: 2 additions & 3 deletions sdks/sandbox/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,15 @@ sandbox = await Sandbox.resume(
# Get current status
info = await sandbox.get_info()
print(f"State: {info.status.state}")
print(f"Expires: {info.expires_at}") # None when manual cleanup mode is used
print(f"Expires: {info.expires_at}") # None when no automatic expiration is configured
```

Create a non-expiring sandbox by passing `timeout=None`:
Create a non-expiring sandbox by omitting `timeout`:

```python
manual = await Sandbox.create(
"ubuntu",
connection_config=config,
timeout=None,
)
```

Expand Down
48 changes: 0 additions & 48 deletions sdks/sandbox/python/scripts/generate_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,53 +249,6 @@ def add_license_headers(root: Path) -> None:
)


def patch_lifecycle_nullable_nested_models(root: Path) -> None:
"""Patch generated lifecycle models that openapi-python-client does not null-handle."""
replacements = {
root / "models" / "image_spec.py": [
(
" if isinstance(_auth, Unset):\n auth = UNSET\n",
" if isinstance(_auth, Unset) or _auth is None:\n auth = UNSET\n",
)
],
root / "models" / "create_sandbox_response.py": [
(
" if isinstance(_metadata, Unset):\n metadata = UNSET\n",
" if isinstance(_metadata, Unset) or _metadata is None:\n metadata = UNSET\n",
)
],
root / "models" / "sandbox.py": [
(
" if isinstance(_metadata, Unset):\n metadata = UNSET\n",
" if isinstance(_metadata, Unset) or _metadata is None:\n metadata = UNSET\n",
)
],
root / "models" / "sandbox_status.py": [
(
" if isinstance(_last_transition_at, Unset):\n last_transition_at = UNSET\n",
" if isinstance(_last_transition_at, Unset) or _last_transition_at is None:\n last_transition_at = UNSET\n",
)
],
}

patched_files = 0
for file_path, file_replacements in replacements.items():
if not file_path.exists():
continue

content = file_path.read_text(encoding="utf-8")
updated = content
for old, new in file_replacements:
if old in updated:
updated = updated.replace(old, new, 1)

if updated != content:
file_path.write_text(updated, encoding="utf-8")
patched_files += 1

if patched_files:
print(f"✅ Patched nullable lifecycle model handling in {patched_files} files")


def post_process_generated_code() -> None:
"""Post-process the generated code to ensure proper package structure."""
Expand All @@ -316,7 +269,6 @@ def post_process_generated_code() -> None:
add_license_headers(Path("src/opensandbox/api/egress"))
add_license_headers(Path("src/opensandbox/api/lifecycle"))
add_license_headers(Path("src/opensandbox/api"))
patch_lifecycle_nullable_nested_models(Path("src/opensandbox/api/lifecycle"))


def main() -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ class CreateSandboxRequest:
timeout (int | None | Unset): Sandbox timeout in seconds. The sandbox will automatically terminate after this
duration.
The maximum is controlled by the server configuration (`server.max_sandbox_timeout_seconds`).
Omit or set null to disable automatic expiration and require explicit cleanup.
Omit this field or set it to null to disable automatic expiration and require explicit cleanup.
Note: manual cleanup support is runtime-dependent; Kubernetes providers may reject
null timeout when the underlying workload provider does not support non-expiring sandboxes.
omitted or null timeout when the underlying workload provider does not support non-expiring sandboxes.
env (CreateSandboxRequestEnv | Unset): Environment variables to inject into the sandbox runtime. Example:
{'API_KEY': 'secret-key', 'DEBUG': 'true', 'LOG_LEVEL': 'info'}.
metadata (CreateSandboxRequestMetadata | Unset): Custom key-value metadata for management, filtering, and
Expand All @@ -98,6 +98,12 @@ class CreateSandboxRequest:
**Best Practices**:
- **Namespacing**: Use prefixed keys (e.g., `storage.id`) to prevent collisions.
- **Pass-through**: SDKs and middleware must treat this object as opaque and pass it through transparently.

**Well-known keys**:
- `access.renew.extend.seconds` (optional): Decimal integer string from **300** to **86400** (5 minutes to 24
hours inclusive). Opts the sandbox into OSEP-0009 renew-on-access and sets per-renewal extension seconds. Omit
to disable. Invalid values are rejected at creation with HTTP 400 (validated on the lifecycle create endpoint
via `validate_extensions` in server `src/extensions/validation.py`).
"""

image: ImageSpec
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ class CreateSandboxRequestExtensions:
- **Namespacing**: Use prefixed keys (e.g., `storage.id`) to prevent collisions.
- **Pass-through**: SDKs and middleware must treat this object as opaque and pass it through transparently.
**Well-known keys**:
- `access.renew.extend.seconds` (optional): Decimal integer string from **300** to **86400** (5 minutes to 24 hours
inclusive). Opts the sandbox into OSEP-0009 renew-on-access and sets per-renewal extension seconds. Omit to disable.
Invalid values are rejected at creation with HTTP 400 (validated on the lifecycle create endpoint via
`validate_extensions` in server `src/extensions/validation.py`).
"""

additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,16 @@ class CreateSandboxResponse:
created_at (datetime.datetime): Sandbox creation timestamp
entrypoint (list[str]): Entry process specification from creation request
metadata (CreateSandboxResponseMetadata | Unset): Custom metadata from creation request
expires_at (datetime.datetime | None | Unset): Timestamp when sandbox will auto-terminate. Null when manual
cleanup is enabled.
expires_at (datetime.datetime | Unset): Timestamp when sandbox will auto-terminate. Omitted when manual cleanup
is enabled.
"""

id: str
status: SandboxStatus
created_at: datetime.datetime
entrypoint: list[str]
metadata: CreateSandboxResponseMetadata | Unset = UNSET
expires_at: datetime.datetime | None | Unset = UNSET
expires_at: datetime.datetime | Unset = UNSET
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)

def to_dict(self) -> dict[str, Any]:
Expand All @@ -69,13 +69,9 @@ def to_dict(self) -> dict[str, Any]:
if not isinstance(self.metadata, Unset):
metadata = self.metadata.to_dict()

expires_at: None | str | Unset
if isinstance(self.expires_at, Unset):
expires_at = UNSET
elif isinstance(self.expires_at, datetime.datetime):
expires_at: str | Unset = UNSET
if not isinstance(self.expires_at, Unset):
expires_at = self.expires_at.isoformat()
else:
expires_at = self.expires_at

field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
Expand Down Expand Up @@ -110,27 +106,17 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:

_metadata = d.pop("metadata", UNSET)
metadata: CreateSandboxResponseMetadata | Unset
if isinstance(_metadata, Unset) or _metadata is None:
if isinstance(_metadata, Unset):
metadata = UNSET
else:
metadata = CreateSandboxResponseMetadata.from_dict(_metadata)

def _parse_expires_at(data: object) -> datetime.datetime | None | Unset:
if data is None:
return data
if isinstance(data, Unset):
return data
try:
if not isinstance(data, str):
raise TypeError()
expires_at_type_0 = isoparse(data)

return expires_at_type_0
except (TypeError, ValueError, AttributeError, KeyError):
pass
return cast(datetime.datetime | None | Unset, data)

expires_at = _parse_expires_at(d.pop("expiresAt", UNSET))
_expires_at = d.pop("expiresAt", UNSET)
expires_at: datetime.datetime | Unset
if isinstance(_expires_at, Unset):
expires_at = UNSET
else:
expires_at = isoparse(_expires_at)

create_sandbox_response = cls(
id=id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:

_auth = d.pop("auth", UNSET)
auth: ImageSpecAuth | Unset
if isinstance(_auth, Unset) or _auth is None:
if isinstance(_auth, Unset):
auth = UNSET
else:
auth = ImageSpecAuth.from_dict(_auth)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ class Sandbox:
Always present in responses since entrypoint is required in creation requests.
created_at (datetime.datetime): Sandbox creation timestamp
metadata (SandboxMetadata | Unset): Custom metadata from creation request
expires_at (datetime.datetime | None | Unset): Timestamp when sandbox will auto-terminate. Null when manual
cleanup is enabled.
expires_at (datetime.datetime | Unset): Timestamp when sandbox will auto-terminate. Omitted when manual cleanup
is enabled.
"""

id: str
Expand All @@ -59,7 +59,7 @@ class Sandbox:
entrypoint: list[str]
created_at: datetime.datetime
metadata: SandboxMetadata | Unset = UNSET
expires_at: datetime.datetime | None | Unset = UNSET
expires_at: datetime.datetime | Unset = UNSET
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)

def to_dict(self) -> dict[str, Any]:
Expand All @@ -77,13 +77,9 @@ def to_dict(self) -> dict[str, Any]:
if not isinstance(self.metadata, Unset):
metadata = self.metadata.to_dict()

expires_at: None | str | Unset
if isinstance(self.expires_at, Unset):
expires_at = UNSET
elif isinstance(self.expires_at, datetime.datetime):
expires_at: str | Unset = UNSET
if not isinstance(self.expires_at, Unset):
expires_at = self.expires_at.isoformat()
else:
expires_at = self.expires_at

field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
Expand Down Expand Up @@ -122,27 +118,17 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:

_metadata = d.pop("metadata", UNSET)
metadata: SandboxMetadata | Unset
if isinstance(_metadata, Unset) or _metadata is None:
if isinstance(_metadata, Unset):
metadata = UNSET
else:
metadata = SandboxMetadata.from_dict(_metadata)

def _parse_expires_at(data: object) -> datetime.datetime | None | Unset:
if data is None:
return data
if isinstance(data, Unset):
return data
try:
if not isinstance(data, str):
raise TypeError()
expires_at_type_0 = isoparse(data)

return expires_at_type_0
except (TypeError, ValueError, AttributeError, KeyError):
pass
return cast(datetime.datetime | None | Unset, data)

expires_at = _parse_expires_at(d.pop("expiresAt", UNSET))
_expires_at = d.pop("expiresAt", UNSET)
expires_at: datetime.datetime | Unset
if isinstance(_expires_at, Unset):
expires_at = UNSET
else:
expires_at = isoparse(_expires_at)

sandbox = cls(
id=id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:

_last_transition_at = d.pop("lastTransitionAt", UNSET)
last_transition_at: datetime.datetime | Unset
if isinstance(_last_transition_at, Unset) or _last_transition_at is None:
if isinstance(_last_transition_at, Unset):
last_transition_at = UNSET
else:
last_transition_at = isoparse(_last_transition_at)
Expand Down
22 changes: 9 additions & 13 deletions sdks/sandbox/python/tests/test_models_stability.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,42 +56,38 @@ def test_sandbox_image_spec_rejects_blank_image() -> None:
SandboxImageSpec(" ")


def test_api_image_spec_tolerates_null_auth() -> None:
spec = ApiImageSpec.from_dict({"uri": "python:3.11", "auth": None})
def test_api_image_spec_tolerates_omitted_auth() -> None:
spec = ApiImageSpec.from_dict({"uri": "python:3.11"})
assert spec.uri == "python:3.11"
assert spec.auth is UNSET


def test_api_create_sandbox_response_tolerates_null_metadata() -> None:
def test_api_create_sandbox_response_tolerates_omitted_optional_fields() -> None:
response = ApiCreateSandboxResponse.from_dict(
{
"id": "sandbox-1",
"status": {"state": "Running", "lastTransitionAt": None},
"status": {"state": "Running"},
"createdAt": "2025-01-01T00:00:00Z",
"entrypoint": ["/bin/sh"],
"metadata": None,
"expiresAt": None,
}
)
assert response.metadata is UNSET
assert response.expires_at is None
assert response.expires_at is UNSET
assert response.status.last_transition_at is UNSET


def test_api_sandbox_tolerates_null_metadata() -> None:
def test_api_sandbox_tolerates_omitted_optional_fields() -> None:
sandbox = ApiSandbox.from_dict(
{
"id": "sandbox-1",
"image": {"uri": "python:3.11", "auth": None},
"status": {"state": "Running", "lastTransitionAt": None},
"image": {"uri": "python:3.11"},
"status": {"state": "Running"},
"entrypoint": ["/bin/sh"],
"createdAt": "2025-01-01T00:00:00Z",
"metadata": None,
"expiresAt": None,
}
)
assert sandbox.metadata is UNSET
assert sandbox.expires_at is None
assert sandbox.expires_at is UNSET
assert sandbox.status.last_transition_at is UNSET


Expand Down
Loading