From d44b82976bf2fcc2be1715f31be0eae521787ab5 Mon Sep 17 00:00:00 2001 From: sid-rl Date: Mon, 17 Nov 2025 15:24:16 -0800 Subject: [PATCH 01/56] Increase pytest workers from 2 to 10 --- .github/workflows/smoketests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/smoketests.yml b/.github/workflows/smoketests.yml index 0a7dc2338..2fe394f13 100644 --- a/.github/workflows/smoketests.yml +++ b/.github/workflows/smoketests.yml @@ -51,6 +51,6 @@ jobs: env: # Use 2 workers to run files in parallel. # Tests within a file are run sequentially. - PYTEST_ADDOPTS: "-n 2 -m smoketest" + PYTEST_ADDOPTS: "-n 10 -m smoketest" run: | uv run pytest -q -vv tests/smoketests From 723a3ffb343df43e220d3b556a2aa4d52a85571e Mon Sep 17 00:00:00 2001 From: sid-rl Date: Mon, 17 Nov 2025 16:39:39 -0800 Subject: [PATCH 02/56] Reduce pytest workers from 10 to 5 --- .github/workflows/smoketests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smoketests.yml b/.github/workflows/smoketests.yml index 2fe394f13..5931da235 100644 --- a/.github/workflows/smoketests.yml +++ b/.github/workflows/smoketests.yml @@ -49,8 +49,8 @@ jobs: - name: Run smoke tests (pytest via uv) env: - # Use 2 workers to run files in parallel. + # Use 5 workers to run files in parallel. # Tests within a file are run sequentially. - PYTEST_ADDOPTS: "-n 10 -m smoketest" + PYTEST_ADDOPTS: "-n 5 -m smoketest" run: | uv run pytest -q -vv tests/smoketests From 59426629b075bd94b45345de5db3951ad6933f84 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 6 Nov 2025 18:17:35 -0800 Subject: [PATCH 03/56] initial llm work --- README-SDK.md | 149 +++++++ README.md | 4 + src/runloop_api_client/__init__.py | 3 + src/runloop_api_client/sdk/__init__.py | 41 ++ src/runloop_api_client/sdk/_async.py | 80 ++++ src/runloop_api_client/sdk/_helpers.py | 35 ++ src/runloop_api_client/sdk/_sync.py | 80 ++++ src/runloop_api_client/sdk/async_blueprint.py | 68 ++++ src/runloop_api_client/sdk/async_devbox.py | 362 +++++++++++++++++ src/runloop_api_client/sdk/async_execution.py | 100 +++++ .../sdk/async_execution_result.py | 51 +++ src/runloop_api_client/sdk/async_snapshot.py | 72 ++++ .../sdk/async_storage_object.py | 163 ++++++++ src/runloop_api_client/sdk/blueprint.py | 77 ++++ src/runloop_api_client/sdk/devbox.py | 373 ++++++++++++++++++ src/runloop_api_client/sdk/execution.py | 108 +++++ .../sdk/execution_result.py | 61 +++ src/runloop_api_client/sdk/snapshot.py | 67 ++++ src/runloop_api_client/sdk/storage_object.py | 201 ++++++++++ tests/sdk/__init__.py | 2 + tests/sdk/test_async_devbox.py | 224 +++++++++++ tests/sdk/test_async_resources.py | 115 ++++++ tests/sdk/test_devbox.py | 208 ++++++++++ tests/sdk/test_imports.py | 28 ++ tests/sdk/test_resources.py | 111 ++++++ 25 files changed, 2783 insertions(+) create mode 100644 README-SDK.md create mode 100644 src/runloop_api_client/sdk/__init__.py create mode 100644 src/runloop_api_client/sdk/_async.py create mode 100644 src/runloop_api_client/sdk/_helpers.py create mode 100644 src/runloop_api_client/sdk/_sync.py create mode 100644 src/runloop_api_client/sdk/async_blueprint.py create mode 100644 src/runloop_api_client/sdk/async_devbox.py create mode 100644 src/runloop_api_client/sdk/async_execution.py create mode 100644 src/runloop_api_client/sdk/async_execution_result.py create mode 100644 src/runloop_api_client/sdk/async_snapshot.py create mode 100644 src/runloop_api_client/sdk/async_storage_object.py create mode 100644 src/runloop_api_client/sdk/blueprint.py create mode 100644 src/runloop_api_client/sdk/devbox.py create mode 100644 src/runloop_api_client/sdk/execution.py create mode 100644 src/runloop_api_client/sdk/execution_result.py create mode 100644 src/runloop_api_client/sdk/snapshot.py create mode 100644 src/runloop_api_client/sdk/storage_object.py create mode 100644 tests/sdk/__init__.py create mode 100644 tests/sdk/test_async_devbox.py create mode 100644 tests/sdk/test_async_resources.py create mode 100644 tests/sdk/test_devbox.py create mode 100644 tests/sdk/test_imports.py create mode 100644 tests/sdk/test_resources.py diff --git a/README-SDK.md b/README-SDK.md new file mode 100644 index 000000000..845c4bcb8 --- /dev/null +++ b/README-SDK.md @@ -0,0 +1,149 @@ +# Runloop SDK – Python Object-Oriented Client + +The `RunloopSDK` builds on top of the generated REST client and provides a Pythonic, object-oriented API for managing devboxes, blueprints, snapshots, and storage objects. The SDK exposes synchronous and asynchronous variants to match your runtime requirements. + +> **Installation** +> The SDK ships with the `runloop_api_client` package—no extra dependencies are required. + +```bash +pip install runloop_api_client +``` + +## Quickstart (synchronous) + +```python +from runloop_api_client import RunloopSDK + +sdk = RunloopSDK() + +# Create a ready-to-use devbox +with sdk.devbox.create(name="my-devbox") as devbox: + result = devbox.cmd.exec("echo 'Hello from Runloop!'") + print(result.stdout()) + + # Stream stdout in real time + devbox.cmd.exec( + "ls -la", + stdout=lambda line: print("stdout:", line), + output=lambda line: print("combined:", line), + ) + +# Blueprints +blueprint = sdk.blueprint.create( + name="my-blueprint", + dockerfile="FROM ubuntu:22.04\nRUN echo 'Hello' > /hello.txt\n", +) +devbox = blueprint.create_devbox(name="dev-from-blueprint") + +# Storage objects +obj = sdk.storage_object.upload_from_text("Hello world!", name="greeting.txt") +print(obj.download_as_text()) +``` + +## Quickstart (asynchronous) + +```python +import asyncio +from runloop_api_client import AsyncRunloopSDK + +async def main(): + sdk = AsyncRunloopSDK() + async with sdk.devbox.create(name="async-devbox") as devbox: + result = await devbox.cmd.exec("pwd") + print(await result.stdout()) + + async def capture(line: str) -> None: + print(">>", line) + + await devbox.cmd.exec("ls", stdout=capture) + +asyncio.run(main()) +``` + +## Available Resources + +- **Devbox / AsyncDevbox** + - Creation helpers (`create`, `create_from_blueprint_id`, `create_from_snapshot`, `from_id`) + - Lifecycle management (`await_running`, `suspend`, `resume`, `keep_alive`, `shutdown`) + - Command execution (`cmd.exec`, `cmd.exec_async`) with optional streaming callbacks + - File operations (`read`, `write`, `upload`, `download`) + - Network helpers (`net.create_ssh_key`, `net.create_tunnel`, `net.remove_tunnel`) + +- **Blueprint / AsyncBlueprint** + - Build orchestration (`create`) + - Fetch metadata & logs (`get_info`, `logs`) + - Spawn devboxes from existing blueprints (`create_devbox`) + +- **Snapshot / AsyncSnapshot** + - List and inspect snapshots (`list`, `get_info`, `await_completed`) + - Metadata updates (`update`), deletion (`delete`) + - Provision new devboxes from snapshots (`create_devbox`) + +- **StorageObject / AsyncStorageObject** + - Object creation (`create`, `from_id`, `list`) + - Convenience uploads (`upload_from_file`, `upload_from_text`, `upload_from_bytes`) + - Manual uploads via presigned URLs (`upload_content`, `complete`) + - Downloads (`download_as_text`, `download_as_bytes`) + +All objects expose the low-level REST ID through the `id` property, making it easy to cross-reference with existing tooling. + +## Streaming Command Output + +Pass callbacks into `cmd.exec` / `cmd.exec_async` to process logs in real time. Synchronous callbacks receive strings; asynchronous callbacks may return either `None` or `Awaitable[None]`. + +```python +def handle_output(line: str) -> None: + print("LOG:", line) + +result = devbox.cmd.exec( + "python train.py", + stdout=handle_output, + stderr=lambda line: print("ERR:", line), + output=lambda line: print("ANY:", line), +) +print("exit code:", result.exit_code) +``` + +Async example: + +```python +async def capture(line: str) -> None: + await log_queue.put(line) + +await devbox.cmd.exec( + "tail -f /var/log/app.log", + stdout=capture, +) +``` + +## Storage Object Upload Helpers + +The storage helpers manage the multi-step upload flow (create → PUT to presigned URL → complete): + +```python +from pathlib import Path + +# Upload local file with content-type detection +obj = sdk.storage_object.upload_from_file(Path("./report.csv")) + +# Manual control +obj = sdk.storage_object.create("data.bin", content_type="binary") +obj.upload_content(b"\xDE\xAD\xBE\xEF") +obj.complete() +``` + +## Accessing the Generated REST Client + +The SDK always exposes the underlying generated client through the `.api` attribute: + +```python +sdk = RunloopSDK() +raw_devbox = sdk.api.devboxes.create() +``` + +This makes it straightforward to mix high-level helpers with low-level calls whenever you need advanced control. + +## Feedback + +The object-oriented SDK is new for Python—feedback and ideas are welcome! Please open an issue or pull request on GitHub if you spot gaps, bugs, or ergonomic improvements. + diff --git a/README.md b/README.md index 97ca6da88..dabd52fc7 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ pip install runloop_api_client The full API of this library can be found in [api.md](api.md). +### Object-Oriented SDK + +For a higher-level, Pythonic interface, check out the new [`RunloopSDK`](README-SDK.md) which layers an object-oriented API on top of the generated client (including synchronous and asynchronous variants). + ```python import os from runloop_api_client import Runloop diff --git a/src/runloop_api_client/__init__.py b/src/runloop_api_client/__init__.py index d6be57db2..c74afc2c1 100644 --- a/src/runloop_api_client/__init__.py +++ b/src/runloop_api_client/__init__.py @@ -3,6 +3,7 @@ import typing as _t from . import types +from .sdk import RunloopSDK, AsyncRunloopSDK from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given from ._utils import file_from_path from ._client import Client, Stream, Runloop, Timeout, Transport, AsyncClient, AsyncStream, AsyncRunloop, RequestOptions @@ -39,6 +40,8 @@ "NotGiven", "NOT_GIVEN", "not_given", + "RunloopSDK", + "AsyncRunloopSDK", "Omit", "omit", "RunloopError", diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py new file mode 100644 index 000000000..c647d9f3f --- /dev/null +++ b/src/runloop_api_client/sdk/__init__.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from ._sync import RunloopSDK +from ._async import AsyncRunloopSDK +from .devbox import Devbox, DevboxClient +from .snapshot import Snapshot, SnapshotClient +from .blueprint import Blueprint, BlueprintClient +from .execution import Execution +from .async_devbox import AsyncDevbox, AsyncDevboxClient +from .async_snapshot import AsyncSnapshot, AsyncSnapshotClient +from .storage_object import StorageObject, StorageObjectClient +from .async_blueprint import AsyncBlueprint, AsyncBlueprintClient +from .async_execution import AsyncExecution +from .execution_result import ExecutionResult +from .async_storage_object import AsyncStorageObject, AsyncStorageObjectClient +from .async_execution_result import AsyncExecutionResult + +__all__ = [ + "RunloopSDK", + "AsyncRunloopSDK", + "Devbox", + "DevboxClient", + "Execution", + "ExecutionResult", + "Blueprint", + "BlueprintClient", + "Snapshot", + "SnapshotClient", + "StorageObject", + "StorageObjectClient", + "AsyncDevbox", + "AsyncDevboxClient", + "AsyncExecution", + "AsyncExecutionResult", + "AsyncBlueprint", + "AsyncBlueprintClient", + "AsyncSnapshot", + "AsyncSnapshotClient", + "AsyncStorageObject", + "AsyncStorageObjectClient", +] diff --git a/src/runloop_api_client/sdk/_async.py b/src/runloop_api_client/sdk/_async.py new file mode 100644 index 000000000..ace3798e2 --- /dev/null +++ b/src/runloop_api_client/sdk/_async.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import Mapping + +import httpx + +from .._types import Timeout, NotGiven, not_given +from .._client import AsyncRunloop +from .async_devbox import AsyncDevboxClient +from .async_snapshot import AsyncSnapshotClient +from .async_blueprint import AsyncBlueprintClient +from .async_storage_object import AsyncStorageObjectClient + + +class AsyncRunloopSDK: + """ + High-level asynchronous entry point for the Runloop SDK. + + The generated async REST client remains available via the ``api`` attribute. + Higher-level helpers will be introduced incrementally. + """ + + api: AsyncRunloop + devbox: AsyncDevboxClient + blueprint: AsyncBlueprintClient + snapshot: AsyncSnapshotClient + storage_object: AsyncStorageObjectClient + + def __init__( + self, + *, + client: AsyncRunloop | None = None, + bearer_token: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + max_retries: int | None = None, + default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + http_client: httpx.AsyncClient | None = None, + _strict_response_validation: bool = False, + ) -> None: + """ + Create an asynchronous Runloop SDK instance. + + Arguments mirror :class:`runloop_api_client.AsyncRunloop`. + """ + if client is None: + runloop_kwargs: dict[str, object] = { + "bearer_token": bearer_token, + "base_url": base_url, + "timeout": timeout, + "default_headers": default_headers, + "default_query": default_query, + "http_client": http_client, + "_strict_response_validation": _strict_response_validation, + } + if max_retries is not None: + runloop_kwargs["max_retries"] = max_retries + + self.api = AsyncRunloop(**runloop_kwargs) + self._owns_client = True + else: + self.api = client + self._owns_client = False + + self.devbox = AsyncDevboxClient(self.api) + self.blueprint = AsyncBlueprintClient(self.api, self.devbox) + self.snapshot = AsyncSnapshotClient(self.api, self.devbox) + self.storage_object = AsyncStorageObjectClient(self.api) + + async def aclose(self) -> None: + """Close the underlying async HTTP client.""" + if self._owns_client: + await self.api.close() + + async def __aenter__(self) -> "AsyncRunloopSDK": + return self + + async def __aexit__(self, *_exc_info: object) -> None: + await self.aclose() diff --git a/src/runloop_api_client/sdk/_helpers.py b/src/runloop_api_client/sdk/_helpers.py new file mode 100644 index 000000000..ee99dbf0d --- /dev/null +++ b/src/runloop_api_client/sdk/_helpers.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import io +import os +from typing import Union +from pathlib import Path + +from .._types import FileTypes +from .._utils import file_from_path + +UploadInput = Union[FileTypes, str, os.PathLike[str], Path, bytes, bytearray, io.IOBase] + + +def normalize_upload_input(file: UploadInput) -> FileTypes: + """ + Normalize a variety of Python file representations into the generated client's FileTypes. + """ + if isinstance(file, tuple): + return file + if isinstance(file, bytes): + return file + if isinstance(file, bytearray): + return bytes(file) + if isinstance(file, (str, Path, os.PathLike)): + return file_from_path(file) + if isinstance(file, io.TextIOBase): + return file.read().encode("utf-8") + if isinstance(file, io.BufferedIOBase) or isinstance(file, io.RawIOBase): + return file + if isinstance(file, io.IOBase) and hasattr(file, "read"): + data = file.read() + if isinstance(data, str): + return data.encode("utf-8") + return data + raise TypeError("Unsupported file type for upload. Provide path, bytes, or file-like object.") diff --git a/src/runloop_api_client/sdk/_sync.py b/src/runloop_api_client/sdk/_sync.py new file mode 100644 index 000000000..43ca73a7a --- /dev/null +++ b/src/runloop_api_client/sdk/_sync.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import Mapping + +import httpx + +from .devbox import DevboxClient +from .._types import Timeout, NotGiven, not_given +from .._client import Runloop +from .snapshot import SnapshotClient +from .blueprint import BlueprintClient +from .storage_object import StorageObjectClient + + +class RunloopSDK: + """ + High-level synchronous entry point for the Runloop SDK. + + This thin wrapper exposes the generated REST client via the ``api`` attribute. + Higher-level object-oriented helpers will be layered on top incrementally. + """ + + api: Runloop + devbox: DevboxClient + blueprint: BlueprintClient + snapshot: SnapshotClient + storage_object: StorageObjectClient + + def __init__( + self, + *, + client: Runloop | None = None, + bearer_token: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + max_retries: int | None = None, + default_headers: Mapping[str, str] | None = None, + default_query: Mapping[str, object] | None = None, + http_client: httpx.Client | None = None, + _strict_response_validation: bool = False, + ) -> None: + """ + Create a synchronous Runloop SDK instance. + + Arguments mirror :class:`runloop_api_client.Runloop`. Additional high-level helpers + are exposed as attributes on this class as they're implemented. + """ + if client is None: + runloop_kwargs: dict[str, object] = { + "bearer_token": bearer_token, + "base_url": base_url, + "timeout": timeout, + "max_retries": max_retries, + "default_headers": default_headers, + "default_query": default_query, + "http_client": http_client, + "_strict_response_validation": _strict_response_validation, + } + + self.api = Runloop(**runloop_kwargs) + self._owns_client = True + else: + self.api = client + self._owns_client = False + + self.devbox = DevboxClient(self.api) + self.blueprint = BlueprintClient(self.api, self.devbox) + self.snapshot = SnapshotClient(self.api, self.devbox) + self.storage_object = StorageObjectClient(self.api) + + def close(self) -> None: + """Close the underlying HTTP client.""" + if self._owns_client: + self.api.close() + + def __enter__(self) -> "RunloopSDK": + return self + + def __exit__(self, *_exc_info: object) -> None: + self.close() diff --git a/src/runloop_api_client/sdk/async_blueprint.py b/src/runloop_api_client/sdk/async_blueprint.py new file mode 100644 index 000000000..abee97e7d --- /dev/null +++ b/src/runloop_api_client/sdk/async_blueprint.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import Any, List + +from .._client import AsyncRunloop +from ..lib.polling import PollingConfig +from .async_devbox import AsyncDevbox, AsyncDevboxClient +from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView + + +class AsyncBlueprintClient: + """ + Manage :class:`AsyncBlueprint` objects through the async SDK. + """ + + def __init__(self, client: AsyncRunloop, devbox_client: AsyncDevboxClient) -> None: + self._client = client + self._devbox_client = devbox_client + + async def create(self, *, polling_config: PollingConfig | None = None, **params: Any) -> "AsyncBlueprint": + params = dict(params) + if polling_config is None: + polling_config = params.pop("polling_config", None) + + blueprint = await self._client.blueprints.create_and_await_build_complete( + polling_config=polling_config, + **params, + ) + return AsyncBlueprint(self._client, blueprint.id, self._devbox_client) + + def from_id(self, blueprint_id: str) -> "AsyncBlueprint": + return AsyncBlueprint(self._client, blueprint_id, self._devbox_client) + + async def list(self, **params: Any) -> List["AsyncBlueprint"]: + page = await self._client.blueprints.list(**params) + return [AsyncBlueprint(self._client, item.id, self._devbox_client) for item in getattr(page, "blueprints", [])] + + +class AsyncBlueprint: + """ + Async wrapper around blueprint operations. + """ + + def __init__(self, client: AsyncRunloop, blueprint_id: str, devbox_client: AsyncDevboxClient) -> None: + self._client = client + self._id = blueprint_id + self._devbox_client = devbox_client + + def __repr__(self) -> str: + return f"" + + @property + def id(self) -> str: + return self._id + + async def get_info(self, **request_options: Any) -> Any: + return await self._client.blueprints.retrieve(self._id, **request_options) + + async def logs(self, **request_options: Any) -> BlueprintBuildLogsListView: + return await self._client.blueprints.logs(self._id, **request_options) + + async def delete(self, **request_options: Any) -> Any: + return await self._client.blueprints.delete(self._id, **request_options) + + async def create_devbox(self, *, polling_config: PollingConfig | None = None, **params: Any) -> AsyncDevbox: + params = dict(params) + params["blueprint_id"] = self._id + return await self._devbox_client.create(polling_config=polling_config, **params) diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py new file mode 100644 index 000000000..4dc42764b --- /dev/null +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import asyncio +import inspect +import logging +from typing import Any, Union, Callable, Optional, Sequence, Awaitable + +from .._types import not_given +from .._client import AsyncRunloop +from ._helpers import UploadInput, normalize_upload_input +from .._streaming import AsyncStream +from ..lib.polling import PollingConfig +from .async_execution import AsyncExecution, _AsyncStreamingGroup +from .async_execution_result import AsyncExecutionResult +from ..types.devboxes.execution_update_chunk import ExecutionUpdateChunk + +AsyncCallback = Callable[[str], Union[Awaitable[None], None]] + + +class AsyncDevboxClient: + """ + High-level manager for creating and retrieving :class:`AsyncDevbox` instances. + """ + + def __init__(self, client: AsyncRunloop) -> None: + self._client = client + + async def create(self, *, polling_config: PollingConfig | None = None, **params: Any) -> "AsyncDevbox": + params = dict(params) + if polling_config is None: + polling_config = params.pop("polling_config", None) + + devbox_view = await self._client.devboxes.create_and_await_running( + polling_config=polling_config, + **params, + ) + return AsyncDevbox(self._client, devbox_view.id) + + async def create_from_blueprint_id( + self, + blueprint_id: str, + *, + polling_config: PollingConfig | None = None, + **params: Any, + ) -> "AsyncDevbox": + params = dict(params) + params["blueprint_id"] = blueprint_id + return await self.create(polling_config=polling_config, **params) + + async def create_from_blueprint_name( + self, + blueprint_name: str, + *, + polling_config: PollingConfig | None = None, + **params: Any, + ) -> "AsyncDevbox": + params = dict(params) + params["blueprint_name"] = blueprint_name + return await self.create(polling_config=polling_config, **params) + + async def create_from_snapshot( + self, + snapshot_id: str, + *, + polling_config: PollingConfig | None = None, + **params: Any, + ) -> "AsyncDevbox": + params = dict(params) + params["snapshot_id"] = snapshot_id + return await self.create(polling_config=polling_config, **params) + + def from_id(self, devbox_id: str) -> "AsyncDevbox": + return AsyncDevbox(self._client, devbox_id) + + async def list(self, **params: Any) -> list["AsyncDevbox"]: + page = await self._client.devboxes.list(**params) + return [AsyncDevbox(self._client, item.id) for item in getattr(page, "devboxes", [])] + + +class AsyncDevbox: + """ + Async object-oriented wrapper around devbox operations. + """ + + def __init__(self, client: AsyncRunloop, devbox_id: str) -> None: + self._client = client + self._id = devbox_id + self._logger = logging.getLogger(__name__) + + def __repr__(self) -> str: + return f"" + + async def __aenter__(self) -> "AsyncDevbox": + return self + + async def __aexit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any) -> None: + try: + await self.shutdown() + except Exception: + self._logger.exception("failed to shutdown async devbox %s on context exit", self._id) + + @property + def id(self) -> str: + return self._id + + async def get_info(self, **request_options: Any) -> Any: + return await self._client.devboxes.retrieve(self._id, **request_options) + + async def await_running(self, *, polling_config: PollingConfig | None = None) -> Any: + return await self._client.devboxes.await_running(self._id, polling_config=polling_config) + + async def await_suspended(self, *, polling_config: PollingConfig | None = None) -> Any: + return await self._client.devboxes.await_suspended(self._id, polling_config=polling_config) + + async def shutdown(self, **request_options: Any) -> Any: + return await self._client.devboxes.shutdown(self._id, **request_options) + + async def suspend(self, **request_options: Any) -> Any: + return await self._client.devboxes.suspend(self._id, **request_options) + + async def resume(self, **request_options: Any) -> Any: + return await self._client.devboxes.resume(self._id, **request_options) + + async def keep_alive(self, **request_options: Any) -> Any: + return await self._client.devboxes.keep_alive(self._id, **request_options) + + async def close(self) -> None: + await self.shutdown() + + @property + def cmd(self) -> "_AsyncCommandInterface": + return _AsyncCommandInterface(self) + + @property + def file(self) -> "_AsyncFileInterface": + return _AsyncFileInterface(self) + + @property + def net(self) -> "_AsyncNetworkInterface": + return _AsyncNetworkInterface(self) + + # ------------------------------------------------------------------ # + # Internal helpers + # ------------------------------------------------------------------ # + + def _start_streaming( + self, + execution_id: str, + *, + stdout: AsyncCallback | None, + stderr: AsyncCallback | None, + output: AsyncCallback | None, + ) -> Optional[_AsyncStreamingGroup]: + tasks: list[asyncio.Task[None]] = [] + + if stdout or output: + callbacks = [cb for cb in (stdout, output) if cb is not None] + tasks.append( + asyncio.create_task( + self._stream_worker( + name="stdout", + stream_factory=lambda: self._client.devboxes.executions.stream_stdout_updates( + execution_id, + devbox_id=self._id, + ), + callbacks=callbacks, + ) + ) + ) + + if stderr or output: + callbacks = [cb for cb in (stderr, output) if cb is not None] + tasks.append( + asyncio.create_task( + self._stream_worker( + name="stderr", + stream_factory=lambda: self._client.devboxes.executions.stream_stderr_updates( + execution_id, + devbox_id=self._id, + ), + callbacks=callbacks, + ) + ) + ) + + if not tasks: + return None + + return _AsyncStreamingGroup(tasks) + + async def _stream_worker( + self, + *, + name: str, + stream_factory: Callable[[], AsyncStream[ExecutionUpdateChunk]], + callbacks: Sequence[AsyncCallback], + ) -> None: + logger = self._logger + try: + async with stream_factory() as stream: + async for chunk in stream: + text = getattr(chunk, "output", "") + for callback in callbacks: + try: + result = callback(text) + if inspect.isawaitable(result): + await result # type: ignore[arg-type] + except Exception: + logger.exception("error in async %s callback for devbox %s", name, self._id) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("error streaming %s logs for devbox %s", name, self._id) + + +class _AsyncCommandInterface: + def __init__(self, devbox: AsyncDevbox) -> None: + self._devbox = devbox + + async def exec( + self, + command: str, + *, + shell_name: str | None = None, + stdout: AsyncCallback | None = None, + stderr: AsyncCallback | None = None, + output: AsyncCallback | None = None, + polling_config: PollingConfig | None = None, + **request_options: Any, + ) -> AsyncExecutionResult: + devbox = self._devbox + client = devbox._client + request_options = dict(request_options) + if "shell_name" in request_options: + shell_name = request_options.pop("shell_name") + + if stdout or stderr or output: + execution = await client.devboxes.execute_async( + devbox.id, + command=command, + shell_name=shell_name, + **request_options, + ) + streaming_group = devbox._start_streaming( + execution.execution_id, + stdout=stdout, + stderr=stderr, + output=output, + ) + try: + if execution.status == "completed": + final = execution + else: + final = await client.devboxes.executions.await_completed( + execution.execution_id, + devbox_id=devbox.id, + polling_config=polling_config, + ) + except Exception: + if streaming_group is not None: + await streaming_group.cancel() + raise + else: + if streaming_group is not None: + await streaming_group.wait() + + return AsyncExecutionResult(client, devbox.id, final) + + final = await client.devboxes.execute_and_await_completion( + devbox.id, + command=command, + shell_name=shell_name if shell_name is not None else not_given, + polling_config=polling_config, + **request_options, + ) + return AsyncExecutionResult(client, devbox.id, final) + + async def exec_async( + self, + command: str, + *, + shell_name: str | None = None, + stdout: AsyncCallback | None = None, + stderr: AsyncCallback | None = None, + output: AsyncCallback | None = None, + **request_options: Any, + ) -> AsyncExecution: + devbox = self._devbox + client = devbox._client + request_options = dict(request_options) + if "shell_name" in request_options: + shell_name = request_options.pop("shell_name") + + execution = await client.devboxes.execute_async( + devbox.id, + command=command, + shell_name=shell_name, + **request_options, + ) + + streaming_group = devbox._start_streaming( + execution.execution_id, + stdout=stdout, + stderr=stderr, + output=output, + ) + + return AsyncExecution(client, devbox.id, execution, streaming_group) + + +class _AsyncFileInterface: + def __init__(self, devbox: AsyncDevbox) -> None: + self._devbox = devbox + + async def read(self, path: str, **request_options: Any) -> str: + return await self._devbox._client.devboxes.read_file_contents( + self._devbox.id, file_path=path, **request_options + ) + + async def write(self, path: str, contents: str | bytes, **request_options: Any) -> Any: + if isinstance(contents, bytes): + contents_str = contents.decode("utf-8") + else: + contents_str = contents + + return await self._devbox._client.devboxes.write_file_contents( + self._devbox.id, + file_path=path, + contents=contents_str, + **request_options, + ) + + async def download(self, path: str, **request_options: Any) -> bytes: + response = await self._devbox._client.devboxes.download_file( + self._devbox.id, + path=path, + **request_options, + ) + return await response.read() + + async def upload(self, path: str, file: UploadInput, **request_options: Any) -> Any: + file_param = normalize_upload_input(file) + return await self._devbox._client.devboxes.upload_file( + self._devbox.id, + path=path, + file=file_param, + **request_options, + ) + + +class _AsyncNetworkInterface: + def __init__(self, devbox: AsyncDevbox) -> None: + self._devbox = devbox + + async def create_ssh_key(self, **request_options: Any) -> Any: + return await self._devbox._client.devboxes.create_ssh_key(self._devbox.id, **request_options) + + async def create_tunnel(self, *, port: int, **request_options: Any) -> Any: + return await self._devbox._client.devboxes.create_tunnel(self._devbox.id, port=port, **request_options) + + async def remove_tunnel(self, *, port: int, **request_options: Any) -> Any: + return await self._devbox._client.devboxes.remove_tunnel(self._devbox.id, port=port, **request_options) diff --git a/src/runloop_api_client/sdk/async_execution.py b/src/runloop_api_client/sdk/async_execution.py new file mode 100644 index 000000000..716be3918 --- /dev/null +++ b/src/runloop_api_client/sdk/async_execution.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Optional + +from .._client import AsyncRunloop +from ..lib.polling import PollingConfig +from .async_execution_result import AsyncExecutionResult +from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView + + +class _AsyncStreamingGroup: + """ + Internal helper to manage background streaming tasks. + """ + + def __init__(self, tasks: list[asyncio.Task[None]]) -> None: + self._tasks = tasks + self._logger = logging.getLogger(__name__) + + async def wait(self) -> None: + results = await asyncio.gather(*self._tasks, return_exceptions=True) + self._log_results(results) + + async def cancel(self) -> None: + for task in self._tasks: + task.cancel() + results = await asyncio.gather(*self._tasks, return_exceptions=True) + self._log_results(results) + + def _log_results(self, results: list[object]) -> None: + for result in results: + if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError): + self._logger.debug("stream task error: %s", result) + + +class AsyncExecution: + """ + Represents an asynchronous command execution on a devbox. + """ + + def __init__( + self, + client: AsyncRunloop, + devbox_id: str, + execution: DevboxAsyncExecutionDetailView, + streaming_group: Optional[_AsyncStreamingGroup] = None, + ) -> None: + self._client = client + self._devbox_id = devbox_id + self._execution_id = execution.execution_id + self._latest = execution + self._streaming_group = streaming_group + + @property + def execution_id(self) -> str: + return self._execution_id + + @property + def devbox_id(self) -> str: + return self._devbox_id + + async def result(self, *, polling_config: PollingConfig | None = None) -> AsyncExecutionResult: + if self._latest.status == "completed": + final = self._latest + else: + final = await self._client.devboxes.executions.await_completed( + self._execution_id, + devbox_id=self._devbox_id, + polling_config=polling_config, + ) + await self._settle_streaming(cancel=False) + + self._latest = final + return AsyncExecutionResult(self._client, self._devbox_id, final) + + async def get_state(self) -> DevboxAsyncExecutionDetailView: + self._latest = await self._client.devboxes.executions.retrieve( + self._execution_id, + devbox_id=self._devbox_id, + ) + return self._latest + + async def kill(self, *, kill_process_group: bool | None = None) -> None: + await self._client.devboxes.executions.kill( + self._execution_id, + devbox_id=self._devbox_id, + kill_process_group=kill_process_group, + ) + await self._settle_streaming(cancel=True) + + async def _settle_streaming(self, *, cancel: bool) -> None: + if self._streaming_group is None: + return + if cancel: + await self._streaming_group.cancel() + else: + await self._streaming_group.wait() + self._streaming_group = None diff --git a/src/runloop_api_client/sdk/async_execution_result.py b/src/runloop_api_client/sdk/async_execution_result.py new file mode 100644 index 000000000..f7b2e742f --- /dev/null +++ b/src/runloop_api_client/sdk/async_execution_result.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from .._client import AsyncRunloop +from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView + + +class AsyncExecutionResult: + """ + Completed asynchronous command execution result. + """ + + def __init__( + self, + client: AsyncRunloop, + devbox_id: str, + execution: DevboxAsyncExecutionDetailView, + ) -> None: + self._client = client + self._devbox_id = devbox_id + self._execution = execution + + @property + def devbox_id(self) -> str: + return self._devbox_id + + @property + def execution_id(self) -> str: + return self._execution.execution_id + + @property + def exit_code(self) -> int | None: + return self._execution.exit_status + + @property + def success(self) -> bool: + return self.exit_code == 0 + + @property + def failed(self) -> bool: + exit_code = self.exit_code + return exit_code is not None and exit_code != 0 + + async def stdout(self) -> str: + return self._execution.stdout or "" + + async def stderr(self) -> str: + return self._execution.stderr or "" + + @property + def raw(self) -> DevboxAsyncExecutionDetailView: + return self._execution diff --git a/src/runloop_api_client/sdk/async_snapshot.py b/src/runloop_api_client/sdk/async_snapshot.py new file mode 100644 index 000000000..ba02b1ab7 --- /dev/null +++ b/src/runloop_api_client/sdk/async_snapshot.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import Any, List + +from .._client import AsyncRunloop +from ..lib.polling import PollingConfig +from .async_devbox import AsyncDevbox, AsyncDevboxClient +from ..types.devbox_snapshot_view import DevboxSnapshotView +from ..types.devboxes.devbox_snapshot_async_status_view import DevboxSnapshotAsyncStatusView + + +class AsyncSnapshotClient: + """ + Manage :class:`AsyncSnapshot` instances. + """ + + def __init__(self, client: AsyncRunloop, devbox_client: AsyncDevboxClient) -> None: + self._client = client + self._devbox_client = devbox_client + + async def list(self, **params: Any) -> List["AsyncSnapshot"]: + page = await self._client.devboxes.disk_snapshots.list(**params) + return [ + AsyncSnapshot(self._client, item.id, self._devbox_client) for item in getattr(page, "disk_snapshots", []) + ] + + def from_id(self, snapshot_id: str) -> "AsyncSnapshot": + return AsyncSnapshot(self._client, snapshot_id, self._devbox_client) + + +class AsyncSnapshot: + """ + Async wrapper around snapshot operations. + """ + + def __init__(self, client: AsyncRunloop, snapshot_id: str, devbox_client: AsyncDevboxClient) -> None: + self._client = client + self._id = snapshot_id + self._devbox_client = devbox_client + + def __repr__(self) -> str: + return f"" + + @property + def id(self) -> str: + return self._id + + async def get_info(self, **request_options: Any) -> DevboxSnapshotAsyncStatusView: + return await self._client.devboxes.disk_snapshots.query_status(self._id, **request_options) + + async def update(self, **params: Any) -> DevboxSnapshotView: + return await self._client.devboxes.disk_snapshots.update(self._id, **params) + + async def delete(self, **request_options: Any) -> Any: + return await self._client.devboxes.disk_snapshots.delete(self._id, **request_options) + + async def await_completed( + self, + *, + polling_config: PollingConfig | None = None, + **request_options: Any, + ) -> DevboxSnapshotAsyncStatusView: + return await self._client.devboxes.disk_snapshots.await_completed( + self._id, + polling_config=polling_config, + **request_options, + ) + + async def create_devbox(self, *, polling_config: PollingConfig | None = None, **params: Any) -> AsyncDevbox: + params = dict(params) + params["snapshot_id"] = self._id + return await self._devbox_client.create(polling_config=polling_config, **params) diff --git a/src/runloop_api_client/sdk/async_storage_object.py b/src/runloop_api_client/sdk/async_storage_object.py new file mode 100644 index 000000000..1ac6b592d --- /dev/null +++ b/src/runloop_api_client/sdk/async_storage_object.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from pathlib import Path + +import httpx + +from .._client import AsyncRunloop +from .storage_object import UploadData, ContentType, _read_upload_data, _detect_content_type +from ..types.object_view import ObjectView +from ..types.object_download_url_view import ObjectDownloadURLView + + +class AsyncStorageObjectClient: + """ + Async manager for :class:`AsyncStorageObject` instances. + """ + + def __init__(self, client: AsyncRunloop) -> None: + self._client = client + + async def create( + self, + name: str, + *, + content_type: ContentType | None = None, + metadata: Optional[Dict[str, str]] = None, + ) -> "AsyncStorageObject": + content_type = content_type or _detect_content_type(name) + obj = await self._client.objects.create( + name=name, + content_type=content_type, + metadata=metadata, + ) + return AsyncStorageObject(self._client, obj.id, upload_url=obj.upload_url) + + def from_id(self, object_id: str) -> "AsyncStorageObject": + return AsyncStorageObject(self._client, object_id, upload_url=None) + + async def list(self, **params: Any) -> List["AsyncStorageObject"]: + page = await self._client.objects.list(**params) + return [AsyncStorageObject(self._client, item.id, upload_url=None) for item in getattr(page, "objects", [])] + + async def upload_from_file( + self, + path: str | Path, + name: str | None = None, + *, + metadata: Optional[Dict[str, str]] = None, + content_type: ContentType | None = None, + ) -> "AsyncStorageObject": + file_path = Path(path) + object_name = name or file_path.name + obj = await self.create(object_name, content_type=content_type, metadata=metadata) + await obj.upload_content(file_path) + await obj.complete() + return obj + + async def upload_from_text( + self, + text: str, + name: str, + *, + metadata: Optional[Dict[str, str]] = None, + ) -> "AsyncStorageObject": + obj = await self.create(name, content_type="text", metadata=metadata) + await obj.upload_content(text) + await obj.complete() + return obj + + async def upload_from_bytes( + self, + data: bytes, + name: str, + *, + metadata: Optional[Dict[str, str]] = None, + content_type: ContentType | None = None, + ) -> "AsyncStorageObject": + obj = await self.create(name, content_type=content_type or _detect_content_type(name), metadata=metadata) + await obj.upload_content(data) + await obj.complete() + return obj + + +class AsyncStorageObject: + """ + Async wrapper around storage object operations. + """ + + def __init__(self, client: AsyncRunloop, object_id: str, upload_url: str | None) -> None: + self._client = client + self._id = object_id + self._upload_url = upload_url + + def __repr__(self) -> str: + return f"" + + @property + def id(self) -> str: + return self._id + + @property + def upload_url(self) -> str | None: + return self._upload_url + + async def refresh(self, **request_options: Any) -> ObjectView: + return await self._client.objects.retrieve(self._id, **request_options) + + async def complete(self, **request_options: Any) -> ObjectView: + result = await self._client.objects.complete(self._id, **request_options) + self._upload_url = None + return result + + async def get_download_url( + self, + *, + duration_seconds: int | None = None, + **request_options: Any, + ) -> ObjectDownloadURLView: + if duration_seconds is None: + return await self._client.objects.download(self._id, **request_options) + return await self._client.objects.download(self._id, duration_seconds=duration_seconds, **request_options) + + async def download_as_bytes( + self, + *, + duration_seconds: int | None = None, + **request_options: Any, + ) -> bytes: + url_view = await self.get_download_url(duration_seconds=duration_seconds, **request_options) + async with httpx.AsyncClient() as client: + response = await client.get(url_view.download_url) + response.raise_for_status() + return response.content + + async def download_as_text( + self, + *, + duration_seconds: int | None = None, + encoding: str = "utf-8", + **request_options: Any, + ) -> str: + url_view = await self.get_download_url(duration_seconds=duration_seconds, **request_options) + async with httpx.AsyncClient() as client: + response = await client.get(url_view.download_url) + response.raise_for_status() + response.encoding = encoding + return response.text + + async def delete(self, **request_options: Any) -> Any: + return await self._client.objects.delete(self._id, **request_options) + + async def upload_content(self, data: UploadData) -> None: + url = self._ensure_upload_url() + payload = _read_upload_data(data) + async with httpx.AsyncClient() as client: + response = await client.put(url, content=payload) + response.raise_for_status() + + def _ensure_upload_url(self) -> str: + if not self._upload_url: + raise RuntimeError("No upload URL available. Create a new object before uploading content.") + return self._upload_url diff --git a/src/runloop_api_client/sdk/blueprint.py b/src/runloop_api_client/sdk/blueprint.py new file mode 100644 index 000000000..a5c1d1da1 --- /dev/null +++ b/src/runloop_api_client/sdk/blueprint.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import Any, List + +from .devbox import Devbox, DevboxClient +from .._client import Runloop +from ..lib.polling import PollingConfig +from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView + + +class BlueprintClient: + """ + Manage :class:`Blueprint` objects through the object-oriented SDK. + """ + + def __init__(self, client: Runloop, devbox_client: DevboxClient) -> None: + self._client = client + self._devbox_client = devbox_client + + def create(self, *, polling_config: PollingConfig | None = None, **params: Any) -> "Blueprint": + """ + Create a blueprint and wait for the build to complete. + """ + params = dict(params) + if polling_config is None: + polling_config = params.pop("polling_config", None) + + blueprint = self._client.blueprints.create_and_await_build_complete( + polling_config=polling_config, + **params, + ) + return Blueprint(self._client, blueprint.id, self._devbox_client) + + def from_id(self, blueprint_id: str) -> "Blueprint": + """ + Return a :class:`Blueprint` wrapper for an existing blueprint ID. + """ + return Blueprint(self._client, blueprint_id, self._devbox_client) + + def list(self, **params: Any) -> List["Blueprint"]: + """ + List blueprints and return lightweight wrappers. + """ + page = self._client.blueprints.list(**params) + return [Blueprint(self._client, item.id, self._devbox_client) for item in getattr(page, "blueprints", [])] + + +class Blueprint: + """ + High-level wrapper around a blueprint resource. + """ + + def __init__(self, client: Runloop, blueprint_id: str, devbox_client: DevboxClient) -> None: + self._client = client + self._id = blueprint_id + self._devbox_client = devbox_client + + def __repr__(self) -> str: + return f"" + + @property + def id(self) -> str: + return self._id + + def get_info(self, **request_options: Any) -> Any: + return self._client.blueprints.retrieve(self._id, **request_options) + + def logs(self, **request_options: Any) -> BlueprintBuildLogsListView: + return self._client.blueprints.logs(self._id, **request_options) + + def delete(self, **request_options: Any) -> Any: + return self._client.blueprints.delete(self._id, **request_options) + + def create_devbox(self, *, polling_config: PollingConfig | None = None, **params: Any) -> Devbox: + params = dict(params) + params["blueprint_id"] = self._id + return self._devbox_client.create(polling_config=polling_config, **params) diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py new file mode 100644 index 000000000..327333f38 --- /dev/null +++ b/src/runloop_api_client/sdk/devbox.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import logging +import threading +from typing import Any, Callable, Optional, Sequence + +from .._types import not_given +from .._client import Runloop +from ._helpers import UploadInput, normalize_upload_input +from .execution import Execution, _StreamingGroup +from .._streaming import Stream +from ..lib.polling import PollingConfig +from .execution_result import ExecutionResult +from ..types.devboxes.execution_update_chunk import ExecutionUpdateChunk + +LogCallback = Callable[[str], None] + + +class DevboxClient: + """ + High-level manager for creating and retrieving :class:`Devbox` instances. + """ + + def __init__(self, client: Runloop) -> None: + self._client = client + + def create(self, *, polling_config: PollingConfig | None = None, **params: Any) -> "Devbox": + """ + Create a new devbox and block until it is running. + """ + params = dict(params) + if polling_config is None: + polling_config = params.pop("polling_config", None) + + devbox_view = self._client.devboxes.create_and_await_running( + polling_config=polling_config, + **params, + ) + return Devbox(self._client, devbox_view.id) + + def create_from_blueprint_id( + self, + blueprint_id: str, + *, + polling_config: PollingConfig | None = None, + **params: Any, + ) -> "Devbox": + params = dict(params) + params["blueprint_id"] = blueprint_id + return self.create(polling_config=polling_config, **params) + + def create_from_blueprint_name( + self, + blueprint_name: str, + *, + polling_config: PollingConfig | None = None, + **params: Any, + ) -> "Devbox": + params = dict(params) + params["blueprint_name"] = blueprint_name + return self.create(polling_config=polling_config, **params) + + def create_from_snapshot( + self, + snapshot_id: str, + *, + polling_config: PollingConfig | None = None, + **params: Any, + ) -> "Devbox": + params = dict(params) + params["snapshot_id"] = snapshot_id + return self.create(polling_config=polling_config, **params) + + def from_id(self, devbox_id: str) -> "Devbox": + """ + Create a :class:`Devbox` wrapper for an existing devbox ID. + """ + return Devbox(self._client, devbox_id) + + def list(self, **params: Any) -> list["Devbox"]: + """ + List devboxes and return lightweight :class:`Devbox` wrappers. + """ + page = self._client.devboxes.list(**params) + return [Devbox(self._client, item.id) for item in getattr(page, "devboxes", [])] + + +class Devbox: + """ + Object-oriented wrapper around devbox operations. + """ + + def __init__(self, client: Runloop, devbox_id: str) -> None: + self._client = client + self._id = devbox_id + self._logger = logging.getLogger(__name__) + + def __repr__(self) -> str: + return f"" + + def __enter__(self) -> "Devbox": + return self + + def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any) -> None: + try: + self.shutdown() + except Exception: + self._logger.exception("failed to shutdown devbox %s on context exit", self._id) + + @property + def id(self) -> str: + return self._id + + def get_info(self, **request_options: Any) -> Any: + return self._client.devboxes.retrieve(self._id, **request_options) + + def await_running(self, *, polling_config: PollingConfig | None = None) -> Any: + return self._client.devboxes.await_running(self._id, polling_config=polling_config) + + def await_suspended(self, *, polling_config: PollingConfig | None = None) -> Any: + return self._client.devboxes.await_suspended(self._id, polling_config=polling_config) + + def shutdown(self, **request_options: Any) -> Any: + return self._client.devboxes.shutdown(self._id, **request_options) + + def suspend(self, **request_options: Any) -> Any: + return self._client.devboxes.suspend(self._id, **request_options) + + def resume(self, **request_options: Any) -> Any: + return self._client.devboxes.resume(self._id, **request_options) + + def keep_alive(self, **request_options: Any) -> Any: + return self._client.devboxes.keep_alive(self._id, **request_options) + + def close(self) -> None: + self.shutdown() + + @property + def cmd(self) -> "_CommandInterface": + return _CommandInterface(self) + + @property + def file(self) -> "_FileInterface": + return _FileInterface(self) + + @property + def net(self) -> "_NetworkInterface": + return _NetworkInterface(self) + + # --------------------------------------------------------------------- # + # Internal helpers + # --------------------------------------------------------------------- # + + def _start_streaming( + self, + execution_id: str, + *, + stdout: LogCallback | None, + stderr: LogCallback | None, + output: LogCallback | None, + ) -> Optional[_StreamingGroup]: + threads: list[threading.Thread] = [] + stop_event = threading.Event() + + if stdout or output: + callbacks = [cb for cb in (stdout, output) if cb is not None] + threads.append( + self._spawn_stream_thread( + name="stdout", + stream_factory=lambda: self._client.devboxes.executions.stream_stdout_updates( + execution_id, + devbox_id=self._id, + ), + callbacks=callbacks, + stop_event=stop_event, + ) + ) + + if stderr or output: + callbacks = [cb for cb in (stderr, output) if cb is not None] + threads.append( + self._spawn_stream_thread( + name="stderr", + stream_factory=lambda: self._client.devboxes.executions.stream_stderr_updates( + execution_id, + devbox_id=self._id, + ), + callbacks=callbacks, + stop_event=stop_event, + ) + ) + + if not threads: + return None + + return _StreamingGroup(threads, stop_event) + + def _spawn_stream_thread( + self, + *, + name: str, + stream_factory: Callable[[], Stream[ExecutionUpdateChunk]], + callbacks: Sequence[LogCallback], + stop_event: threading.Event, + ) -> threading.Thread: + logger = self._logger + + def worker() -> None: + try: + with stream_factory() as stream: + for chunk in stream: + if stop_event.is_set(): + break + text = getattr(chunk, "output", "") + for callback in callbacks: + try: + callback(text) + except Exception: + logger.exception("error in %s callback for devbox %s", name, self._id) + except Exception: + logger.exception("error streaming %s logs for devbox %s", name, self._id) + + thread = threading.Thread( + target=worker, + name=f"runloop-devbox-{self._id}-{name}", + daemon=True, + ) + thread.start() + return thread + + +class _CommandInterface: + def __init__(self, devbox: Devbox) -> None: + self._devbox = devbox + + def exec( + self, + command: str, + *, + shell_name: str | None = None, + stdout: LogCallback | None = None, + stderr: LogCallback | None = None, + output: LogCallback | None = None, + polling_config: PollingConfig | None = None, + **request_options: Any, + ) -> ExecutionResult: + devbox = self._devbox + client = devbox._client + request_options = dict(request_options) + if "shell_name" in request_options: + shell_name = request_options.pop("shell_name") + + if stdout or stderr or output: + execution = client.devboxes.execute_async( + devbox.id, + command=command, + shell_name=shell_name, + **request_options, + ) + streaming_group = devbox._start_streaming( + execution.execution_id, + stdout=stdout, + stderr=stderr, + output=output, + ) + try: + if execution.status == "completed": + final = execution + else: + final = client.devboxes.executions.await_completed( + execution.execution_id, + devbox_id=devbox.id, + polling_config=polling_config, + ) + finally: + if streaming_group is not None: + streaming_group.stop() + streaming_group.join() + + return ExecutionResult(client, devbox.id, final) + + final = client.devboxes.execute_and_await_completion( + devbox.id, + command=command, + shell_name=shell_name if shell_name is not None else not_given, + polling_config=polling_config, + **request_options, + ) + return ExecutionResult(client, devbox.id, final) + + def exec_async( + self, + command: str, + *, + shell_name: str | None = None, + stdout: LogCallback | None = None, + stderr: LogCallback | None = None, + output: LogCallback | None = None, + **request_options: Any, + ) -> Execution: + devbox = self._devbox + client = devbox._client + request_options = dict(request_options) + if "shell_name" in request_options: + shell_name = request_options.pop("shell_name") + + execution = client.devboxes.execute_async( + devbox.id, + command=command, + shell_name=shell_name, + **request_options, + ) + + streaming_group = devbox._start_streaming( + execution.execution_id, + stdout=stdout, + stderr=stderr, + output=output, + ) + + return Execution(client, devbox.id, execution, streaming_group) + + +class _FileInterface: + def __init__(self, devbox: Devbox) -> None: + self._devbox = devbox + + def read(self, path: str, **request_options: Any) -> str: + return self._devbox._client.devboxes.read_file_contents(self._devbox.id, file_path=path, **request_options) + + def write(self, path: str, contents: str | bytes, **request_options: Any) -> Any: + if isinstance(contents, bytes): + contents_str = contents.decode("utf-8") + else: + contents_str = contents + + return self._devbox._client.devboxes.write_file_contents( + self._devbox.id, + file_path=path, + contents=contents_str, + **request_options, + ) + + def download(self, path: str, **request_options: Any) -> bytes: + response = self._devbox._client.devboxes.download_file( + self._devbox.id, + path=path, + **request_options, + ) + return response.read() + + def upload(self, path: str, file: UploadInput, **request_options: Any) -> Any: + file_param = normalize_upload_input(file) + return self._devbox._client.devboxes.upload_file( + self._devbox.id, + path=path, + file=file_param, + **request_options, + ) + + +class _NetworkInterface: + def __init__(self, devbox: Devbox) -> None: + self._devbox = devbox + + def create_ssh_key(self, **request_options: Any) -> Any: + return self._devbox._client.devboxes.create_ssh_key(self._devbox.id, **request_options) + + def create_tunnel(self, *, port: int, **request_options: Any) -> Any: + return self._devbox._client.devboxes.create_tunnel(self._devbox.id, port=port, **request_options) + + def remove_tunnel(self, *, port: int, **request_options: Any) -> Any: + return self._devbox._client.devboxes.remove_tunnel(self._devbox.id, port=port, **request_options) diff --git a/src/runloop_api_client/sdk/execution.py b/src/runloop_api_client/sdk/execution.py new file mode 100644 index 000000000..adecc0a71 --- /dev/null +++ b/src/runloop_api_client/sdk/execution.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import logging +import threading +from typing import Optional + +from .._client import Runloop +from ..lib.polling import PollingConfig +from .execution_result import ExecutionResult +from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView + + +class _StreamingGroup: + """ + Internal helper used to coordinate stdout/stderr streaming threads. + """ + + def __init__(self, threads: list[threading.Thread], stop_event: threading.Event) -> None: + self._threads = threads + self._stop_event = stop_event + self._logger = logging.getLogger(__name__) + + def stop(self) -> None: + self._stop_event.set() + + def join(self, timeout: float = 5.0) -> None: + for thread in self._threads: + thread.join(timeout) + if thread.is_alive(): + self._logger.debug("streaming thread %s still running after join timeout", thread.name) + + @property + def active(self) -> bool: + return any(thread.is_alive() for thread in self._threads) + + +class Execution: + """ + Represents an asynchronous command execution on a devbox. + """ + + def __init__( + self, + client: Runloop, + devbox_id: str, + execution: DevboxAsyncExecutionDetailView, + streaming_group: Optional[_StreamingGroup] = None, + ) -> None: + self._client = client + self._devbox_id = devbox_id + self._execution_id = execution.execution_id + self._latest = execution + self._streaming_group = streaming_group + + @property + def execution_id(self) -> str: + return self._execution_id + + @property + def devbox_id(self) -> str: + return self._devbox_id + + def result(self, *, polling_config: PollingConfig | None = None) -> ExecutionResult: + """ + Wait for completion and return an :class:`ExecutionResult`. + """ + try: + if self._latest.status == "completed": + final = self._latest + else: + final = self._client.devboxes.executions.await_completed( + self._execution_id, + devbox_id=self._devbox_id, + polling_config=polling_config, + ) + finally: + self._stop_streaming() + + self._latest = final + return ExecutionResult(self._client, self._devbox_id, final) + + def get_state(self) -> DevboxAsyncExecutionDetailView: + """ + Fetch the latest execution state. + """ + self._latest = self._client.devboxes.executions.retrieve( + self._execution_id, + devbox_id=self._devbox_id, + ) + return self._latest + + def kill(self, *, kill_process_group: bool | None = None) -> None: + """ + Request termination of the running execution. + """ + self._client.devboxes.executions.kill( + self._execution_id, + devbox_id=self._devbox_id, + kill_process_group=kill_process_group, + ) + self._stop_streaming() + + def _stop_streaming(self) -> None: + if self._streaming_group is None: + return + self._streaming_group.stop() + self._streaming_group.join() + self._streaming_group = None diff --git a/src/runloop_api_client/sdk/execution_result.py b/src/runloop_api_client/sdk/execution_result.py new file mode 100644 index 000000000..8b18686a4 --- /dev/null +++ b/src/runloop_api_client/sdk/execution_result.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from .._client import Runloop +from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView + + +class ExecutionResult: + """ + Completed command execution result. + + Provides convenient helpers to inspect process exit status and captured output. + """ + + def __init__( + self, + client: Runloop, + devbox_id: str, + execution: DevboxAsyncExecutionDetailView, + ) -> None: + self._client = client + self._devbox_id = devbox_id + self._execution = execution + + @property + def devbox_id(self) -> str: + """Associated devbox identifier.""" + return self._devbox_id + + @property + def execution_id(self) -> str: + """Underlying execution identifier.""" + return self._execution.execution_id + + @property + def exit_code(self) -> int | None: + """Process exit code, or ``None`` if unavailable.""" + return self._execution.exit_status + + @property + def success(self) -> bool: + """Whether the process exited successfully (exit code ``0``).""" + return self.exit_code == 0 + + @property + def failed(self) -> bool: + """Whether the process exited with a non-zero exit code.""" + exit_code = self.exit_code + return exit_code is not None and exit_code != 0 + + def stdout(self) -> str: + """Return captured standard output.""" + return self._execution.stdout or "" + + def stderr(self) -> str: + """Return captured standard error.""" + return self._execution.stderr or "" + + @property + def raw(self) -> DevboxAsyncExecutionDetailView: + """Access the underlying API response.""" + return self._execution diff --git a/src/runloop_api_client/sdk/snapshot.py b/src/runloop_api_client/sdk/snapshot.py new file mode 100644 index 000000000..26b6de1c6 --- /dev/null +++ b/src/runloop_api_client/sdk/snapshot.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Any, List + +from .devbox import Devbox, DevboxClient +from .._client import Runloop +from ..lib.polling import PollingConfig +from ..types.devbox_snapshot_view import DevboxSnapshotView +from ..types.devboxes.devbox_snapshot_async_status_view import DevboxSnapshotAsyncStatusView + + +class SnapshotClient: + """ + Manage :class:`Snapshot` objects through the SDK. + """ + + def __init__(self, client: Runloop, devbox_client: DevboxClient) -> None: + self._client = client + self._devbox_client = devbox_client + + def list(self, **params: Any) -> List["Snapshot"]: + page = self._client.devboxes.disk_snapshots.list(**params) + return [Snapshot(self._client, item.id, self._devbox_client) for item in getattr(page, "disk_snapshots", [])] + + def from_id(self, snapshot_id: str) -> "Snapshot": + return Snapshot(self._client, snapshot_id, self._devbox_client) + + +class Snapshot: + """ + Wrapper around snapshot operations. + """ + + def __init__(self, client: Runloop, snapshot_id: str, devbox_client: DevboxClient) -> None: + self._client = client + self._id = snapshot_id + self._devbox_client = devbox_client + + def __repr__(self) -> str: + return f"" + + @property + def id(self) -> str: + return self._id + + def get_info(self, **request_options: Any) -> DevboxSnapshotAsyncStatusView: + return self._client.devboxes.disk_snapshots.query_status(self._id, **request_options) + + def update(self, **params: Any) -> DevboxSnapshotView: + return self._client.devboxes.disk_snapshots.update(self._id, **params) + + def delete(self, **request_options: Any) -> Any: + return self._client.devboxes.disk_snapshots.delete(self._id, **request_options) + + def await_completed( + self, *, polling_config: PollingConfig | None = None, **request_options: Any + ) -> DevboxSnapshotAsyncStatusView: + return self._client.devboxes.disk_snapshots.await_completed( + self._id, + polling_config=polling_config, + **request_options, + ) + + def create_devbox(self, *, polling_config: PollingConfig | None = None, **params: Any) -> Devbox: + params = dict(params) + params["snapshot_id"] = self._id + return self._devbox_client.create(polling_config=polling_config, **params) diff --git a/src/runloop_api_client/sdk/storage_object.py b/src/runloop_api_client/sdk/storage_object.py new file mode 100644 index 000000000..cf94b692e --- /dev/null +++ b/src/runloop_api_client/sdk/storage_object.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import io +import os +from typing import Any, Dict, List, Union, Literal, Optional +from pathlib import Path + +import httpx + +from .._client import Runloop +from ..types.object_view import ObjectView +from ..types.object_download_url_view import ObjectDownloadURLView + +ContentType = Literal["unspecified", "text", "binary", "gzip", "tar", "tgz"] +UploadData = Union[str, bytes, bytearray, Path, os.PathLike[str], io.IOBase] + + +class StorageObjectClient: + """ + Manage :class:`StorageObject` instances and provide convenience upload helpers. + """ + + def __init__(self, client: Runloop) -> None: + self._client = client + + def create( + self, + name: str, + *, + content_type: ContentType | None = None, + metadata: Optional[Dict[str, str]] = None, + ) -> "StorageObject": + content_type = content_type or _detect_content_type(name) + obj = self._client.objects.create( + name=name, + content_type=content_type, + metadata=metadata, + ) + return StorageObject(self._client, obj.id, upload_url=obj.upload_url) + + def from_id(self, object_id: str) -> "StorageObject": + return StorageObject(self._client, object_id, upload_url=None) + + def list(self, **params: Any) -> List["StorageObject"]: + page = self._client.objects.list(**params) + return [StorageObject(self._client, item.id, upload_url=None) for item in getattr(page, "objects", [])] + + def upload_from_file( + self, + path: str | Path, + name: str | None = None, + *, + metadata: Optional[Dict[str, str]] = None, + content_type: ContentType | None = None, + ) -> "StorageObject": + file_path = Path(path) + object_name = name or file_path.name + obj = self.create(object_name, content_type=content_type, metadata=metadata) + obj.upload_content(file_path) + obj.complete() + return obj + + def upload_from_text( + self, + text: str, + name: str, + *, + metadata: Optional[Dict[str, str]] = None, + ) -> "StorageObject": + obj = self.create(name, content_type="text", metadata=metadata) + obj.upload_content(text) + obj.complete() + return obj + + def upload_from_bytes( + self, + data: bytes, + name: str, + *, + metadata: Optional[Dict[str, str]] = None, + content_type: ContentType | None = None, + ) -> "StorageObject": + obj = self.create(name, content_type=content_type or _detect_content_type(name), metadata=metadata) + obj.upload_content(data) + obj.complete() + return obj + + +class StorageObject: + """ + Wrapper around storage object operations, including uploads and downloads. + """ + + def __init__(self, client: Runloop, object_id: str, upload_url: str | None) -> None: + self._client = client + self._id = object_id + self._upload_url = upload_url + + def __repr__(self) -> str: + return f"" + + @property + def id(self) -> str: + return self._id + + @property + def upload_url(self) -> str | None: + return self._upload_url + + def refresh(self, **request_options: Any) -> ObjectView: + return self._client.objects.retrieve(self._id, **request_options) + + def complete(self, **request_options: Any) -> ObjectView: + result = self._client.objects.complete(self._id, **request_options) + self._upload_url = None + return result + + def get_download_url(self, *, duration_seconds: int | None = None, **request_options: Any) -> ObjectDownloadURLView: + if duration_seconds is None: + return self._client.objects.download(self._id, **request_options) + return self._client.objects.download(self._id, duration_seconds=duration_seconds, **request_options) + + def download_as_bytes(self, *, duration_seconds: int | None = None, **request_options: Any) -> bytes: + url_view = self.get_download_url(duration_seconds=duration_seconds, **request_options) + response = httpx.get(url_view.download_url) + response.raise_for_status() + return response.content + + def download_as_text( + self, + *, + duration_seconds: int | None = None, + encoding: str = "utf-8", + **request_options: Any, + ) -> str: + url_view = self.get_download_url(duration_seconds=duration_seconds, **request_options) + response = httpx.get(url_view.download_url) + response.raise_for_status() + response.encoding = encoding + return response.text + + def delete(self, **request_options: Any) -> Any: + return self._client.objects.delete(self._id, **request_options) + + def upload_content(self, data: UploadData) -> None: + url = self._ensure_upload_url() + payload = _read_upload_data(data) + response = httpx.put(url, content=payload) + response.raise_for_status() + + def _ensure_upload_url(self) -> str: + if not self._upload_url: + raise RuntimeError("No upload URL available. Create a new object before uploading content.") + return self._upload_url + + +_CONTENT_TYPE_MAP: Dict[str, ContentType] = { + ".txt": "text", + ".html": "text", + ".css": "text", + ".js": "text", + ".json": "text", + ".xml": "text", + ".yaml": "text", + ".yml": "text", + ".md": "text", + ".csv": "text", + ".gz": "gzip", + ".tar": "tar", + ".tgz": "tgz", + ".tar.gz": "tgz", +} + + +def _detect_content_type(name: str) -> ContentType: + lower = name.lower() + if lower.endswith(".tar.gz") or lower.endswith(".tgz"): + return "tgz" + ext = Path(lower).suffix + return _CONTENT_TYPE_MAP.get(ext, "unspecified") + + +def _read_upload_data(data: UploadData) -> bytes: + if isinstance(data, bytes): + return data + if isinstance(data, bytearray): + return bytes(data) + if isinstance(data, (Path, os.PathLike)): + return Path(data).read_bytes() + if isinstance(data, str): + return data.encode("utf-8") + if isinstance(data, io.TextIOBase): + return data.read().encode("utf-8") + if isinstance(data, io.BufferedIOBase) or isinstance(data, io.RawIOBase): + return data.read() + if isinstance(data, io.IOBase) and hasattr(data, "read"): + result = data.read() + if isinstance(result, str): + return result.encode("utf-8") + return result + raise TypeError("Unsupported upload data type. Provide str, bytes, path, or file-like object.") diff --git a/tests/sdk/__init__.py b/tests/sdk/__init__.py new file mode 100644 index 000000000..e77f6cfbb --- /dev/null +++ b/tests/sdk/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: MIT + diff --git a/tests/sdk/test_async_devbox.py b/tests/sdk/test_async_devbox.py new file mode 100644 index 000000000..1141db0db --- /dev/null +++ b/tests/sdk/test_async_devbox.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import List + +import pytest + +from runloop_api_client import AsyncRunloopSDK +from runloop_api_client.sdk import AsyncDevbox, AsyncExecution, AsyncExecutionResult + + +@pytest.fixture() +async def async_sdk() -> AsyncRunloopSDK: + sdk = AsyncRunloopSDK(bearer_token="test-token") + try: + yield sdk + finally: + await sdk.aclose() + + +@pytest.mark.asyncio +async def test_async_create_returns_devbox(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: + devboxes_resource = async_sdk.api.devboxes + + async def fake_create_and_await_running(**_kwargs): + return SimpleNamespace(id="adev_1") + + monkeypatch.setattr(devboxes_resource, "create_and_await_running", fake_create_and_await_running) + + devbox = await async_sdk.devbox.create(name="async") + + assert isinstance(devbox, AsyncDevbox) + assert devbox.id == "adev_1" + + +@pytest.mark.asyncio +async def test_async_context_manager(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: + devboxes_resource = async_sdk.api.devboxes + calls: List[str] = [] + + async def fake_shutdown(devbox_id: str, **_kwargs): + calls.append(devbox_id) + + monkeypatch.setattr(devboxes_resource, "shutdown", fake_shutdown) + + async with async_sdk.devbox.from_id("adev_ctx") as devbox: + assert devbox.id == "adev_ctx" + + assert calls == ["adev_ctx"] + + +@pytest.mark.asyncio +async def test_async_exec_without_streaming(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: + devboxes_resource = async_sdk.api.devboxes + + result = SimpleNamespace( + execution_id="exec-async-1", + devbox_id="adev_exec", + stdout="async hello", + stderr="", + exit_status=0, + status="completed", + ) + + async def fake_execute_and_await_completion(devbox_id: str, **kwargs): + assert devbox_id == "adev_exec" + assert kwargs["command"] == "echo hi" + return result + + monkeypatch.setattr(devboxes_resource, "execute_and_await_completion", fake_execute_and_await_completion) + + devbox = async_sdk.devbox.from_id("adev_exec") + execution_result = await devbox.cmd.exec("echo hi") + + assert isinstance(execution_result, AsyncExecutionResult) + assert execution_result.exit_code == 0 + assert await execution_result.stdout() == "async hello" + + +@pytest.mark.asyncio +async def test_async_exec_with_streaming(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: + devboxes_resource = async_sdk.api.devboxes + executions_resource = devboxes_resource.executions + + execution = SimpleNamespace( + execution_id="exec-stream-async", + devbox_id="adev_stream", + stdout="", + stderr="", + exit_status=None, + status="running", + ) + + final = SimpleNamespace( + execution_id="exec-stream-async", + devbox_id="adev_stream", + stdout="done", + stderr="", + exit_status=0, + status="completed", + ) + + async def fake_execute_async(devbox_id: str, **kwargs): + assert kwargs["command"] == "long async task" + return execution + + async def fake_await_completed(execution_id: str, *, devbox_id: str, **_kwargs): + assert execution_id == "exec-stream-async" + assert devbox_id == "adev_stream" + return final + + class DummyAsyncStream: + def __init__(self, values: list[str]): + self._values = values + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + def __aiter__(self): + async def generator(): + for value in self._values: + yield SimpleNamespace(output=value) + + return generator() + + monkeypatch.setattr(devboxes_resource, "execute_async", fake_execute_async) + monkeypatch.setattr(executions_resource, "await_completed", fake_await_completed) + monkeypatch.setattr( + executions_resource, + "stream_stdout_updates", + lambda execution_id, *, devbox_id: DummyAsyncStream(["a", "b"]), + ) + monkeypatch.setattr( + executions_resource, + "stream_stderr_updates", + lambda execution_id, *, devbox_id: DummyAsyncStream([]), + ) + + stdout_logs: list[str] = [] + combined_logs: list[str] = [] + + async def capture_stdout(line: str) -> None: + stdout_logs.append(line) + + async def capture_output(line: str) -> None: + combined_logs.append(line) + + devbox = async_sdk.devbox.from_id("adev_stream") + result = await devbox.cmd.exec( + "long async task", + stdout=capture_stdout, + output=capture_output, + ) + + assert stdout_logs == ["a", "b"] + assert combined_logs == ["a", "b"] + assert await result.stdout() == "done" + + +@pytest.mark.asyncio +async def test_async_exec_async_returns_execution(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: + devboxes_resource = async_sdk.api.devboxes + executions_resource = devboxes_resource.executions + + execution = SimpleNamespace( + execution_id="exec-async-bg", + devbox_id="adev_bg", + stdout="", + stderr="", + exit_status=None, + status="running", + ) + + final = SimpleNamespace( + execution_id="exec-async-bg", + devbox_id="adev_bg", + stdout="finished", + stderr="", + exit_status=0, + status="completed", + ) + + async def fake_execute_async(devbox_id: str, **kwargs): + return execution + + async def fake_await_completed(execution_id: str, *, devbox_id: str, **_kwargs): + return final + + class EmptyAsyncStream: + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + def __aiter__(self): + async def generator(): + if False: + yield # pragma: no cover + + return generator() + + monkeypatch.setattr(devboxes_resource, "execute_async", fake_execute_async) + monkeypatch.setattr(executions_resource, "await_completed", fake_await_completed) + monkeypatch.setattr( + executions_resource, + "stream_stdout_updates", + lambda execution_id, *, devbox_id: EmptyAsyncStream(), + ) + monkeypatch.setattr( + executions_resource, + "stream_stderr_updates", + lambda execution_id, *, devbox_id: EmptyAsyncStream(), + ) + + devbox = async_sdk.devbox.from_id("adev_bg") + execution_obj = await devbox.cmd.exec_async("background async task") + + assert isinstance(execution_obj, AsyncExecution) + result = await execution_obj.result() + assert await result.stdout() == "finished" diff --git a/tests/sdk/test_async_resources.py b/tests/sdk/test_async_resources.py new file mode 100644 index 000000000..fb911024d --- /dev/null +++ b/tests/sdk/test_async_resources.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import List + +import httpx +import pytest + +from runloop_api_client import AsyncRunloopSDK +from runloop_api_client.sdk import AsyncSnapshot, AsyncBlueprint, AsyncStorageObject + + +@pytest.fixture() +async def async_sdk() -> AsyncRunloopSDK: + sdk = AsyncRunloopSDK(bearer_token="test-token") + try: + yield sdk + finally: + await sdk.aclose() + + +@pytest.mark.asyncio +async def test_async_blueprint_create(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: + blueprints_resource = async_sdk.api.blueprints + + async def fake_create_and_await_build_complete(**kwargs): + return SimpleNamespace(id="abp-1") + + monkeypatch.setattr(blueprints_resource, "create_and_await_build_complete", fake_create_and_await_build_complete) + + devbox_calls: List[dict[str, object]] = [] + + async def fake_devbox_create(**kwargs): + devbox_calls.append(kwargs) + return SimpleNamespace(id="adev-1") + + monkeypatch.setattr(async_sdk.devbox, "create", fake_devbox_create) + + blueprint = await async_sdk.blueprint.create(name="async-blueprint") + assert isinstance(blueprint, AsyncBlueprint) + + await blueprint.create_devbox() + assert devbox_calls[0]["blueprint_id"] == "abp-1" + + +@pytest.mark.asyncio +async def test_async_snapshot_list(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: + disk_snapshots_resource = async_sdk.api.devboxes.disk_snapshots + + async def fake_list(**kwargs): + return SimpleNamespace(disk_snapshots=[SimpleNamespace(id="asnap-1")]) + + monkeypatch.setattr(disk_snapshots_resource, "list", fake_list) + + snapshots = await async_sdk.snapshot.list() + assert isinstance(snapshots[0], AsyncSnapshot) + + +@pytest.mark.asyncio +async def test_async_storage_object_upload(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: + objects_resource = async_sdk.api.objects + + async def fake_create(**kwargs): + return SimpleNamespace(id="aobj-1", upload_url="https://async-upload.example.com") + + async def fake_complete(object_id: str, **kwargs): + return SimpleNamespace(id=object_id, upload_url=None) + + async def fake_download(object_id: str, **kwargs): + return SimpleNamespace(download_url=f"https://async-download.example.com/{object_id}") + + monkeypatch.setattr(objects_resource, "create", fake_create) + monkeypatch.setattr(objects_resource, "complete", fake_complete) + monkeypatch.setattr(objects_resource, "download", fake_download) + + class DummyAsyncResponse: + def __init__(self, content: bytes) -> None: + self.content = content + self.text = content.decode("utf-8") + self.encoding = "utf-8" + + def raise_for_status(self) -> None: + return None + + class DummyAsyncClient: + def __init__(self, response: DummyAsyncResponse) -> None: + self._response = response + self.calls: List[tuple[str, bytes | None]] = [] + + async def __aenter__(self) -> "DummyAsyncClient": + return self + + async def __aexit__(self, *exc) -> None: + return None + + async def put(self, url: str, *, content: bytes) -> DummyAsyncResponse: + self.calls.append((url, content)) + return self._response + + async def get(self, url: str) -> DummyAsyncResponse: + self.calls.append((url, None)) + return self._response + + dummy_response = DummyAsyncResponse(b"hello async") + + def client_factory(*args, **kwargs): + return DummyAsyncClient(dummy_response) + + monkeypatch.setattr(httpx, "AsyncClient", client_factory) + + obj = await async_sdk.storage_object.upload_from_text("hello async", name="message.txt") + assert isinstance(obj, AsyncStorageObject) + + content = await obj.download_as_text() + assert content == "hello async" diff --git a/tests/sdk/test_devbox.py b/tests/sdk/test_devbox.py new file mode 100644 index 000000000..882f78bcd --- /dev/null +++ b/tests/sdk/test_devbox.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import List + +import pytest + +from runloop_api_client import RunloopSDK +from runloop_api_client.sdk import Devbox, Execution, ExecutionResult + + +@pytest.fixture() +def sdk() -> RunloopSDK: + return RunloopSDK(bearer_token="test-token") + + +def test_create_returns_devbox(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: + devboxes_resource = sdk.api.devboxes + + captured_kwargs: dict[str, object] = {} + + def fake_create_and_await_running(**kwargs): + captured_kwargs.update(kwargs) + return SimpleNamespace(id="dev_123") + + monkeypatch.setattr(devboxes_resource, "create_and_await_running", fake_create_and_await_running) + + devbox = sdk.devbox.create(name="my-devbox") + + assert isinstance(devbox, Devbox) + assert devbox.id == "dev_123" + assert captured_kwargs["name"] == "my-devbox" + + +def test_context_manager_shuts_down(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: + devboxes_resource = sdk.api.devboxes + shutdown_calls: List[str] = [] + + def fake_shutdown(devbox_id: str, **_kwargs): + shutdown_calls.append(devbox_id) + return None + + monkeypatch.setattr(devboxes_resource, "shutdown", fake_shutdown) + + with sdk.devbox.from_id("dev_ctx") as devbox: + assert devbox.id == "dev_ctx" + + assert shutdown_calls == ["dev_ctx"] + + +def test_exec_without_streaming(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: + devboxes_resource = sdk.api.devboxes + + result = SimpleNamespace( + execution_id="exec-1", + devbox_id="dev_456", + stdout="hello", + stderr="", + exit_status=0, + status="completed", + ) + + def fake_execute_and_await_completion(devbox_id: str, **kwargs): + assert devbox_id == "dev_456" + assert kwargs["command"] == "echo hello" + return result + + monkeypatch.setattr(devboxes_resource, "execute_and_await_completion", fake_execute_and_await_completion) + + devbox = sdk.devbox.from_id("dev_456") + execution_result = devbox.cmd.exec("echo hello") + + assert isinstance(execution_result, ExecutionResult) + assert execution_result.exit_code == 0 + assert execution_result.stdout() == "hello" + + +def test_exec_with_streaming_callbacks(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: + devboxes_resource = sdk.api.devboxes + executions_resource = devboxes_resource.executions + + execution = SimpleNamespace( + execution_id="exec-stream", + devbox_id="dev_stream", + stdout="", + stderr="", + exit_status=None, + status="running", + ) + + final = SimpleNamespace( + execution_id="exec-stream", + devbox_id="dev_stream", + stdout="done", + stderr="", + exit_status=0, + status="completed", + ) + + def fake_execute_async(devbox_id: str, **kwargs): + assert kwargs["command"] == "long task" + assert devbox_id == "dev_stream" + return execution + + def fake_await_completed(execution_id: str, devbox_id: str, **_kwargs): + assert execution_id == "exec-stream" + assert devbox_id == "dev_stream" + return final + + class DummyStream: + def __init__(self, values: list[str]): + self._values = values + + def __iter__(self): + for value in self._values: + yield SimpleNamespace(output=value) + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + monkeypatch.setattr(devboxes_resource, "execute_async", fake_execute_async) + monkeypatch.setattr(executions_resource, "await_completed", fake_await_completed) + monkeypatch.setattr( + executions_resource, + "stream_stdout_updates", + lambda execution_id, *, devbox_id: DummyStream(["line 1", "line 2"]), + ) + monkeypatch.setattr( + executions_resource, + "stream_stderr_updates", + lambda execution_id, *, devbox_id: DummyStream([]), + ) + + stdout_logs: list[str] = [] + combined_logs: list[str] = [] + + devbox = sdk.devbox.from_id("dev_stream") + result = devbox.cmd.exec( + "long task", + stdout=stdout_logs.append, + output=combined_logs.append, + ) + + assert stdout_logs == ["line 1", "line 2"] + assert combined_logs == ["line 1", "line 2"] + assert result.exit_code == 0 + assert result.stdout() == "done" + + +def test_exec_async_returns_execution(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: + devboxes_resource = sdk.api.devboxes + executions_resource = devboxes_resource.executions + + execution = SimpleNamespace( + execution_id="exec-async", + devbox_id="dev_async", + stdout="", + stderr="", + exit_status=None, + status="running", + ) + + final = SimpleNamespace( + execution_id="exec-async", + devbox_id="dev_async", + stdout="async complete", + stderr="", + exit_status=0, + status="completed", + ) + + monkeypatch.setattr(devboxes_resource, "execute_async", lambda devbox_id, **kwargs: execution) + monkeypatch.setattr( + executions_resource, + "await_completed", + lambda execution_id, *, devbox_id, **kwargs: final, + ) + + class EmptyStream: + def __iter__(self): + return iter(()) + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + monkeypatch.setattr( + executions_resource, + "stream_stdout_updates", + lambda execution_id, *, devbox_id: EmptyStream(), + ) + monkeypatch.setattr( + executions_resource, + "stream_stderr_updates", + lambda execution_id, *, devbox_id: EmptyStream(), + ) + + devbox = sdk.devbox.from_id("dev_async") + execution_obj = devbox.cmd.exec_async("background task") + + assert isinstance(execution_obj, Execution) + result = execution_obj.result() + assert result.stdout() == "async complete" diff --git a/tests/sdk/test_imports.py b/tests/sdk/test_imports.py new file mode 100644 index 000000000..cf2cd8284 --- /dev/null +++ b/tests/sdk/test_imports.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import pytest + +from runloop_api_client import Runloop, RunloopSDK, AsyncRunloop, AsyncRunloopSDK + + +def test_runloop_sdk_exposes_api() -> None: + sdk = RunloopSDK(bearer_token="test-token") + try: + assert isinstance(sdk.api, Runloop) + from runloop_api_client.sdk import DevboxClient + + assert isinstance(sdk.devbox, DevboxClient) + finally: + sdk.close() + + +@pytest.mark.asyncio +async def test_async_runloop_sdk_exposes_api() -> None: + sdk = AsyncRunloopSDK(bearer_token="test-token") + try: + assert isinstance(sdk.api, AsyncRunloop) + from runloop_api_client.sdk import AsyncDevboxClient + + assert isinstance(sdk.devbox, AsyncDevboxClient) + finally: + await sdk.aclose() diff --git a/tests/sdk/test_resources.py b/tests/sdk/test_resources.py new file mode 100644 index 000000000..5e61a0f37 --- /dev/null +++ b/tests/sdk/test_resources.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import List + +import httpx +import pytest + +from runloop_api_client import RunloopSDK +from runloop_api_client.sdk import Blueprint, StorageObject + + +@pytest.fixture() +def sdk() -> RunloopSDK: + return RunloopSDK(bearer_token="test-token") + + +def test_blueprint_create_and_devbox(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: + blueprints_resource = sdk.api.blueprints + created: List[str] = [] + + def fake_create_and_await_build_complete(**kwargs): + created.append(kwargs["name"]) + return SimpleNamespace(id="bp-001") + + monkeypatch.setattr(blueprints_resource, "create_and_await_build_complete", fake_create_and_await_build_complete) + + devbox_calls: List[dict[str, object]] = [] + + monkeypatch.setattr( + sdk.devbox, + "create", + lambda **kwargs: (devbox_calls.append(kwargs), SimpleNamespace(id="dev-123"))[1], + ) + + blueprint = sdk.blueprint.create(name="my-blueprint") + assert isinstance(blueprint, Blueprint) + blueprint.create_devbox() + + assert created == ["my-blueprint"] + assert devbox_calls[0]["blueprint_id"] == "bp-001" + + +def test_snapshot_list_and_devbox(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: + disk_snapshots_resource = sdk.api.devboxes.disk_snapshots + + page = SimpleNamespace(disk_snapshots=[SimpleNamespace(id="snap-1"), SimpleNamespace(id="snap-2")]) + monkeypatch.setattr(disk_snapshots_resource, "list", lambda **kwargs: page) + + devbox_calls: List[dict[str, object]] = [] + monkeypatch.setattr( + sdk.devbox, + "create", + lambda **kwargs: (devbox_calls.append(kwargs), SimpleNamespace(id="dev-from-snap"))[1], + ) + + snapshots = sdk.snapshot.list() + assert [snap.id for snap in snapshots] == ["snap-1", "snap-2"] + snapshots[0].create_devbox() + + assert devbox_calls[0]["snapshot_id"] == "snap-1" + + +def test_storage_object_upload_and_download(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: + objects_resource = sdk.api.objects + + created_objects: List[dict[str, object]] = [] + + def fake_create(**kwargs): + created_objects.append(kwargs) + return SimpleNamespace(id="obj-1", upload_url="https://upload.example.com") + + completed_ids: List[str] = [] + + def fake_complete(object_id: str, **_kwargs): + completed_ids.append(object_id) + return SimpleNamespace(id=object_id, upload_url=None) + + download_urls: List[str] = [] + + def fake_download(object_id: str, **kwargs): + url = f"https://download.example.com/{object_id}" + download_urls.append(url) + return SimpleNamespace(download_url=url) + + monkeypatch.setattr(objects_resource, "create", fake_create) + monkeypatch.setattr(objects_resource, "complete", fake_complete) + monkeypatch.setattr(objects_resource, "download", fake_download) + + put_calls: List[tuple[str, bytes]] = [] + get_calls: List[str] = [] + + def fake_put(url: str, *, content: bytes, **_kwargs): + put_calls.append((url, content)) + return SimpleNamespace(raise_for_status=lambda: None) + + def fake_get(url: str, **_kwargs): + get_calls.append(url) + return SimpleNamespace(raise_for_status=lambda: None, content=b"hello", text="hello", encoding="utf-8") + + monkeypatch.setattr(httpx, "put", fake_put) + monkeypatch.setattr(httpx, "get", fake_get) + + obj = sdk.storage_object.upload_from_text("hello", name="greeting.txt") + assert isinstance(obj, StorageObject) + assert put_calls == [("https://upload.example.com", b"hello")] + assert completed_ids == ["obj-1"] + + data = obj.download_as_text() + assert data == "hello" + assert get_calls == ["https://download.example.com/obj-1"] From b5960b660846df57bea054b865bd7f617a32f140 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 10 Nov 2025 15:14:25 -0800 Subject: [PATCH 04/56] added await_suspended methods to base api --- .../resources/devboxes/devboxes.py | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/runloop_api_client/resources/devboxes/devboxes.py b/src/runloop_api_client/resources/devboxes/devboxes.py index 7a3ddf741..4da1ecc7f 100644 --- a/src/runloop_api_client/resources/devboxes/devboxes.py +++ b/src/runloop_api_client/resources/devboxes/devboxes.py @@ -426,6 +426,50 @@ def is_done_booting(devbox: DevboxView) -> bool: return devbox + def await_suspended( + self, + id: str, + *, + polling_config: PollingConfig | None = None, + ) -> DevboxView: + """Wait for a devbox to reach the suspended state. + + Args: + id: The ID of the devbox to wait for. + polling_config: Optional polling configuration. + + Returns: + The devbox in the suspended state. + + Raises: + PollingTimeout: If polling times out before the devbox is suspended. + RunloopError: If the devbox enters a non-suspended terminal state. + """ + + def wait_for_devbox_status() -> DevboxView: + return self._post( + f"/v1/devboxes/{id}/wait_for_status", + body={"statuses": ["suspended", "failure", "shutdown"]}, + cast_to=DevboxView, + ) + + def handle_timeout_error(error: Exception) -> DevboxView: + if isinstance(error, APITimeoutError) or ( + isinstance(error, APIStatusError) and error.response.status_code == 408 + ): + return placeholder_devbox_view(id) + raise error + + def is_terminal_state(devbox: DevboxView) -> bool: + return devbox.status in {"suspended", "failure", "shutdown"} + + devbox = poll_until(wait_for_devbox_status, is_terminal_state, polling_config, handle_timeout_error) + + if devbox.status != "suspended": + raise RunloopError(f"Devbox entered non-suspended terminal state: {devbox.status}") + + return devbox + def create_and_await_running( self, *, @@ -1928,6 +1972,48 @@ def is_done_booting(devbox: DevboxView) -> bool: return devbox + async def await_suspended( + self, + id: str, + *, + polling_config: PollingConfig | None = None, + ) -> DevboxView: + """Wait for a devbox to reach the suspended state. + + Args: + id: The ID of the devbox to wait for. + polling_config: Optional polling configuration. + + Returns: + The devbox in the suspended state. + + Raises: + PollingTimeout: If polling times out before the devbox is suspended. + RunloopError: If the devbox enters a non-suspended terminal state. + """ + + async def wait_for_devbox_status() -> DevboxView: + try: + return await self._post( + f"/v1/devboxes/{id}/wait_for_status", + body={"statuses": ["suspended", "failure", "shutdown"]}, + cast_to=DevboxView, + ) + except (APITimeoutError, APIStatusError) as error: + if isinstance(error, APITimeoutError) or error.response.status_code == 408: + return placeholder_devbox_view(id) + raise + + def is_terminal_state(devbox: DevboxView) -> bool: + return devbox.status in {"suspended", "failure", "shutdown"} + + devbox = await async_poll_until(wait_for_devbox_status, is_terminal_state, polling_config) + + if devbox.status != "suspended": + raise RunloopError(f"Devbox entered non-suspended terminal state: {devbox.status}") + + return devbox + async def update( self, id: str, From 266626020b4618042068e41a214a918ab21f1cf9 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 10 Nov 2025 15:14:57 -0800 Subject: [PATCH 05/56] added await_completed methods to base api --- .../resources/devboxes/disk_snapshots.py | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/runloop_api_client/resources/devboxes/disk_snapshots.py b/src/runloop_api_client/resources/devboxes/disk_snapshots.py index 3575b41f0..81222c2de 100644 --- a/src/runloop_api_client/resources/devboxes/disk_snapshots.py +++ b/src/runloop_api_client/resources/devboxes/disk_snapshots.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Dict, Optional +from typing import Any, Dict, Optional import httpx @@ -21,6 +21,9 @@ from ...types.devboxes import disk_snapshot_list_params, disk_snapshot_update_params from ...types.devbox_snapshot_view import DevboxSnapshotView from ...types.devboxes.devbox_snapshot_async_status_view import DevboxSnapshotAsyncStatusView +from ..._exceptions import RunloopError +from ...lib.polling import PollingConfig, poll_until +from ...lib.polling_async import async_poll_until __all__ = ["DiskSnapshotsResource", "AsyncDiskSnapshotsResource"] @@ -239,6 +242,29 @@ def query_status( cast_to=DevboxSnapshotAsyncStatusView, ) + def await_completed( + self, + id: str, + *, + polling_config: PollingConfig | None = None, + **request_options: Any, + ) -> DevboxSnapshotAsyncStatusView: + """Wait for a disk snapshot operation to complete.""" + + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + + def is_terminal(result: DevboxSnapshotAsyncStatusView) -> bool: + return result.status in {"complete", "error"} + + status = poll_until(lambda: self.query_status(id, **request_options), is_terminal, polling_config) + + if status.status == "error": + message = status.error_message or "Unknown error" + raise RunloopError(f"Snapshot {id} failed: {message}") + + return status + class AsyncDiskSnapshotsResource(AsyncAPIResource): @cached_property @@ -454,6 +480,33 @@ async def query_status( cast_to=DevboxSnapshotAsyncStatusView, ) + async def await_completed( + self, + id: str, + *, + polling_config: PollingConfig | None = None, + **request_options: Any, + ) -> DevboxSnapshotAsyncStatusView: + """Wait asynchronously for a disk snapshot operation to complete.""" + + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + + def is_terminal(result: DevboxSnapshotAsyncStatusView) -> bool: + return result.status in {"complete", "error"} + + status = await async_poll_until( + lambda: self.query_status(id, **request_options), + is_terminal, + polling_config, + ) + + if status.status == "error": + message = status.error_message or "Unknown error" + raise RunloopError(f"Snapshot {id} failed: {message}") + + return status + class DiskSnapshotsResourceWithRawResponse: def __init__(self, disk_snapshots: DiskSnapshotsResource) -> None: From 511de339373f0836f74e50eeeb3f6b88d9eeb2b3 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 10 Nov 2025 15:54:50 -0800 Subject: [PATCH 06/56] unit and smoke tests for base api changes --- .../devboxes/test_disk_snapshots.py | 215 ++++++++++ tests/api_resources/test_devboxes.py | 375 ++++++++++++++++++ tests/smoketests/test_devboxes.py | 27 ++ 3 files changed, 617 insertions(+) diff --git a/tests/api_resources/devboxes/test_disk_snapshots.py b/tests/api_resources/devboxes/test_disk_snapshots.py index c04e4e971..6221c8647 100644 --- a/tests/api_resources/devboxes/test_disk_snapshots.py +++ b/tests/api_resources/devboxes/test_disk_snapshots.py @@ -4,6 +4,7 @@ import os from typing import Any, cast +from unittest.mock import patch import pytest @@ -14,6 +15,8 @@ from runloop_api_client.types.devboxes import ( DevboxSnapshotAsyncStatusView, ) +from runloop_api_client._exceptions import RunloopError +from runloop_api_client.lib.polling import PollingConfig, PollingTimeout base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -181,6 +184,112 @@ def test_path_params_query_status(self, client: Runloop) -> None: "", ) + # Polling method tests + @parametrize + def test_method_await_completed_success(self, client: Runloop) -> None: + """Test await_completed with successful polling to complete state""" + + # Mock the query_status calls - first returns in_progress, then complete + mock_status_in_progress = DevboxSnapshotAsyncStatusView( + status="in_progress", + error_message=None, + ) + + mock_status_complete = DevboxSnapshotAsyncStatusView( + status="complete", + error_message=None, + ) + + with patch.object(client.devboxes.disk_snapshots, "query_status") as mock_query: + mock_query.side_effect = [mock_status_in_progress, mock_status_complete] + + result = client.devboxes.disk_snapshots.await_completed("test_id") + + assert result.status == "complete" + assert mock_query.call_count == 2 + + @parametrize + def test_method_await_completed_immediate_success(self, client: Runloop) -> None: + """Test await_completed when snapshot is already complete""" + + mock_status_complete = DevboxSnapshotAsyncStatusView( + status="complete", + error_message=None, + ) + + with patch.object(client.devboxes.disk_snapshots, "query_status") as mock_query: + mock_query.return_value = mock_status_complete + + result = client.devboxes.disk_snapshots.await_completed("test_id") + + assert result.status == "complete" + assert mock_query.call_count == 1 + + @parametrize + def test_method_await_completed_error_state(self, client: Runloop) -> None: + """Test await_completed when snapshot status becomes error""" + + mock_status_error = DevboxSnapshotAsyncStatusView( + status="error", + error_message="Snapshot creation failed", + ) + + with patch.object(client.devboxes.disk_snapshots, "query_status") as mock_query: + mock_query.return_value = mock_status_error + + with pytest.raises(RunloopError, match="Snapshot test_id failed: Snapshot creation failed"): + client.devboxes.disk_snapshots.await_completed("test_id") + + @parametrize + def test_method_await_completed_error_state_no_message(self, client: Runloop) -> None: + """Test await_completed when snapshot status becomes error without error message""" + + mock_status_error = DevboxSnapshotAsyncStatusView( + status="error", + error_message=None, + ) + + with patch.object(client.devboxes.disk_snapshots, "query_status") as mock_query: + mock_query.return_value = mock_status_error + + with pytest.raises(RunloopError, match="Snapshot test_id failed: Unknown error"): + client.devboxes.disk_snapshots.await_completed("test_id") + + @parametrize + def test_method_await_completed_with_config(self, client: Runloop) -> None: + """Test await_completed with custom polling configuration""" + + mock_status_complete = DevboxSnapshotAsyncStatusView( + status="complete", + error_message=None, + ) + + config = PollingConfig(interval_seconds=0.1, max_attempts=10) + + with patch.object(client.devboxes.disk_snapshots, "query_status") as mock_query: + mock_query.return_value = mock_status_complete + + result = client.devboxes.disk_snapshots.await_completed("test_id", polling_config=config) + + assert result.status == "complete" + + @parametrize + def test_method_await_completed_polling_timeout(self, client: Runloop) -> None: + """Test await_completed raises PollingTimeout when max attempts exceeded""" + + mock_status_in_progress = DevboxSnapshotAsyncStatusView( + status="in_progress", + error_message=None, + ) + + config = PollingConfig(interval_seconds=0.01, max_attempts=2) + + with patch.object(client.devboxes.disk_snapshots, "query_status") as mock_query: + mock_query.return_value = mock_status_in_progress + + with pytest.raises(PollingTimeout): + client.devboxes.disk_snapshots.await_completed("test_id", polling_config=config) + class TestAsyncDiskSnapshots: parametrize = pytest.mark.parametrize( @@ -346,3 +455,109 @@ async def test_path_params_query_status(self, async_client: AsyncRunloop) -> Non await async_client.devboxes.disk_snapshots.with_raw_response.query_status( "", ) + + # Polling method tests + @parametrize + async def test_method_await_completed_success(self, async_client: AsyncRunloop) -> None: + """Test await_completed with successful polling to complete state""" + + # Mock the query_status calls - first returns in_progress, then complete + mock_status_in_progress = DevboxSnapshotAsyncStatusView( + status="in_progress", + error_message=None, + ) + + mock_status_complete = DevboxSnapshotAsyncStatusView( + status="complete", + error_message=None, + ) + + with patch.object(async_client.devboxes.disk_snapshots, "query_status") as mock_query: + mock_query.side_effect = [mock_status_in_progress, mock_status_complete] + + result = await async_client.devboxes.disk_snapshots.await_completed("test_id") + + assert result.status == "complete" + assert mock_query.call_count == 2 + + @parametrize + async def test_method_await_completed_immediate_success(self, async_client: AsyncRunloop) -> None: + """Test await_completed when snapshot is already complete""" + + mock_status_complete = DevboxSnapshotAsyncStatusView( + status="complete", + error_message=None, + ) + + with patch.object(async_client.devboxes.disk_snapshots, "query_status") as mock_query: + mock_query.return_value = mock_status_complete + + result = await async_client.devboxes.disk_snapshots.await_completed("test_id") + + assert result.status == "complete" + assert mock_query.call_count == 1 + + @parametrize + async def test_method_await_completed_error_state(self, async_client: AsyncRunloop) -> None: + """Test await_completed when snapshot status becomes error""" + + mock_status_error = DevboxSnapshotAsyncStatusView( + status="error", + error_message="Snapshot creation failed", + ) + + with patch.object(async_client.devboxes.disk_snapshots, "query_status") as mock_query: + mock_query.return_value = mock_status_error + + with pytest.raises(RunloopError, match="Snapshot test_id failed: Snapshot creation failed"): + await async_client.devboxes.disk_snapshots.await_completed("test_id") + + @parametrize + async def test_method_await_completed_error_state_no_message(self, async_client: AsyncRunloop) -> None: + """Test await_completed when snapshot status becomes error without error message""" + + mock_status_error = DevboxSnapshotAsyncStatusView( + status="error", + error_message=None, + ) + + with patch.object(async_client.devboxes.disk_snapshots, "query_status") as mock_query: + mock_query.return_value = mock_status_error + + with pytest.raises(RunloopError, match="Snapshot test_id failed: Unknown error"): + await async_client.devboxes.disk_snapshots.await_completed("test_id") + + @parametrize + async def test_method_await_completed_with_config(self, async_client: AsyncRunloop) -> None: + """Test await_completed with custom polling configuration""" + + mock_status_complete = DevboxSnapshotAsyncStatusView( + status="complete", + error_message=None, + ) + + config = PollingConfig(interval_seconds=0.1, max_attempts=10) + + with patch.object(async_client.devboxes.disk_snapshots, "query_status") as mock_query: + mock_query.return_value = mock_status_complete + + result = await async_client.devboxes.disk_snapshots.await_completed("test_id", polling_config=config) + + assert result.status == "complete" + + @parametrize + async def test_method_await_completed_polling_timeout(self, async_client: AsyncRunloop) -> None: + """Test await_completed raises PollingTimeout when max attempts exceeded""" + + mock_status_in_progress = DevboxSnapshotAsyncStatusView( + status="in_progress", + error_message=None, + ) + + config = PollingConfig(interval_seconds=0.01, max_attempts=2) + + with patch.object(async_client.devboxes.disk_snapshots, "query_status") as mock_query: + mock_query.return_value = mock_status_in_progress + + with pytest.raises(PollingTimeout): + await async_client.devboxes.disk_snapshots.await_completed("test_id", polling_config=config) diff --git a/tests/api_resources/test_devboxes.py b/tests/api_resources/test_devboxes.py index 5ffa79548..98d0a4a7a 100644 --- a/tests/api_resources/test_devboxes.py +++ b/tests/api_resources/test_devboxes.py @@ -1398,6 +1398,193 @@ def test_method_create_and_await_running_await_failure(self, client: Runloop) -> name="test", ) + @parametrize + def test_method_await_suspended_success(self, client: Runloop) -> None: + """Test await_suspended with successful polling to suspended state""" + + # Mock the wait_for_status calls - first returns running, then suspended + mock_devbox_running = DevboxView( + id="test_id", + status="running", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + mock_devbox_suspended = DevboxView( + id="test_id", + status="suspended", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + with patch.object(client.devboxes, "_post") as mock_post: + mock_post.side_effect = [mock_devbox_running, mock_devbox_suspended] + + result = client.devboxes.await_suspended("test_id") + + assert result.id == "test_id" + assert result.status == "suspended" + assert mock_post.call_count == 2 + + @parametrize + def test_method_await_suspended_immediate_success(self, client: Runloop) -> None: + """Test await_suspended when devbox is already suspended""" + + mock_devbox_suspended = DevboxView( + id="test_id", + status="suspended", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + with patch.object(client.devboxes, "_post") as mock_post: + mock_post.return_value = mock_devbox_suspended + + result = client.devboxes.await_suspended("test_id") + + assert result.id == "test_id" + assert result.status == "suspended" + assert mock_post.call_count == 1 + + @parametrize + def test_method_await_suspended_failure_state(self, client: Runloop) -> None: + """Test await_suspended when devbox enters failure state""" + + mock_devbox_failed = DevboxView( + id="test_id", + status="failure", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + with patch.object(client.devboxes, "_post") as mock_post: + mock_post.return_value = mock_devbox_failed + + with pytest.raises(RunloopError, match="Devbox entered non-suspended terminal state: failure"): + client.devboxes.await_suspended("test_id") + + @parametrize + def test_method_await_suspended_shutdown_state(self, client: Runloop) -> None: + """Test await_suspended when devbox enters shutdown state""" + + mock_devbox_shutdown = DevboxView( + id="test_id", + status="shutdown", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + with patch.object(client.devboxes, "_post") as mock_post: + mock_post.return_value = mock_devbox_shutdown + + with pytest.raises(RunloopError, match="Devbox entered non-suspended terminal state: shutdown"): + client.devboxes.await_suspended("test_id") + + @parametrize + def test_method_await_suspended_timeout_handling(self, client: Runloop) -> None: + """Test await_suspended handles 408 timeouts correctly""" + + # Create a mock 408 response + mock_response = Mock() + mock_response.status_code = 408 + mock_408_error = APIStatusError("Request timeout", response=mock_response, body=None) + + mock_devbox_suspended = DevboxView( + id="test_id", + status="suspended", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + with patch.object(client.devboxes, "_post") as mock_post: + # First call raises 408, second call succeeds + mock_post.side_effect = [mock_408_error, mock_devbox_suspended] + + result = client.devboxes.await_suspended("test_id") + + assert result.id == "test_id" + assert result.status == "suspended" + assert mock_post.call_count == 2 + + @parametrize + def test_method_await_suspended_other_error(self, client: Runloop) -> None: + """Test await_suspended re-raises non-408 errors""" + + # Create a mock 500 response + mock_response = Mock() + mock_response.status_code = 500 + mock_500_error = APIStatusError("Internal server error", response=mock_response, body=None) + + with patch.object(client.devboxes, "_post") as mock_post: + mock_post.side_effect = mock_500_error + + with pytest.raises(APIStatusError, match="Internal server error"): + client.devboxes.await_suspended("test_id") + + @parametrize + def test_method_await_suspended_with_config(self, client: Runloop) -> None: + """Test await_suspended with custom polling configuration""" + + mock_devbox_suspended = DevboxView( + id="test_id", + status="suspended", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + config = PollingConfig(interval_seconds=0.1, max_attempts=10) + + with patch.object(client.devboxes, "_post") as mock_post: + mock_post.return_value = mock_devbox_suspended + + result = client.devboxes.await_suspended("test_id", polling_config=config) + + assert result.id == "test_id" + assert result.status == "suspended" + + @parametrize + def test_method_await_suspended_polling_timeout(self, client: Runloop) -> None: + """Test await_suspended raises PollingTimeout when max attempts exceeded""" + + mock_devbox_running = DevboxView( + id="test_id", + status="running", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + config = PollingConfig(interval_seconds=0.01, max_attempts=2) + + with patch.object(client.devboxes, "_post") as mock_post: + mock_post.return_value = mock_devbox_running + + with pytest.raises(PollingTimeout): + client.devboxes.await_suspended("test_id", polling_config=config) + class TestAsyncDevboxes: parametrize = pytest.mark.parametrize( @@ -2471,3 +2658,191 @@ async def test_path_params_write_file_contents(self, async_client: AsyncRunloop) contents="contents", file_path="file_path", ) + + # Polling method tests + @parametrize + async def test_method_await_suspended_success(self, async_client: AsyncRunloop) -> None: + """Test await_suspended with successful polling to suspended state""" + + # Mock the wait_for_status calls - first returns running, then suspended + mock_devbox_running = DevboxView( + id="test_id", + status="running", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + mock_devbox_suspended = DevboxView( + id="test_id", + status="suspended", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + with patch.object(async_client.devboxes, "_post") as mock_post: + mock_post.side_effect = [mock_devbox_running, mock_devbox_suspended] + + result = await async_client.devboxes.await_suspended("test_id") + + assert result.id == "test_id" + assert result.status == "suspended" + assert mock_post.call_count == 2 + + @parametrize + async def test_method_await_suspended_immediate_success(self, async_client: AsyncRunloop) -> None: + """Test await_suspended when devbox is already suspended""" + + mock_devbox_suspended = DevboxView( + id="test_id", + status="suspended", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + with patch.object(async_client.devboxes, "_post") as mock_post: + mock_post.return_value = mock_devbox_suspended + + result = await async_client.devboxes.await_suspended("test_id") + + assert result.id == "test_id" + assert result.status == "suspended" + assert mock_post.call_count == 1 + + @parametrize + async def test_method_await_suspended_failure_state(self, async_client: AsyncRunloop) -> None: + """Test await_suspended when devbox enters failure state""" + + mock_devbox_failed = DevboxView( + id="test_id", + status="failure", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + with patch.object(async_client.devboxes, "_post") as mock_post: + mock_post.return_value = mock_devbox_failed + + with pytest.raises(RunloopError, match="Devbox entered non-suspended terminal state: failure"): + await async_client.devboxes.await_suspended("test_id") + + @parametrize + async def test_method_await_suspended_shutdown_state(self, async_client: AsyncRunloop) -> None: + """Test await_suspended when devbox enters shutdown state""" + + mock_devbox_shutdown = DevboxView( + id="test_id", + status="shutdown", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + with patch.object(async_client.devboxes, "_post") as mock_post: + mock_post.return_value = mock_devbox_shutdown + + with pytest.raises(RunloopError, match="Devbox entered non-suspended terminal state: shutdown"): + await async_client.devboxes.await_suspended("test_id") + + @parametrize + async def test_method_await_suspended_timeout_handling(self, async_client: AsyncRunloop) -> None: + """Test await_suspended handles 408 timeouts correctly""" + + # Create a mock 408 response + mock_response = Mock() + mock_response.status_code = 408 + mock_408_error = APIStatusError("Request timeout", response=mock_response, body=None) + + mock_devbox_suspended = DevboxView( + id="test_id", + status="suspended", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + with patch.object(async_client.devboxes, "_post") as mock_post: + # First call raises 408, second call succeeds + mock_post.side_effect = [mock_408_error, mock_devbox_suspended] + + result = await async_client.devboxes.await_suspended("test_id") + + assert result.id == "test_id" + assert result.status == "suspended" + assert mock_post.call_count == 2 + + @parametrize + async def test_method_await_suspended_other_error(self, async_client: AsyncRunloop) -> None: + """Test await_suspended re-raises non-408 errors""" + + # Create a mock 500 response + mock_response = Mock() + mock_response.status_code = 500 + mock_500_error = APIStatusError("Internal server error", response=mock_response, body=None) + + with patch.object(async_client.devboxes, "_post") as mock_post: + mock_post.side_effect = mock_500_error + + with pytest.raises(APIStatusError, match="Internal server error"): + await async_client.devboxes.await_suspended("test_id") + + @parametrize + async def test_method_await_suspended_with_config(self, async_client: AsyncRunloop) -> None: + """Test await_suspended with custom polling configuration""" + + mock_devbox_suspended = DevboxView( + id="test_id", + status="suspended", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + config = PollingConfig(interval_seconds=0.1, max_attempts=10) + + with patch.object(async_client.devboxes, "_post") as mock_post: + mock_post.return_value = mock_devbox_suspended + + result = await async_client.devboxes.await_suspended("test_id", polling_config=config) + + assert result.id == "test_id" + assert result.status == "suspended" + + @parametrize + async def test_method_await_suspended_polling_timeout(self, async_client: AsyncRunloop) -> None: + """Test await_suspended raises PollingTimeout when max attempts exceeded""" + + mock_devbox_running = DevboxView( + id="test_id", + status="running", + capabilities=[], + create_time_ms=1234567890, + launch_parameters=LaunchParameters(resource_size_request="X_SMALL"), + metadata={}, + state_transitions=[], + ) + + config = PollingConfig(interval_seconds=0.01, max_attempts=2) + + with patch.object(async_client.devboxes, "_post") as mock_post: + mock_post.return_value = mock_devbox_running + + with pytest.raises(PollingTimeout): + await async_client.devboxes.await_suspended("test_id", polling_config=config) diff --git a/tests/smoketests/test_devboxes.py b/tests/smoketests/test_devboxes.py index b26207a41..c60b98c7b 100644 --- a/tests/smoketests/test_devboxes.py +++ b/tests/smoketests/test_devboxes.py @@ -86,3 +86,30 @@ def test_create_and_await_running_timeout(client: Runloop) -> None: launch_parameters={"launch_commands": ["sleep 70"]}, polling_config=PollingConfig(max_attempts=1, interval_seconds=0.1), ) + + +@pytest.mark.timeout(120) +def test_await_suspended(client: Runloop) -> None: + """Test await_suspended: create devbox, wait for running, suspend, then await suspended""" + created = client.devboxes.create_and_await_running( + name=unique_name("smoketest-devbox-await-suspended"), + polling_config=PollingConfig(max_attempts=120, interval_seconds=5.0, timeout_seconds=20 * 60), + ) + assert created.status == "running" + + # Suspend the devbox + suspended = client.devboxes.suspend(created.id) + assert suspended.id == created.id + + # Wait for suspended state + result = client.devboxes.await_suspended( + created.id, + polling_config=PollingConfig(max_attempts=60, interval_seconds=2.0, timeout_seconds=5 * 60), + ) + assert result.status == "suspended" + assert result.id == created.id + + # Cleanup + client.devboxes.shutdown(created.id) + + From 5fe9dd12d5caab94d904a13d23b9654cdbbbb92c Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 10 Nov 2025 16:11:39 -0800 Subject: [PATCH 07/56] added snapshot_disk methods to devbox classes --- src/runloop_api_client/sdk/async_devbox.py | 66 +++++++++++++++++++++- src/runloop_api_client/sdk/devbox.py | 66 +++++++++++++++++++++- 2 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index 4dc42764b..8250d6d12 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -121,8 +121,65 @@ async def suspend(self, **request_options: Any) -> Any: async def resume(self, **request_options: Any) -> Any: return await self._client.devboxes.resume(self._id, **request_options) - async def keep_alive(self, **request_options: Any) -> Any: - return await self._client.devboxes.keep_alive(self._id, **request_options) + async def snapshot_disk( + self, + *, + commit_message: str | None | Omit = omit, + metadata: dict[str, str] | None | Omit = omit, + name: str | None | Omit = omit, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> "AsyncSnapshot": + snapshot_data = await self._client.devboxes.snapshot_disk_async( + self._id, + commit_message=commit_message, + metadata=metadata, + name=name, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + snapshot = self._snapshot_from_id(snapshot_data.id) + await snapshot.await_completed( + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return snapshot + + async def snapshot_disk_async( + self, + *, + commit_message: str | None | Omit = omit, + metadata: dict[str, str] | None | Omit = omit, + name: str | None | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> "AsyncSnapshot": + snapshot_data = await self._client.devboxes.snapshot_disk_async( + self._id, + commit_message=commit_message, + metadata=metadata, + name=name, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return self._snapshot_from_id(snapshot_data.id) async def close(self) -> None: await self.shutdown() @@ -143,6 +200,11 @@ def net(self) -> "_AsyncNetworkInterface": # Internal helpers # ------------------------------------------------------------------ # + def _snapshot_from_id(self, snapshot_id: str) -> "AsyncSnapshot": + from .async_snapshot import AsyncSnapshot + + return AsyncSnapshot(self._client, snapshot_id) + def _start_streaming( self, execution_id: str, diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index 327333f38..cca0110a6 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -129,8 +129,65 @@ def suspend(self, **request_options: Any) -> Any: def resume(self, **request_options: Any) -> Any: return self._client.devboxes.resume(self._id, **request_options) - def keep_alive(self, **request_options: Any) -> Any: - return self._client.devboxes.keep_alive(self._id, **request_options) + def snapshot_disk( + self, + *, + commit_message: str | None | Omit = omit, + metadata: dict[str, str] | None | Omit = omit, + name: str | None | Omit = omit, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> "Snapshot": + snapshot_data = self._client.devboxes.snapshot_disk_async( + self._id, + commit_message=commit_message, + metadata=metadata, + name=name, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + snapshot = self._snapshot_from_id(snapshot_data.id) + snapshot.await_completed( + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return snapshot + + def snapshot_disk_async( + self, + *, + commit_message: str | None | Omit = omit, + metadata: dict[str, str] | None | Omit = omit, + name: str | None | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> "Snapshot": + snapshot_data = self._client.devboxes.snapshot_disk_async( + self._id, + commit_message=commit_message, + metadata=metadata, + name=name, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return self._snapshot_from_id(snapshot_data.id) def close(self) -> None: self.shutdown() @@ -151,6 +208,11 @@ def net(self) -> "_NetworkInterface": # Internal helpers # --------------------------------------------------------------------- # + def _snapshot_from_id(self, snapshot_id: str) -> "Snapshot": + from .snapshot import Snapshot + + return Snapshot(self._client, snapshot_id) + def _start_streaming( self, execution_id: str, From 6d5f1ebd9fd373b36e2a6eaf1a3c4bef10261039 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 10 Nov 2025 16:15:09 -0800 Subject: [PATCH 08/56] big refactor (moved client classes to _sync.py and _async.py, and moved shared methods/types to _helpers.py) --- src/runloop_api_client/sdk/__init__.py | 20 +- src/runloop_api_client/sdk/_async.py | 460 +++++++++++++++-- src/runloop_api_client/sdk/_helpers.py | 57 ++- src/runloop_api_client/sdk/_sync.py | 461 ++++++++++++++++-- src/runloop_api_client/sdk/async_blueprint.py | 140 ++++-- src/runloop_api_client/sdk/async_devbox.py | 420 +++++++++++----- src/runloop_api_client/sdk/async_execution.py | 38 +- src/runloop_api_client/sdk/async_snapshot.py | 153 ++++-- .../sdk/async_storage_object.py | 185 +++---- src/runloop_api_client/sdk/blueprint.py | 149 ++++-- src/runloop_api_client/sdk/devbox.py | 390 ++++++++++----- src/runloop_api_client/sdk/execution.py | 26 +- src/runloop_api_client/sdk/snapshot.py | 154 ++++-- src/runloop_api_client/sdk/storage_object.py | 247 ++++------ 14 files changed, 2156 insertions(+), 744 deletions(-) diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py index c647d9f3f..88679ac70 100644 --- a/src/runloop_api_client/sdk/__init__.py +++ b/src/runloop_api_client/sdk/__init__.py @@ -1,18 +1,18 @@ from __future__ import annotations -from ._sync import RunloopSDK -from ._async import AsyncRunloopSDK -from .devbox import Devbox, DevboxClient -from .snapshot import Snapshot, SnapshotClient -from .blueprint import Blueprint, BlueprintClient +from ._sync import RunloopSDK, DevboxClient, SnapshotClient, BlueprintClient, StorageObjectClient +from ._async import AsyncRunloopSDK, AsyncDevboxClient, AsyncSnapshotClient, AsyncBlueprintClient, AsyncStorageObjectClient +from .devbox import Devbox +from .snapshot import Snapshot +from .blueprint import Blueprint from .execution import Execution -from .async_devbox import AsyncDevbox, AsyncDevboxClient -from .async_snapshot import AsyncSnapshot, AsyncSnapshotClient -from .storage_object import StorageObject, StorageObjectClient -from .async_blueprint import AsyncBlueprint, AsyncBlueprintClient +from .async_devbox import AsyncDevbox +from .async_snapshot import AsyncSnapshot +from .storage_object import StorageObject +from .async_blueprint import AsyncBlueprint from .async_execution import AsyncExecution from .execution_result import ExecutionResult -from .async_storage_object import AsyncStorageObject, AsyncStorageObjectClient +from .async_storage_object import AsyncStorageObject from .async_execution_result import AsyncExecutionResult __all__ = [ diff --git a/src/runloop_api_client/sdk/_async.py b/src/runloop_api_client/sdk/_async.py index ace3798e2..7e8dc0885 100644 --- a/src/runloop_api_client/sdk/_async.py +++ b/src/runloop_api_client/sdk/_async.py @@ -1,15 +1,412 @@ from __future__ import annotations -from typing import Mapping +from pathlib import Path +from typing import Any, Dict, Iterable, Literal, Mapping, Optional import httpx -from .._types import Timeout, NotGiven, not_given from .._client import AsyncRunloop -from .async_devbox import AsyncDevboxClient -from .async_snapshot import AsyncSnapshotClient -from .async_blueprint import AsyncBlueprintClient -from .async_storage_object import AsyncStorageObjectClient +from .._types import Body, Headers, NotGiven, NOT_GIVEN, Omit, Query, SequenceNotStr, Timeout, not_given, omit +from ..lib.polling import PollingConfig +from ..types.shared_params.code_mount_parameters import CodeMountParameters +from ..types.shared_params.launch_parameters import LaunchParameters +from .async_devbox import AsyncDevbox +from .async_snapshot import AsyncSnapshot +from .async_blueprint import AsyncBlueprint +from .async_storage_object import AsyncStorageObject +from ._helpers import ContentType, detect_content_type + + +class AsyncDevboxClient: + """Async manager for :class:`AsyncDevbox` wrappers.""" + + def __init__(self, client: AsyncRunloop) -> None: + self._client = client + + async def create( + self, + *, + blueprint_id: Optional[str] | NotGiven = NOT_GIVEN, + blueprint_name: Optional[str] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + entrypoint: Optional[str] | NotGiven = NOT_GIVEN, + environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, + repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, + secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + snapshot_id: Optional[str] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> AsyncDevbox: + devbox_view = await self._client.devboxes.create_and_await_running( + blueprint_id=blueprint_id, + blueprint_name=blueprint_name, + code_mounts=code_mounts, + entrypoint=entrypoint, + environment_variables=environment_variables, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + metadata=metadata, + name=name, + repo_connection_id=repo_connection_id, + secrets=secrets, + snapshot_id=snapshot_id, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return AsyncDevbox(self._client, devbox_view.id) + + async def create_from_blueprint_id( + self, + blueprint_id: str, + *, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + entrypoint: Optional[str] | NotGiven = NOT_GIVEN, + environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, + repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, + secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> AsyncDevbox: + devbox_view = await self._client.devboxes.create_and_await_running( + blueprint_id=blueprint_id, + code_mounts=code_mounts, + entrypoint=entrypoint, + environment_variables=environment_variables, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + metadata=metadata, + name=name, + repo_connection_id=repo_connection_id, + secrets=secrets, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return AsyncDevbox(self._client, devbox_view.id) + + async def create_from_blueprint_name( + self, + blueprint_name: str, + *, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + entrypoint: Optional[str] | NotGiven = NOT_GIVEN, + environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, + repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, + secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> AsyncDevbox: + devbox_view = await self._client.devboxes.create_and_await_running( + blueprint_name=blueprint_name, + code_mounts=code_mounts, + entrypoint=entrypoint, + environment_variables=environment_variables, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + metadata=metadata, + name=name, + repo_connection_id=repo_connection_id, + secrets=secrets, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return AsyncDevbox(self._client, devbox_view.id) + + async def create_from_snapshot( + self, + snapshot_id: str, + *, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + entrypoint: Optional[str] | NotGiven = NOT_GIVEN, + environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, + repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, + secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> AsyncDevbox: + devbox_view = await self._client.devboxes.create_and_await_running( + snapshot_id=snapshot_id, + code_mounts=code_mounts, + entrypoint=entrypoint, + environment_variables=environment_variables, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + metadata=metadata, + name=name, + repo_connection_id=repo_connection_id, + secrets=secrets, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return AsyncDevbox(self._client, devbox_view.id) + + def from_id(self, devbox_id: str) -> AsyncDevbox: + return AsyncDevbox(self._client, devbox_id) + + async def list( + self, + *, + limit: int | Omit = omit, + starting_after: str | Omit = omit, + status: Literal[ + "provisioning", "initializing", "running", "suspending", "suspended", "resuming", "failure", "shutdown" + ] + | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> list[AsyncDevbox]: + page = await self._client.devboxes.list( + limit=limit, + starting_after=starting_after, + status=status, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return [AsyncDevbox(self._client, item.id) for item in getattr(page, "devboxes", [])] + + +class AsyncSnapshotClient: + """Async manager for :class:`AsyncSnapshot` wrappers.""" + + def __init__(self, client: AsyncRunloop) -> None: + self._client = client + + async def list( + self, + *, + devbox_id: str | Omit = omit, + limit: int | Omit = omit, + metadata_key: str | Omit = omit, + metadata_key_in: str | Omit = omit, + starting_after: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> list[AsyncSnapshot]: + page = await self._client.devboxes.disk_snapshots.list( + devbox_id=devbox_id, + limit=limit, + metadata_key=metadata_key, + metadata_key_in=metadata_key_in, + starting_after=starting_after, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return [AsyncSnapshot(self._client, item.id) for item in getattr(page, "disk_snapshots", [])] + + def from_id(self, snapshot_id: str) -> AsyncSnapshot: + return AsyncSnapshot(self._client, snapshot_id) + + +class AsyncBlueprintClient: + """Async manager for :class:`AsyncBlueprint` wrappers.""" + + def __init__(self, client: AsyncRunloop) -> None: + self._client = client + + async def create( + self, + *, + name: str, + base_blueprint_id: Optional[str] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + dockerfile: Optional[str] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + services: Optional[Iterable[Any]] | NotGiven = NOT_GIVEN, + system_setup_commands: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> AsyncBlueprint: + blueprint = await self._client.blueprints.create_and_await_build_complete( + name=name, + base_blueprint_id=base_blueprint_id, + code_mounts=code_mounts, + dockerfile=dockerfile, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + services=services, + system_setup_commands=system_setup_commands, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return AsyncBlueprint(self._client, blueprint.id) + + def from_id(self, blueprint_id: str) -> AsyncBlueprint: + return AsyncBlueprint(self._client, blueprint_id) + + async def list( + self, + *, + limit: int | Omit = omit, + name: str | Omit = omit, + starting_after: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> list[AsyncBlueprint]: + page = await self._client.blueprints.list( + limit=limit, + name=name, + starting_after=starting_after, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return [AsyncBlueprint(self._client, item.id) for item in getattr(page, "blueprints", [])] + + +class AsyncStorageObjectClient: + """Async manager for :class:`AsyncStorageObject` wrappers.""" + + def __init__(self, client: AsyncRunloop) -> None: + self._client = client + + async def create( + self, + name: str, + *, + content_type: ContentType | None = None, + metadata: Optional[Dict[str, str]] = None, + ) -> AsyncStorageObject: + content_type = content_type or detect_content_type(name) + obj = await self._client.objects.create(name=name, content_type=content_type, metadata=metadata) + return AsyncStorageObject(self._client, obj.id, upload_url=obj.upload_url) + + def from_id(self, object_id: str) -> AsyncStorageObject: + return AsyncStorageObject(self._client, object_id, upload_url=None) + + async def list( + self, + *, + content_type: str | Omit = omit, + limit: int | Omit = omit, + name: str | Omit = omit, + search: str | Omit = omit, + starting_after: str | Omit = omit, + state: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> list[AsyncStorageObject]: + page = await self._client.objects.list( + content_type=content_type, + limit=limit, + name=name, + search=search, + starting_after=starting_after, + state=state, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return [AsyncStorageObject(self._client, item.id, upload_url=None) for item in getattr(page, "objects", [])] + + async def upload_from_file( + self, + path: str | Path, + name: str | None = None, + *, + metadata: Optional[Dict[str, str]] = None, + content_type: ContentType | None = None, + ) -> AsyncStorageObject: + file_path = Path(path) + object_name = name or file_path.name + obj = await self.create(object_name, content_type=content_type, metadata=metadata) + await obj.upload_content(file_path) + await obj.complete() + return obj + + async def upload_from_text( + self, + text: str, + name: str, + *, + metadata: Optional[Dict[str, str]] = None, + ) -> AsyncStorageObject: + obj = await self.create(name, content_type="text", metadata=metadata) + await obj.upload_content(text) + await obj.complete() + return obj + + async def upload_from_bytes( + self, + data: bytes, + name: str, + *, + metadata: Optional[Dict[str, str]] = None, + content_type: ContentType | None = None, + ) -> AsyncStorageObject: + obj = await self.create(name, content_type=content_type or detect_content_type(name), metadata=metadata) + await obj.upload_content(data) + await obj.complete() + return obj class AsyncRunloopSDK: @@ -29,7 +426,6 @@ class AsyncRunloopSDK: def __init__( self, *, - client: AsyncRunloop | None = None, bearer_token: str | None = None, base_url: str | httpx.URL | None = None, timeout: float | Timeout | None | NotGiven = not_given, @@ -39,39 +435,35 @@ def __init__( http_client: httpx.AsyncClient | None = None, _strict_response_validation: bool = False, ) -> None: - """ - Create an asynchronous Runloop SDK instance. - - Arguments mirror :class:`runloop_api_client.AsyncRunloop`. - """ - if client is None: - runloop_kwargs: dict[str, object] = { - "bearer_token": bearer_token, - "base_url": base_url, - "timeout": timeout, - "default_headers": default_headers, - "default_query": default_query, - "http_client": http_client, - "_strict_response_validation": _strict_response_validation, - } - if max_retries is not None: - runloop_kwargs["max_retries"] = max_retries - - self.api = AsyncRunloop(**runloop_kwargs) - self._owns_client = True + if max_retries is None: + self.api = AsyncRunloop( + bearer_token=bearer_token, + base_url=base_url, + timeout=timeout, + default_headers=default_headers, + default_query=default_query, + http_client=http_client, + _strict_response_validation=_strict_response_validation, + ) else: - self.api = client - self._owns_client = False + self.api = AsyncRunloop( + bearer_token=bearer_token, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + default_headers=default_headers, + default_query=default_query, + http_client=http_client, + _strict_response_validation=_strict_response_validation, + ) self.devbox = AsyncDevboxClient(self.api) - self.blueprint = AsyncBlueprintClient(self.api, self.devbox) - self.snapshot = AsyncSnapshotClient(self.api, self.devbox) + self.blueprint = AsyncBlueprintClient(self.api) + self.snapshot = AsyncSnapshotClient(self.api) self.storage_object = AsyncStorageObjectClient(self.api) async def aclose(self) -> None: - """Close the underlying async HTTP client.""" - if self._owns_client: - await self.api.close() + await self.api.close() async def __aenter__(self) -> "AsyncRunloopSDK": return self diff --git a/src/runloop_api_client/sdk/_helpers.py b/src/runloop_api_client/sdk/_helpers.py index ee99dbf0d..ac50e0648 100644 --- a/src/runloop_api_client/sdk/_helpers.py +++ b/src/runloop_api_client/sdk/_helpers.py @@ -2,14 +2,64 @@ import io import os -from typing import Union from pathlib import Path +from typing import IO, Callable, Dict, Literal, Union, cast from .._types import FileTypes from .._utils import file_from_path +LogCallback = Callable[[str], None] UploadInput = Union[FileTypes, str, os.PathLike[str], Path, bytes, bytearray, io.IOBase] +ContentType = Literal["unspecified", "text", "binary", "gzip", "tar", "tgz"] +UploadData = Union[str, bytes, bytearray, Path, os.PathLike[str], io.IOBase] + +_CONTENT_TYPE_MAP: Dict[str, ContentType] = { + ".txt": "text", + ".html": "text", + ".css": "text", + ".js": "text", + ".json": "text", + ".xml": "text", + ".yaml": "text", + ".yml": "text", + ".md": "text", + ".csv": "text", + ".gz": "gzip", + ".tar": "tar", + ".tgz": "tgz", + ".tar.gz": "tgz", +} + + +def detect_content_type(name: str) -> ContentType: + lower = name.lower() + if lower.endswith(".tar.gz") or lower.endswith(".tgz"): + return "tgz" + ext = Path(lower).suffix + return _CONTENT_TYPE_MAP.get(ext, "unspecified") + + +def read_upload_data(data: UploadData) -> bytes: + if isinstance(data, bytes): + return data + if isinstance(data, bytearray): + return bytes(data) + if isinstance(data, (Path, os.PathLike)): + return Path(data).read_bytes() + if isinstance(data, str): + return data.encode("utf-8") + if isinstance(data, io.TextIOBase): + return data.read().encode("utf-8") + if isinstance(data, io.BufferedIOBase) or isinstance(data, io.RawIOBase): + return data.read() + if hasattr(data, "read"): + result = data.read() + if isinstance(result, str): + return result.encode("utf-8") + return result + raise TypeError("Unsupported upload data type. Provide str, bytes, path, or file-like object.") + def normalize_upload_input(file: UploadInput) -> FileTypes: """ @@ -22,11 +72,12 @@ def normalize_upload_input(file: UploadInput) -> FileTypes: if isinstance(file, bytearray): return bytes(file) if isinstance(file, (str, Path, os.PathLike)): - return file_from_path(file) + path_str = str(file) + return file_from_path(path_str) if isinstance(file, io.TextIOBase): return file.read().encode("utf-8") if isinstance(file, io.BufferedIOBase) or isinstance(file, io.RawIOBase): - return file + return cast(IO[bytes], file) if isinstance(file, io.IOBase) and hasattr(file, "read"): data = file.read() if isinstance(data, str): diff --git a/src/runloop_api_client/sdk/_sync.py b/src/runloop_api_client/sdk/_sync.py index 43ca73a7a..ae8cbb574 100644 --- a/src/runloop_api_client/sdk/_sync.py +++ b/src/runloop_api_client/sdk/_sync.py @@ -1,15 +1,413 @@ from __future__ import annotations -from typing import Mapping +from pathlib import Path +from typing import Any, Dict, Iterable, Literal, Mapping, Optional import httpx -from .devbox import DevboxClient -from .._types import Timeout, NotGiven, not_given from .._client import Runloop -from .snapshot import SnapshotClient -from .blueprint import BlueprintClient -from .storage_object import StorageObjectClient +from .._types import Body, Headers, NotGiven, NOT_GIVEN, Omit, Query, SequenceNotStr, Timeout, not_given, omit +from ..lib.polling import PollingConfig +from ..types.shared_params.code_mount_parameters import CodeMountParameters +from ..types.shared_params.launch_parameters import LaunchParameters +from .devbox import Devbox +from .snapshot import Snapshot +from .blueprint import Blueprint +from .storage_object import StorageObject +from ._helpers import ContentType, detect_content_type + + +class DevboxClient: + """High-level manager for :class:`Devbox` wrappers.""" + + def __init__(self, client: Runloop) -> None: + self._client = client + + def create( + self, + *, + blueprint_id: Optional[str] | NotGiven = NOT_GIVEN, + blueprint_name: Optional[str] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + entrypoint: Optional[str] | NotGiven = NOT_GIVEN, + environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, + repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, + secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + snapshot_id: Optional[str] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Devbox: + devbox_view = self._client.devboxes.create_and_await_running( + blueprint_id=blueprint_id, + blueprint_name=blueprint_name, + code_mounts=code_mounts, + entrypoint=entrypoint, + environment_variables=environment_variables, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + metadata=metadata, + name=name, + repo_connection_id=repo_connection_id, + secrets=secrets, + snapshot_id=snapshot_id, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return Devbox(self._client, devbox_view.id) + + def create_from_blueprint_id( + self, + blueprint_id: str, + *, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + entrypoint: Optional[str] | NotGiven = NOT_GIVEN, + environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, + repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, + secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Devbox: + devbox_view = self._client.devboxes.create_and_await_running( + blueprint_id=blueprint_id, + code_mounts=code_mounts, + entrypoint=entrypoint, + environment_variables=environment_variables, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + metadata=metadata, + name=name, + repo_connection_id=repo_connection_id, + secrets=secrets, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return Devbox(self._client, devbox_view.id) + + def create_from_blueprint_name( + self, + blueprint_name: str, + *, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + entrypoint: Optional[str] | NotGiven = NOT_GIVEN, + environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, + repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, + secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Devbox: + devbox_view = self._client.devboxes.create_and_await_running( + blueprint_name=blueprint_name, + code_mounts=code_mounts, + entrypoint=entrypoint, + environment_variables=environment_variables, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + metadata=metadata, + name=name, + repo_connection_id=repo_connection_id, + secrets=secrets, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return Devbox(self._client, devbox_view.id) + + def create_from_snapshot( + self, + snapshot_id: str, + *, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + entrypoint: Optional[str] | NotGiven = NOT_GIVEN, + environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, + repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, + secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Devbox: + devbox_view = self._client.devboxes.create_and_await_running( + snapshot_id=snapshot_id, + code_mounts=code_mounts, + entrypoint=entrypoint, + environment_variables=environment_variables, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + metadata=metadata, + name=name, + repo_connection_id=repo_connection_id, + secrets=secrets, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return Devbox(self._client, devbox_view.id) + + def from_id(self, devbox_id: str) -> Devbox: + self._client.devboxes.await_running(devbox_id) + return Devbox(self._client, devbox_id) + + def list( + self, + *, + limit: int | Omit = omit, + starting_after: str | Omit = omit, + status: Literal[ + "provisioning", "initializing", "running", "suspending", "suspended", "resuming", "failure", "shutdown" + ] + | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> list[Devbox]: + page = self._client.devboxes.list( + limit=limit, + starting_after=starting_after, + status=status, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return [Devbox(self._client, item.id) for item in getattr(page, "devboxes", [])] + + +class SnapshotClient: + """Manager for :class:`Snapshot` wrappers.""" + + def __init__(self, client: Runloop) -> None: + self._client = client + + def list( + self, + *, + devbox_id: str | Omit = omit, + limit: int | Omit = omit, + metadata_key: str | Omit = omit, + metadata_key_in: str | Omit = omit, + starting_after: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> list[Snapshot]: + page = self._client.devboxes.disk_snapshots.list( + devbox_id=devbox_id, + limit=limit, + metadata_key=metadata_key, + metadata_key_in=metadata_key_in, + starting_after=starting_after, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return [Snapshot(self._client, item.id) for item in getattr(page, "disk_snapshots", [])] + + def from_id(self, snapshot_id: str) -> Snapshot: + return Snapshot(self._client, snapshot_id) + + +class BlueprintClient: + """Manager for :class:`Blueprint` wrappers.""" + + def __init__(self, client: Runloop) -> None: + self._client = client + + def create( + self, + *, + name: str, + base_blueprint_id: Optional[str] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + dockerfile: Optional[str] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + services: Optional[Iterable[Any]] | NotGiven = NOT_GIVEN, + system_setup_commands: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Blueprint: + blueprint = self._client.blueprints.create_and_await_build_complete( + name=name, + base_blueprint_id=base_blueprint_id, + code_mounts=code_mounts, + dockerfile=dockerfile, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + services=services, + system_setup_commands=system_setup_commands, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return Blueprint(self._client, blueprint.id) + + def from_id(self, blueprint_id: str) -> Blueprint: + return Blueprint(self._client, blueprint_id) + + def list( + self, + *, + limit: int | Omit = omit, + name: str | Omit = omit, + starting_after: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> list[Blueprint]: + page = self._client.blueprints.list( + limit=limit, + name=name, + starting_after=starting_after, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return [Blueprint(self._client, item.id) for item in getattr(page, "blueprints", [])] + + +class StorageObjectClient: + """Manager for :class:`StorageObject` wrappers and upload helpers.""" + + def __init__(self, client: Runloop) -> None: + self._client = client + + def create( + self, + name: str, + *, + content_type: ContentType | None = None, + metadata: Optional[Dict[str, str]] = None, + ) -> StorageObject: + content_type = content_type or detect_content_type(name) + obj = self._client.objects.create(name=name, content_type=content_type, metadata=metadata) + return StorageObject(self._client, obj.id, upload_url=obj.upload_url) + + def from_id(self, object_id: str) -> StorageObject: + return StorageObject(self._client, object_id, upload_url=None) + + def list( + self, + *, + content_type: str | Omit = omit, + limit: int | Omit = omit, + name: str | Omit = omit, + search: str | Omit = omit, + starting_after: str | Omit = omit, + state: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> list[StorageObject]: + page = self._client.objects.list( + content_type=content_type, + limit=limit, + name=name, + search=search, + starting_after=starting_after, + state=state, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return [StorageObject(self._client, item.id, upload_url=None) for item in getattr(page, "objects", [])] + + def upload_from_file( + self, + path: str | Path, + name: str | None = None, + *, + metadata: Optional[Dict[str, str]] = None, + content_type: ContentType | None = None, + ) -> StorageObject: + file_path = Path(path) + object_name = name or file_path.name + obj = self.create(object_name, content_type=content_type, metadata=metadata) + obj.upload_content(file_path) + obj.complete() + return obj + + def upload_from_text( + self, + text: str, + name: str, + *, + metadata: Optional[Dict[str, str]] = None, + ) -> StorageObject: + obj = self.create(name, content_type="text", metadata=metadata) + obj.upload_content(text) + obj.complete() + return obj + + def upload_from_bytes( + self, + data: bytes, + name: str, + *, + metadata: Optional[Dict[str, str]] = None, + content_type: ContentType | None = None, + ) -> StorageObject: + obj = self.create(name, content_type=content_type or detect_content_type(name), metadata=metadata) + obj.upload_content(data) + obj.complete() + return obj class RunloopSDK: @@ -29,7 +427,6 @@ class RunloopSDK: def __init__( self, *, - client: Runloop | None = None, bearer_token: str | None = None, base_url: str | httpx.URL | None = None, timeout: float | Timeout | None | NotGiven = not_given, @@ -39,39 +436,35 @@ def __init__( http_client: httpx.Client | None = None, _strict_response_validation: bool = False, ) -> None: - """ - Create a synchronous Runloop SDK instance. - - Arguments mirror :class:`runloop_api_client.Runloop`. Additional high-level helpers - are exposed as attributes on this class as they're implemented. - """ - if client is None: - runloop_kwargs: dict[str, object] = { - "bearer_token": bearer_token, - "base_url": base_url, - "timeout": timeout, - "max_retries": max_retries, - "default_headers": default_headers, - "default_query": default_query, - "http_client": http_client, - "_strict_response_validation": _strict_response_validation, - } - - self.api = Runloop(**runloop_kwargs) - self._owns_client = True + if max_retries is None: + self.api = Runloop( + bearer_token=bearer_token, + base_url=base_url, + timeout=timeout, + default_headers=default_headers, + default_query=default_query, + http_client=http_client, + _strict_response_validation=_strict_response_validation, + ) else: - self.api = client - self._owns_client = False + self.api = Runloop( + bearer_token=bearer_token, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + default_headers=default_headers, + default_query=default_query, + http_client=http_client, + _strict_response_validation=_strict_response_validation, + ) self.devbox = DevboxClient(self.api) - self.blueprint = BlueprintClient(self.api, self.devbox) - self.snapshot = SnapshotClient(self.api, self.devbox) + self.blueprint = BlueprintClient(self.api) + self.snapshot = SnapshotClient(self.api) self.storage_object = StorageObjectClient(self.api) def close(self) -> None: - """Close the underlying HTTP client.""" - if self._owns_client: - self.api.close() + self.api.close() def __enter__(self) -> "RunloopSDK": return self diff --git a/src/runloop_api_client/sdk/async_blueprint.py b/src/runloop_api_client/sdk/async_blueprint.py index abee97e7d..479529e74 100644 --- a/src/runloop_api_client/sdk/async_blueprint.py +++ b/src/runloop_api_client/sdk/async_blueprint.py @@ -1,51 +1,32 @@ from __future__ import annotations -from typing import Any, List +from typing import Any, Dict, Iterable, Optional + +from typing_extensions import override from .._client import AsyncRunloop from ..lib.polling import PollingConfig -from .async_devbox import AsyncDevbox, AsyncDevboxClient +from .._types import Body, Headers, NotGiven, NOT_GIVEN, Query, Timeout, not_given +from ..types.shared_params.code_mount_parameters import CodeMountParameters +from ..types.shared_params.launch_parameters import LaunchParameters +from .async_devbox import AsyncDevbox from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView -class AsyncBlueprintClient: - """ - Manage :class:`AsyncBlueprint` objects through the async SDK. - """ - - def __init__(self, client: AsyncRunloop, devbox_client: AsyncDevboxClient) -> None: - self._client = client - self._devbox_client = devbox_client - - async def create(self, *, polling_config: PollingConfig | None = None, **params: Any) -> "AsyncBlueprint": - params = dict(params) - if polling_config is None: - polling_config = params.pop("polling_config", None) - - blueprint = await self._client.blueprints.create_and_await_build_complete( - polling_config=polling_config, - **params, - ) - return AsyncBlueprint(self._client, blueprint.id, self._devbox_client) - - def from_id(self, blueprint_id: str) -> "AsyncBlueprint": - return AsyncBlueprint(self._client, blueprint_id, self._devbox_client) - - async def list(self, **params: Any) -> List["AsyncBlueprint"]: - page = await self._client.blueprints.list(**params) - return [AsyncBlueprint(self._client, item.id, self._devbox_client) for item in getattr(page, "blueprints", [])] - - class AsyncBlueprint: """ Async wrapper around blueprint operations. """ - def __init__(self, client: AsyncRunloop, blueprint_id: str, devbox_client: AsyncDevboxClient) -> None: + def __init__( + self, + client: AsyncRunloop, + blueprint_id: str, + ) -> None: self._client = client self._id = blueprint_id - self._devbox_client = devbox_client + @override def __repr__(self) -> str: return f"" @@ -53,16 +34,91 @@ def __repr__(self) -> str: def id(self) -> str: return self._id - async def get_info(self, **request_options: Any) -> Any: - return await self._client.blueprints.retrieve(self._id, **request_options) + async def get_info( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> Any: + return await self._client.blueprints.retrieve( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) - async def logs(self, **request_options: Any) -> BlueprintBuildLogsListView: - return await self._client.blueprints.logs(self._id, **request_options) + async def logs( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> BlueprintBuildLogsListView: + return await self._client.blueprints.logs( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) - async def delete(self, **request_options: Any) -> Any: - return await self._client.blueprints.delete(self._id, **request_options) + async def delete( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> Any: + return await self._client.blueprints.delete( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) - async def create_devbox(self, *, polling_config: PollingConfig | None = None, **params: Any) -> AsyncDevbox: - params = dict(params) - params["blueprint_id"] = self._id - return await self._devbox_client.create(polling_config=polling_config, **params) + async def create_devbox( + self, + *, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + entrypoint: Optional[str] | NotGiven = NOT_GIVEN, + environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, + repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, + secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> AsyncDevbox: + from ._async import AsyncDevboxClient + + devbox_client = AsyncDevboxClient(self._client) + return await devbox_client.create_from_blueprint_id( + self._id, + code_mounts=code_mounts, + entrypoint=entrypoint, + environment_variables=environment_variables, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + metadata=metadata, + name=name, + repo_connection_id=repo_connection_id, + secrets=secrets, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index 8250d6d12..d1615383b 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -1,80 +1,26 @@ from __future__ import annotations import asyncio -import inspect import logging -from typing import Any, Union, Callable, Optional, Sequence, Awaitable +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Sequence, cast -from .._types import not_given +from typing_extensions import override + +from .._types import Body, Headers, NotGiven, Omit, Query, Timeout, not_given, omit from .._client import AsyncRunloop -from ._helpers import UploadInput, normalize_upload_input +from ._helpers import LogCallback, UploadInput, normalize_upload_input from .._streaming import AsyncStream from ..lib.polling import PollingConfig from .async_execution import AsyncExecution, _AsyncStreamingGroup from .async_execution_result import AsyncExecutionResult +from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView from ..types.devboxes.execution_update_chunk import ExecutionUpdateChunk -AsyncCallback = Callable[[str], Union[Awaitable[None], None]] - - -class AsyncDevboxClient: - """ - High-level manager for creating and retrieving :class:`AsyncDevbox` instances. - """ - - def __init__(self, client: AsyncRunloop) -> None: - self._client = client - - async def create(self, *, polling_config: PollingConfig | None = None, **params: Any) -> "AsyncDevbox": - params = dict(params) - if polling_config is None: - polling_config = params.pop("polling_config", None) - - devbox_view = await self._client.devboxes.create_and_await_running( - polling_config=polling_config, - **params, - ) - return AsyncDevbox(self._client, devbox_view.id) - - async def create_from_blueprint_id( - self, - blueprint_id: str, - *, - polling_config: PollingConfig | None = None, - **params: Any, - ) -> "AsyncDevbox": - params = dict(params) - params["blueprint_id"] = blueprint_id - return await self.create(polling_config=polling_config, **params) - - async def create_from_blueprint_name( - self, - blueprint_name: str, - *, - polling_config: PollingConfig | None = None, - **params: Any, - ) -> "AsyncDevbox": - params = dict(params) - params["blueprint_name"] = blueprint_name - return await self.create(polling_config=polling_config, **params) - - async def create_from_snapshot( - self, - snapshot_id: str, - *, - polling_config: PollingConfig | None = None, - **params: Any, - ) -> "AsyncDevbox": - params = dict(params) - params["snapshot_id"] = snapshot_id - return await self.create(polling_config=polling_config, **params) - def from_id(self, devbox_id: str) -> "AsyncDevbox": - return AsyncDevbox(self._client, devbox_id) +StreamFactory = Callable[[], Awaitable[AsyncStream[ExecutionUpdateChunk]]] - async def list(self, **params: Any) -> list["AsyncDevbox"]: - page = await self._client.devboxes.list(**params) - return [AsyncDevbox(self._client, item.id) for item in getattr(page, "devboxes", [])] +if TYPE_CHECKING: + from .async_snapshot import AsyncSnapshot class AsyncDevbox: @@ -87,6 +33,7 @@ def __init__(self, client: AsyncRunloop, devbox_id: str) -> None: self._id = devbox_id self._logger = logging.getLogger(__name__) + @override def __repr__(self) -> str: return f"" @@ -103,8 +50,21 @@ async def __aexit__(self, exc_type: type[BaseException] | None, exc: BaseExcepti def id(self) -> str: return self._id - async def get_info(self, **request_options: Any) -> Any: - return await self._client.devboxes.retrieve(self._id, **request_options) + async def get_info( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> Any: + return await self._client.devboxes.retrieve( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) async def await_running(self, *, polling_config: PollingConfig | None = None) -> Any: return await self._client.devboxes.await_running(self._id, polling_config=polling_config) @@ -112,14 +72,87 @@ async def await_running(self, *, polling_config: PollingConfig | None = None) -> async def await_suspended(self, *, polling_config: PollingConfig | None = None) -> Any: return await self._client.devboxes.await_suspended(self._id, polling_config=polling_config) - async def shutdown(self, **request_options: Any) -> Any: - return await self._client.devboxes.shutdown(self._id, **request_options) + async def shutdown( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return await self._client.devboxes.shutdown( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + + async def suspend( + self, + *, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + await self._client.devboxes.suspend( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return await self._client.devboxes.await_suspended( + self._id, + polling_config=polling_config, + ) - async def suspend(self, **request_options: Any) -> Any: - return await self._client.devboxes.suspend(self._id, **request_options) + async def resume( + self, + *, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + await self._client.devboxes.resume( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return await self._client.devboxes.await_running( + self._id, + polling_config=polling_config, + ) - async def resume(self, **request_options: Any) -> Any: - return await self._client.devboxes.resume(self._id, **request_options) + async def keep_alive( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return await self._client.devboxes.keep_alive( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) async def snapshot_disk( self, @@ -209,9 +242,9 @@ def _start_streaming( self, execution_id: str, *, - stdout: AsyncCallback | None, - stderr: AsyncCallback | None, - output: AsyncCallback | None, + stdout: LogCallback | None, + stderr: LogCallback | None, + output: LogCallback | None, ) -> Optional[_AsyncStreamingGroup]: tasks: list[asyncio.Task[None]] = [] @@ -254,19 +287,18 @@ async def _stream_worker( self, *, name: str, - stream_factory: Callable[[], AsyncStream[ExecutionUpdateChunk]], - callbacks: Sequence[AsyncCallback], + stream_factory: StreamFactory, + callbacks: Sequence[LogCallback], ) -> None: logger = self._logger try: - async with stream_factory() as stream: + stream = await stream_factory() + async with stream: async for chunk in stream: text = getattr(chunk, "output", "") for callback in callbacks: try: - result = callback(text) - if inspect.isawaitable(result): - await result # type: ignore[arg-type] + callback(text) except Exception: logger.exception("error in async %s callback for devbox %s", name, self._id) except asyncio.CancelledError: @@ -284,24 +316,31 @@ async def exec( command: str, *, shell_name: str | None = None, - stdout: AsyncCallback | None = None, - stderr: AsyncCallback | None = None, - output: AsyncCallback | None = None, + stdout: LogCallback | None = None, + stderr: LogCallback | None = None, + output: LogCallback | None = None, polling_config: PollingConfig | None = None, - **request_options: Any, + attach_stdin: bool | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, ) -> AsyncExecutionResult: devbox = self._devbox client = devbox._client - request_options = dict(request_options) - if "shell_name" in request_options: - shell_name = request_options.pop("shell_name") if stdout or stderr or output: - execution = await client.devboxes.execute_async( + execution: DevboxAsyncExecutionDetailView = await client.devboxes.execute_async( devbox.id, command=command, - shell_name=shell_name, - **request_options, + shell_name=shell_name if shell_name is not None else omit, + attach_stdin=attach_stdin, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) streaming_group = devbox._start_streaming( execution.execution_id, @@ -309,31 +348,42 @@ async def exec( stderr=stderr, output=output, ) - try: + + async def command_coro() -> DevboxAsyncExecutionDetailView: if execution.status == "completed": - final = execution - else: - final = await client.devboxes.executions.await_completed( - execution.execution_id, - devbox_id=devbox.id, - polling_config=polling_config, - ) - except Exception: + return execution + return await client.devboxes.executions.await_completed( + execution.execution_id, + devbox_id=devbox.id, + polling_config=polling_config, + ) + + awaitables: list[Awaitable[DevboxAsyncExecutionDetailView | None]] = [command_coro()] + if streaming_group is not None: + awaitables.append(streaming_group.wait()) + + results = await asyncio.gather(*awaitables, return_exceptions=True) + command_result = results[0] + + if isinstance(command_result, Exception): if streaming_group is not None: await streaming_group.cancel() - raise - else: - if streaming_group is not None: - await streaming_group.wait() + raise command_result - return AsyncExecutionResult(client, devbox.id, final) + # Streaming finishes asynchronously via the shared gather call; nothing more to do here. + command_value = cast(DevboxAsyncExecutionDetailView, command_result) + return AsyncExecutionResult(client, devbox.id, command_value) final = await client.devboxes.execute_and_await_completion( devbox.id, command=command, shell_name=shell_name if shell_name is not None else not_given, polling_config=polling_config, - **request_options, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) return AsyncExecutionResult(client, devbox.id, final) @@ -342,22 +392,29 @@ async def exec_async( command: str, *, shell_name: str | None = None, - stdout: AsyncCallback | None = None, - stderr: AsyncCallback | None = None, - output: AsyncCallback | None = None, - **request_options: Any, + stdout: LogCallback | None = None, + stderr: LogCallback | None = None, + output: LogCallback | None = None, + attach_stdin: bool | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, ) -> AsyncExecution: devbox = self._devbox client = devbox._client - request_options = dict(request_options) - if "shell_name" in request_options: - shell_name = request_options.pop("shell_name") - execution = await client.devboxes.execute_async( + execution: DevboxAsyncExecutionDetailView = await client.devboxes.execute_async( devbox.id, command=command, - shell_name=shell_name, - **request_options, + shell_name=shell_name if shell_name is not None else omit, + attach_stdin=attach_stdin, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) streaming_group = devbox._start_streaming( @@ -374,12 +431,37 @@ class _AsyncFileInterface: def __init__(self, devbox: AsyncDevbox) -> None: self._devbox = devbox - async def read(self, path: str, **request_options: Any) -> str: + async def read( + self, + path: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> str: return await self._devbox._client.devboxes.read_file_contents( - self._devbox.id, file_path=path, **request_options + self._devbox.id, + file_path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) - async def write(self, path: str, contents: str | bytes, **request_options: Any) -> Any: + async def write( + self, + path: str, + contents: str | bytes, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: if isinstance(contents, bytes): contents_str = contents.decode("utf-8") else: @@ -389,24 +471,55 @@ async def write(self, path: str, contents: str | bytes, **request_options: Any) self._devbox.id, file_path=path, contents=contents_str, - **request_options, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) - async def download(self, path: str, **request_options: Any) -> bytes: + async def download( + self, + path: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> bytes: response = await self._devbox._client.devboxes.download_file( self._devbox.id, path=path, - **request_options, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) return await response.read() - async def upload(self, path: str, file: UploadInput, **request_options: Any) -> Any: + async def upload( + self, + path: str, + file: UploadInput, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: file_param = normalize_upload_input(file) return await self._devbox._client.devboxes.upload_file( self._devbox.id, path=path, file=file_param, - **request_options, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) @@ -414,11 +527,60 @@ class _AsyncNetworkInterface: def __init__(self, devbox: AsyncDevbox) -> None: self._devbox = devbox - async def create_ssh_key(self, **request_options: Any) -> Any: - return await self._devbox._client.devboxes.create_ssh_key(self._devbox.id, **request_options) + async def create_ssh_key( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return await self._devbox._client.devboxes.create_ssh_key( + self._devbox.id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) - async def create_tunnel(self, *, port: int, **request_options: Any) -> Any: - return await self._devbox._client.devboxes.create_tunnel(self._devbox.id, port=port, **request_options) + async def create_tunnel( + self, + *, + port: int, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return await self._devbox._client.devboxes.create_tunnel( + self._devbox.id, + port=port, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) - async def remove_tunnel(self, *, port: int, **request_options: Any) -> Any: - return await self._devbox._client.devboxes.remove_tunnel(self._devbox.id, port=port, **request_options) + async def remove_tunnel( + self, + *, + port: int, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return await self._devbox._client.devboxes.remove_tunnel( + self._devbox.id, + port=port, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) diff --git a/src/runloop_api_client/sdk/async_execution.py b/src/runloop_api_client/sdk/async_execution.py index 716be3918..d4a29c323 100644 --- a/src/runloop_api_client/sdk/async_execution.py +++ b/src/runloop_api_client/sdk/async_execution.py @@ -2,7 +2,7 @@ import asyncio import logging -from typing import Optional +from typing import Awaitable, Optional, cast from .._client import AsyncRunloop from ..lib.polling import PollingConfig @@ -21,15 +21,15 @@ def __init__(self, tasks: list[asyncio.Task[None]]) -> None: async def wait(self) -> None: results = await asyncio.gather(*self._tasks, return_exceptions=True) - self._log_results(results) + self._log_results(tuple(results)) async def cancel(self) -> None: for task in self._tasks: task.cancel() results = await asyncio.gather(*self._tasks, return_exceptions=True) - self._log_results(results) + self._log_results(tuple(results)) - def _log_results(self, results: list[object]) -> None: + def _log_results(self, results: tuple[object | BaseException | None, ...]) -> None: for result in results: if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError): self._logger.debug("stream task error: %s", result) @@ -62,18 +62,34 @@ def devbox_id(self) -> str: return self._devbox_id async def result(self, *, polling_config: PollingConfig | None = None) -> AsyncExecutionResult: - if self._latest.status == "completed": - final = self._latest - else: - final = await self._client.devboxes.executions.await_completed( + async def command_coro() -> DevboxAsyncExecutionDetailView: + if self._latest.status == "completed": + return self._latest + return await self._client.devboxes.executions.await_completed( self._execution_id, devbox_id=self._devbox_id, polling_config=polling_config, ) - await self._settle_streaming(cancel=False) - self._latest = final - return AsyncExecutionResult(self._client, self._devbox_id, final) + awaitables: list[Awaitable[DevboxAsyncExecutionDetailView | None]] = [command_coro()] + if self._streaming_group is not None: + awaitables.append(self._streaming_group.wait()) + + results = await asyncio.gather(*awaitables, return_exceptions=True) + command_result = results[0] + + if isinstance(command_result, Exception): + if self._streaming_group is not None: + await self._streaming_group.cancel() + raise command_result + + if self._streaming_group is not None: + self._streaming_group = None + + # Streaming completion is orchestrated via the gather call above. + command_value = cast(DevboxAsyncExecutionDetailView, command_result) + self._latest = command_value + return AsyncExecutionResult(self._client, self._devbox_id, command_value) async def get_state(self) -> DevboxAsyncExecutionDetailView: self._latest = await self._client.devboxes.executions.retrieve( diff --git a/src/runloop_api_client/sdk/async_snapshot.py b/src/runloop_api_client/sdk/async_snapshot.py index ba02b1ab7..38370a45a 100644 --- a/src/runloop_api_client/sdk/async_snapshot.py +++ b/src/runloop_api_client/sdk/async_snapshot.py @@ -1,43 +1,33 @@ from __future__ import annotations -from typing import Any, List +from typing import Any, Dict, Iterable, Optional + +from typing_extensions import override from .._client import AsyncRunloop from ..lib.polling import PollingConfig -from .async_devbox import AsyncDevbox, AsyncDevboxClient +from .._types import Body, Headers, NotGiven, NOT_GIVEN, Omit, Query, Timeout, not_given, omit +from ..types.shared_params.code_mount_parameters import CodeMountParameters +from ..types.shared_params.launch_parameters import LaunchParameters +from .async_devbox import AsyncDevbox from ..types.devbox_snapshot_view import DevboxSnapshotView from ..types.devboxes.devbox_snapshot_async_status_view import DevboxSnapshotAsyncStatusView -class AsyncSnapshotClient: - """ - Manage :class:`AsyncSnapshot` instances. - """ - - def __init__(self, client: AsyncRunloop, devbox_client: AsyncDevboxClient) -> None: - self._client = client - self._devbox_client = devbox_client - - async def list(self, **params: Any) -> List["AsyncSnapshot"]: - page = await self._client.devboxes.disk_snapshots.list(**params) - return [ - AsyncSnapshot(self._client, item.id, self._devbox_client) for item in getattr(page, "disk_snapshots", []) - ] - - def from_id(self, snapshot_id: str) -> "AsyncSnapshot": - return AsyncSnapshot(self._client, snapshot_id, self._devbox_client) - - class AsyncSnapshot: """ Async wrapper around snapshot operations. """ - def __init__(self, client: AsyncRunloop, snapshot_id: str, devbox_client: AsyncDevboxClient) -> None: + def __init__( + self, + client: AsyncRunloop, + snapshot_id: str, + ) -> None: self._client = client self._id = snapshot_id - self._devbox_client = devbox_client + @override def __repr__(self) -> str: return f"" @@ -45,28 +35,121 @@ def __repr__(self) -> str: def id(self) -> str: return self._id - async def get_info(self, **request_options: Any) -> DevboxSnapshotAsyncStatusView: - return await self._client.devboxes.disk_snapshots.query_status(self._id, **request_options) + async def get_info( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> DevboxSnapshotAsyncStatusView: + return await self._client.devboxes.disk_snapshots.query_status( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) - async def update(self, **params: Any) -> DevboxSnapshotView: - return await self._client.devboxes.disk_snapshots.update(self._id, **params) + async def update( + self, + *, + commit_message: Optional[str] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + name: Optional[str] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> DevboxSnapshotView: + return await self._client.devboxes.disk_snapshots.update( + self._id, + commit_message=commit_message, + metadata=metadata, + name=name, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) - async def delete(self, **request_options: Any) -> Any: - return await self._client.devboxes.disk_snapshots.delete(self._id, **request_options) + async def delete( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return await self._client.devboxes.disk_snapshots.delete( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) async def await_completed( self, *, polling_config: PollingConfig | None = None, - **request_options: Any, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, ) -> DevboxSnapshotAsyncStatusView: return await self._client.devboxes.disk_snapshots.await_completed( self._id, polling_config=polling_config, - **request_options, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) - async def create_devbox(self, *, polling_config: PollingConfig | None = None, **params: Any) -> AsyncDevbox: - params = dict(params) - params["snapshot_id"] = self._id - return await self._devbox_client.create(polling_config=polling_config, **params) + async def create_devbox( + self, + *, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + entrypoint: Optional[str] | NotGiven = NOT_GIVEN, + environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, + repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, + secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> AsyncDevbox: + from ._async import AsyncDevboxClient + + devbox_client = AsyncDevboxClient(self._client) + return await devbox_client.create_from_snapshot( + self._id, + code_mounts=code_mounts, + entrypoint=entrypoint, + environment_variables=environment_variables, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + metadata=metadata, + name=name, + repo_connection_id=repo_connection_id, + secrets=secrets, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) diff --git a/src/runloop_api_client/sdk/async_storage_object.py b/src/runloop_api_client/sdk/async_storage_object.py index 1ac6b592d..cdcf6eef3 100644 --- a/src/runloop_api_client/sdk/async_storage_object.py +++ b/src/runloop_api_client/sdk/async_storage_object.py @@ -1,87 +1,18 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional -from pathlib import Path +from typing import Any import httpx +from typing_extensions import override + from .._client import AsyncRunloop -from .storage_object import UploadData, ContentType, _read_upload_data, _detect_content_type +from .._types import Body, Headers, NotGiven, Query, Timeout, not_given +from ._helpers import UploadData, read_upload_data from ..types.object_view import ObjectView from ..types.object_download_url_view import ObjectDownloadURLView -class AsyncStorageObjectClient: - """ - Async manager for :class:`AsyncStorageObject` instances. - """ - - def __init__(self, client: AsyncRunloop) -> None: - self._client = client - - async def create( - self, - name: str, - *, - content_type: ContentType | None = None, - metadata: Optional[Dict[str, str]] = None, - ) -> "AsyncStorageObject": - content_type = content_type or _detect_content_type(name) - obj = await self._client.objects.create( - name=name, - content_type=content_type, - metadata=metadata, - ) - return AsyncStorageObject(self._client, obj.id, upload_url=obj.upload_url) - - def from_id(self, object_id: str) -> "AsyncStorageObject": - return AsyncStorageObject(self._client, object_id, upload_url=None) - - async def list(self, **params: Any) -> List["AsyncStorageObject"]: - page = await self._client.objects.list(**params) - return [AsyncStorageObject(self._client, item.id, upload_url=None) for item in getattr(page, "objects", [])] - - async def upload_from_file( - self, - path: str | Path, - name: str | None = None, - *, - metadata: Optional[Dict[str, str]] = None, - content_type: ContentType | None = None, - ) -> "AsyncStorageObject": - file_path = Path(path) - object_name = name or file_path.name - obj = await self.create(object_name, content_type=content_type, metadata=metadata) - await obj.upload_content(file_path) - await obj.complete() - return obj - - async def upload_from_text( - self, - text: str, - name: str, - *, - metadata: Optional[Dict[str, str]] = None, - ) -> "AsyncStorageObject": - obj = await self.create(name, content_type="text", metadata=metadata) - await obj.upload_content(text) - await obj.complete() - return obj - - async def upload_from_bytes( - self, - data: bytes, - name: str, - *, - metadata: Optional[Dict[str, str]] = None, - content_type: ContentType | None = None, - ) -> "AsyncStorageObject": - obj = await self.create(name, content_type=content_type or _detect_content_type(name), metadata=metadata) - await obj.upload_content(data) - await obj.complete() - return obj - - class AsyncStorageObject: """ Async wrapper around storage object operations. @@ -92,6 +23,7 @@ def __init__(self, client: AsyncRunloop, object_id: str, upload_url: str | None) self._id = object_id self._upload_url = upload_url + @override def __repr__(self) -> str: return f"" @@ -103,11 +35,39 @@ def id(self) -> str: def upload_url(self) -> str | None: return self._upload_url - async def refresh(self, **request_options: Any) -> ObjectView: - return await self._client.objects.retrieve(self._id, **request_options) + async def refresh( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> ObjectView: + return await self._client.objects.retrieve( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) - async def complete(self, **request_options: Any) -> ObjectView: - result = await self._client.objects.complete(self._id, **request_options) + async def complete( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> ObjectView: + result = await self._client.objects.complete( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) self._upload_url = None return result @@ -115,19 +75,44 @@ async def get_download_url( self, *, duration_seconds: int | None = None, - **request_options: Any, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, ) -> ObjectDownloadURLView: if duration_seconds is None: - return await self._client.objects.download(self._id, **request_options) - return await self._client.objects.download(self._id, duration_seconds=duration_seconds, **request_options) + return await self._client.objects.download( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return await self._client.objects.download( + self._id, + duration_seconds=duration_seconds, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) async def download_as_bytes( self, *, duration_seconds: int | None = None, - **request_options: Any, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, ) -> bytes: - url_view = await self.get_download_url(duration_seconds=duration_seconds, **request_options) + url_view = await self.get_download_url( + duration_seconds=duration_seconds, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) async with httpx.AsyncClient() as client: response = await client.get(url_view.download_url) response.raise_for_status() @@ -138,21 +123,45 @@ async def download_as_text( *, duration_seconds: int | None = None, encoding: str = "utf-8", - **request_options: Any, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, ) -> str: - url_view = await self.get_download_url(duration_seconds=duration_seconds, **request_options) + url_view = await self.get_download_url( + duration_seconds=duration_seconds, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) async with httpx.AsyncClient() as client: response = await client.get(url_view.download_url) response.raise_for_status() response.encoding = encoding return response.text - async def delete(self, **request_options: Any) -> Any: - return await self._client.objects.delete(self._id, **request_options) + async def delete( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return await self._client.objects.delete( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) async def upload_content(self, data: UploadData) -> None: url = self._ensure_upload_url() - payload = _read_upload_data(data) + payload = read_upload_data(data) async with httpx.AsyncClient() as client: response = await client.put(url, content=payload) response.raise_for_status() diff --git a/src/runloop_api_client/sdk/blueprint.py b/src/runloop_api_client/sdk/blueprint.py index a5c1d1da1..1778fa463 100644 --- a/src/runloop_api_client/sdk/blueprint.py +++ b/src/runloop_api_client/sdk/blueprint.py @@ -1,60 +1,32 @@ from __future__ import annotations -from typing import Any, List +from typing import Any, Dict, Iterable, Optional -from .devbox import Devbox, DevboxClient +from typing_extensions import override + +from .devbox import Devbox from .._client import Runloop from ..lib.polling import PollingConfig +from .._types import Body, Headers, NotGiven, NOT_GIVEN, Query, Timeout, not_given +from ..types.shared_params.code_mount_parameters import CodeMountParameters +from ..types.shared_params.launch_parameters import LaunchParameters from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView -class BlueprintClient: - """ - Manage :class:`Blueprint` objects through the object-oriented SDK. - """ - - def __init__(self, client: Runloop, devbox_client: DevboxClient) -> None: - self._client = client - self._devbox_client = devbox_client - - def create(self, *, polling_config: PollingConfig | None = None, **params: Any) -> "Blueprint": - """ - Create a blueprint and wait for the build to complete. - """ - params = dict(params) - if polling_config is None: - polling_config = params.pop("polling_config", None) - - blueprint = self._client.blueprints.create_and_await_build_complete( - polling_config=polling_config, - **params, - ) - return Blueprint(self._client, blueprint.id, self._devbox_client) - - def from_id(self, blueprint_id: str) -> "Blueprint": - """ - Return a :class:`Blueprint` wrapper for an existing blueprint ID. - """ - return Blueprint(self._client, blueprint_id, self._devbox_client) - - def list(self, **params: Any) -> List["Blueprint"]: - """ - List blueprints and return lightweight wrappers. - """ - page = self._client.blueprints.list(**params) - return [Blueprint(self._client, item.id, self._devbox_client) for item in getattr(page, "blueprints", [])] - - class Blueprint: """ High-level wrapper around a blueprint resource. """ - def __init__(self, client: Runloop, blueprint_id: str, devbox_client: DevboxClient) -> None: + def __init__( + self, + client: Runloop, + blueprint_id: str, + ) -> None: self._client = client self._id = blueprint_id - self._devbox_client = devbox_client + @override def __repr__(self) -> str: return f"" @@ -62,16 +34,91 @@ def __repr__(self) -> str: def id(self) -> str: return self._id - def get_info(self, **request_options: Any) -> Any: - return self._client.blueprints.retrieve(self._id, **request_options) + def get_info( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> Any: + return self._client.blueprints.retrieve( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) - def logs(self, **request_options: Any) -> BlueprintBuildLogsListView: - return self._client.blueprints.logs(self._id, **request_options) + def logs( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> BlueprintBuildLogsListView: + return self._client.blueprints.logs( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) - def delete(self, **request_options: Any) -> Any: - return self._client.blueprints.delete(self._id, **request_options) + def delete( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> Any: + return self._client.blueprints.delete( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) - def create_devbox(self, *, polling_config: PollingConfig | None = None, **params: Any) -> Devbox: - params = dict(params) - params["blueprint_id"] = self._id - return self._devbox_client.create(polling_config=polling_config, **params) + def create_devbox( + self, + *, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + entrypoint: Optional[str] | NotGiven = NOT_GIVEN, + environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, + repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, + secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Devbox: + from ._sync import DevboxClient + + devbox_client = DevboxClient(self._client) + return devbox_client.create_from_blueprint_id( + self._id, + code_mounts=code_mounts, + entrypoint=entrypoint, + environment_variables=environment_variables, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + metadata=metadata, + name=name, + repo_connection_id=repo_connection_id, + secrets=secrets, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index cca0110a6..d82a5fc3c 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -2,88 +2,22 @@ import logging import threading -from typing import Any, Callable, Optional, Sequence +from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence -from .._types import not_given +from typing_extensions import override + +from .._types import Body, Headers, NotGiven, Omit, Query, Timeout, not_given, omit from .._client import Runloop -from ._helpers import UploadInput, normalize_upload_input +from ._helpers import LogCallback, UploadInput, normalize_upload_input from .execution import Execution, _StreamingGroup from .._streaming import Stream from ..lib.polling import PollingConfig from .execution_result import ExecutionResult from ..types.devboxes.execution_update_chunk import ExecutionUpdateChunk +from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView -LogCallback = Callable[[str], None] - - -class DevboxClient: - """ - High-level manager for creating and retrieving :class:`Devbox` instances. - """ - - def __init__(self, client: Runloop) -> None: - self._client = client - - def create(self, *, polling_config: PollingConfig | None = None, **params: Any) -> "Devbox": - """ - Create a new devbox and block until it is running. - """ - params = dict(params) - if polling_config is None: - polling_config = params.pop("polling_config", None) - - devbox_view = self._client.devboxes.create_and_await_running( - polling_config=polling_config, - **params, - ) - return Devbox(self._client, devbox_view.id) - - def create_from_blueprint_id( - self, - blueprint_id: str, - *, - polling_config: PollingConfig | None = None, - **params: Any, - ) -> "Devbox": - params = dict(params) - params["blueprint_id"] = blueprint_id - return self.create(polling_config=polling_config, **params) - - def create_from_blueprint_name( - self, - blueprint_name: str, - *, - polling_config: PollingConfig | None = None, - **params: Any, - ) -> "Devbox": - params = dict(params) - params["blueprint_name"] = blueprint_name - return self.create(polling_config=polling_config, **params) - - def create_from_snapshot( - self, - snapshot_id: str, - *, - polling_config: PollingConfig | None = None, - **params: Any, - ) -> "Devbox": - params = dict(params) - params["snapshot_id"] = snapshot_id - return self.create(polling_config=polling_config, **params) - - def from_id(self, devbox_id: str) -> "Devbox": - """ - Create a :class:`Devbox` wrapper for an existing devbox ID. - """ - return Devbox(self._client, devbox_id) - - def list(self, **params: Any) -> list["Devbox"]: - """ - List devboxes and return lightweight :class:`Devbox` wrappers. - """ - page = self._client.devboxes.list(**params) - return [Devbox(self._client, item.id) for item in getattr(page, "devboxes", [])] - +if TYPE_CHECKING: + from .snapshot import Snapshot class Devbox: """ @@ -95,6 +29,7 @@ def __init__(self, client: Runloop, devbox_id: str) -> None: self._id = devbox_id self._logger = logging.getLogger(__name__) + @override def __repr__(self) -> str: return f"" @@ -111,8 +46,21 @@ def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | No def id(self) -> str: return self._id - def get_info(self, **request_options: Any) -> Any: - return self._client.devboxes.retrieve(self._id, **request_options) + def get_info( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> Any: + return self._client.devboxes.retrieve( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) def await_running(self, *, polling_config: PollingConfig | None = None) -> Any: return self._client.devboxes.await_running(self._id, polling_config=polling_config) @@ -120,14 +68,81 @@ def await_running(self, *, polling_config: PollingConfig | None = None) -> Any: def await_suspended(self, *, polling_config: PollingConfig | None = None) -> Any: return self._client.devboxes.await_suspended(self._id, polling_config=polling_config) - def shutdown(self, **request_options: Any) -> Any: - return self._client.devboxes.shutdown(self._id, **request_options) + def shutdown( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return self._client.devboxes.shutdown( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) - def suspend(self, **request_options: Any) -> Any: - return self._client.devboxes.suspend(self._id, **request_options) + def suspend( + self, + *, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + self._client.devboxes.suspend( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return self._client.devboxes.await_suspended(self._id, polling_config=polling_config) - def resume(self, **request_options: Any) -> Any: - return self._client.devboxes.resume(self._id, **request_options) + def resume( + self, + *, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + self._client.devboxes.resume( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) + return self._client.devboxes.await_running(self._id, polling_config=polling_config) + + def keep_alive( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return self._client.devboxes.keep_alive( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) def snapshot_disk( self, @@ -304,20 +319,27 @@ def exec( stderr: LogCallback | None = None, output: LogCallback | None = None, polling_config: PollingConfig | None = None, - **request_options: Any, + attach_stdin: bool | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, ) -> ExecutionResult: devbox = self._devbox client = devbox._client - request_options = dict(request_options) - if "shell_name" in request_options: - shell_name = request_options.pop("shell_name") if stdout or stderr or output: - execution = client.devboxes.execute_async( + execution: DevboxAsyncExecutionDetailView = client.devboxes.execute_async( devbox.id, command=command, - shell_name=shell_name, - **request_options, + shell_name=shell_name if shell_name is not None else omit, + attach_stdin=attach_stdin, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) streaming_group = devbox._start_streaming( execution.execution_id, @@ -325,19 +347,21 @@ def exec( stderr=stderr, output=output, ) - try: - if execution.status == "completed": - final = execution - else: - final = client.devboxes.executions.await_completed( - execution.execution_id, - devbox_id=devbox.id, - polling_config=polling_config, - ) - finally: - if streaming_group is not None: - streaming_group.stop() - streaming_group.join() + final = execution + if execution.status == "completed": + final: DevboxAsyncExecutionDetailView = execution + else: + final = client.devboxes.executions.await_completed( + execution.execution_id, + devbox_id=devbox.id, + polling_config=polling_config, + ) + + if streaming_group is not None: + # Ensure log streaming has drained before returning the result. _stop_streaming() + # below will perform the final cleanup, but we still join here so callers only + # resume once all logs have been delivered. + streaming_group.join() return ExecutionResult(client, devbox.id, final) @@ -346,7 +370,11 @@ def exec( command=command, shell_name=shell_name if shell_name is not None else not_given, polling_config=polling_config, - **request_options, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) return ExecutionResult(client, devbox.id, final) @@ -358,19 +386,26 @@ def exec_async( stdout: LogCallback | None = None, stderr: LogCallback | None = None, output: LogCallback | None = None, - **request_options: Any, + attach_stdin: bool | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, ) -> Execution: devbox = self._devbox client = devbox._client - request_options = dict(request_options) - if "shell_name" in request_options: - shell_name = request_options.pop("shell_name") - execution = client.devboxes.execute_async( + execution: DevboxAsyncExecutionDetailView = client.devboxes.execute_async( devbox.id, command=command, - shell_name=shell_name, - **request_options, + shell_name=shell_name if shell_name is not None else omit, + attach_stdin=attach_stdin, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) streaming_group = devbox._start_streaming( @@ -387,10 +422,37 @@ class _FileInterface: def __init__(self, devbox: Devbox) -> None: self._devbox = devbox - def read(self, path: str, **request_options: Any) -> str: - return self._devbox._client.devboxes.read_file_contents(self._devbox.id, file_path=path, **request_options) + def read( + self, + path: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> str: + return self._devbox._client.devboxes.read_file_contents( + self._devbox.id, + file_path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) - def write(self, path: str, contents: str | bytes, **request_options: Any) -> Any: + def write( + self, + path: str, + contents: str | bytes, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: if isinstance(contents, bytes): contents_str = contents.decode("utf-8") else: @@ -400,24 +462,55 @@ def write(self, path: str, contents: str | bytes, **request_options: Any) -> Any self._devbox.id, file_path=path, contents=contents_str, - **request_options, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) - def download(self, path: str, **request_options: Any) -> bytes: + def download( + self, + path: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> bytes: response = self._devbox._client.devboxes.download_file( self._devbox.id, path=path, - **request_options, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) return response.read() - def upload(self, path: str, file: UploadInput, **request_options: Any) -> Any: + def upload( + self, + path: str, + file: UploadInput, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: file_param = normalize_upload_input(file) return self._devbox._client.devboxes.upload_file( self._devbox.id, path=path, file=file_param, - **request_options, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) @@ -425,11 +518,60 @@ class _NetworkInterface: def __init__(self, devbox: Devbox) -> None: self._devbox = devbox - def create_ssh_key(self, **request_options: Any) -> Any: - return self._devbox._client.devboxes.create_ssh_key(self._devbox.id, **request_options) + def create_ssh_key( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return self._devbox._client.devboxes.create_ssh_key( + self._devbox.id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) - def create_tunnel(self, *, port: int, **request_options: Any) -> Any: - return self._devbox._client.devboxes.create_tunnel(self._devbox.id, port=port, **request_options) + def create_tunnel( + self, + *, + port: int, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return self._devbox._client.devboxes.create_tunnel( + self._devbox.id, + port=port, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) - def remove_tunnel(self, *, port: int, **request_options: Any) -> Any: - return self._devbox._client.devboxes.remove_tunnel(self._devbox.id, port=port, **request_options) + def remove_tunnel( + self, + *, + port: int, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return self._devbox._client.devboxes.remove_tunnel( + self._devbox.id, + port=port, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) diff --git a/src/runloop_api_client/sdk/execution.py b/src/runloop_api_client/sdk/execution.py index adecc0a71..a26773af2 100644 --- a/src/runloop_api_client/sdk/execution.py +++ b/src/runloop_api_client/sdk/execution.py @@ -64,17 +64,21 @@ def result(self, *, polling_config: PollingConfig | None = None) -> ExecutionRes """ Wait for completion and return an :class:`ExecutionResult`. """ - try: - if self._latest.status == "completed": - final = self._latest - else: - final = self._client.devboxes.executions.await_completed( - self._execution_id, - devbox_id=self._devbox_id, - polling_config=polling_config, - ) - finally: - self._stop_streaming() + if self._latest.status == "completed": + final = self._latest + else: + final = self._client.devboxes.executions.await_completed( + self._execution_id, + devbox_id=self._devbox_id, + polling_config=polling_config, + ) + + if self._streaming_group is not None: + # Block until streaming threads have drained so callers observe all log output + # before we hand back control. _stop_streaming() handles the tidy-up afterward. + self._streaming_group.join() + + self._stop_streaming() self._latest = final return ExecutionResult(self._client, self._devbox_id, final) diff --git a/src/runloop_api_client/sdk/snapshot.py b/src/runloop_api_client/sdk/snapshot.py index 26b6de1c6..3d53297f1 100644 --- a/src/runloop_api_client/sdk/snapshot.py +++ b/src/runloop_api_client/sdk/snapshot.py @@ -1,41 +1,33 @@ from __future__ import annotations -from typing import Any, List +from typing import Any, Dict, Iterable, Optional -from .devbox import Devbox, DevboxClient +from typing_extensions import override + +from .devbox import Devbox from .._client import Runloop from ..lib.polling import PollingConfig +from .._types import Body, Headers, NotGiven, NOT_GIVEN, Omit, Query, Timeout, not_given, omit +from ..types.shared_params.code_mount_parameters import CodeMountParameters +from ..types.shared_params.launch_parameters import LaunchParameters from ..types.devbox_snapshot_view import DevboxSnapshotView from ..types.devboxes.devbox_snapshot_async_status_view import DevboxSnapshotAsyncStatusView -class SnapshotClient: - """ - Manage :class:`Snapshot` objects through the SDK. - """ - - def __init__(self, client: Runloop, devbox_client: DevboxClient) -> None: - self._client = client - self._devbox_client = devbox_client - - def list(self, **params: Any) -> List["Snapshot"]: - page = self._client.devboxes.disk_snapshots.list(**params) - return [Snapshot(self._client, item.id, self._devbox_client) for item in getattr(page, "disk_snapshots", [])] - - def from_id(self, snapshot_id: str) -> "Snapshot": - return Snapshot(self._client, snapshot_id, self._devbox_client) - - class Snapshot: """ Wrapper around snapshot operations. """ - def __init__(self, client: Runloop, snapshot_id: str, devbox_client: DevboxClient) -> None: + def __init__( + self, + client: Runloop, + snapshot_id: str, + ) -> None: self._client = client self._id = snapshot_id - self._devbox_client = devbox_client + @override def __repr__(self) -> str: return f"" @@ -43,25 +35,121 @@ def __repr__(self) -> str: def id(self) -> str: return self._id - def get_info(self, **request_options: Any) -> DevboxSnapshotAsyncStatusView: - return self._client.devboxes.disk_snapshots.query_status(self._id, **request_options) + def get_info( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> DevboxSnapshotAsyncStatusView: + return self._client.devboxes.disk_snapshots.query_status( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) - def update(self, **params: Any) -> DevboxSnapshotView: - return self._client.devboxes.disk_snapshots.update(self._id, **params) + def update( + self, + *, + commit_message: Optional[str] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + name: Optional[str] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> DevboxSnapshotView: + return self._client.devboxes.disk_snapshots.update( + self._id, + commit_message=commit_message, + metadata=metadata, + name=name, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) - def delete(self, **request_options: Any) -> Any: - return self._client.devboxes.disk_snapshots.delete(self._id, **request_options) + def delete( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return self._client.devboxes.disk_snapshots.delete( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) def await_completed( - self, *, polling_config: PollingConfig | None = None, **request_options: Any + self, + *, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, ) -> DevboxSnapshotAsyncStatusView: return self._client.devboxes.disk_snapshots.await_completed( self._id, polling_config=polling_config, - **request_options, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, ) - def create_devbox(self, *, polling_config: PollingConfig | None = None, **params: Any) -> Devbox: - params = dict(params) - params["snapshot_id"] = self._id - return self._devbox_client.create(polling_config=polling_config, **params) + def create_devbox( + self, + *, + code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, + entrypoint: Optional[str] | NotGiven = NOT_GIVEN, + environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, + metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + name: Optional[str] | NotGiven = NOT_GIVEN, + repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, + secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + polling_config: PollingConfig | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Devbox: + from ._sync import DevboxClient + + devbox_client = DevboxClient(self._client) + return devbox_client.create_from_snapshot( + self._id, + code_mounts=code_mounts, + entrypoint=entrypoint, + environment_variables=environment_variables, + file_mounts=file_mounts, + launch_parameters=launch_parameters, + metadata=metadata, + name=name, + repo_connection_id=repo_connection_id, + secrets=secrets, + polling_config=polling_config, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) diff --git a/src/runloop_api_client/sdk/storage_object.py b/src/runloop_api_client/sdk/storage_object.py index cf94b692e..c10bfd309 100644 --- a/src/runloop_api_client/sdk/storage_object.py +++ b/src/runloop_api_client/sdk/storage_object.py @@ -1,89 +1,16 @@ from __future__ import annotations -import io -import os -from typing import Any, Dict, List, Union, Literal, Optional -from pathlib import Path +from typing import Any + +from typing_extensions import override import httpx from .._client import Runloop +from .._types import Body, Headers, NotGiven, Query, Timeout, not_given from ..types.object_view import ObjectView from ..types.object_download_url_view import ObjectDownloadURLView - -ContentType = Literal["unspecified", "text", "binary", "gzip", "tar", "tgz"] -UploadData = Union[str, bytes, bytearray, Path, os.PathLike[str], io.IOBase] - - -class StorageObjectClient: - """ - Manage :class:`StorageObject` instances and provide convenience upload helpers. - """ - - def __init__(self, client: Runloop) -> None: - self._client = client - - def create( - self, - name: str, - *, - content_type: ContentType | None = None, - metadata: Optional[Dict[str, str]] = None, - ) -> "StorageObject": - content_type = content_type or _detect_content_type(name) - obj = self._client.objects.create( - name=name, - content_type=content_type, - metadata=metadata, - ) - return StorageObject(self._client, obj.id, upload_url=obj.upload_url) - - def from_id(self, object_id: str) -> "StorageObject": - return StorageObject(self._client, object_id, upload_url=None) - - def list(self, **params: Any) -> List["StorageObject"]: - page = self._client.objects.list(**params) - return [StorageObject(self._client, item.id, upload_url=None) for item in getattr(page, "objects", [])] - - def upload_from_file( - self, - path: str | Path, - name: str | None = None, - *, - metadata: Optional[Dict[str, str]] = None, - content_type: ContentType | None = None, - ) -> "StorageObject": - file_path = Path(path) - object_name = name or file_path.name - obj = self.create(object_name, content_type=content_type, metadata=metadata) - obj.upload_content(file_path) - obj.complete() - return obj - - def upload_from_text( - self, - text: str, - name: str, - *, - metadata: Optional[Dict[str, str]] = None, - ) -> "StorageObject": - obj = self.create(name, content_type="text", metadata=metadata) - obj.upload_content(text) - obj.complete() - return obj - - def upload_from_bytes( - self, - data: bytes, - name: str, - *, - metadata: Optional[Dict[str, str]] = None, - content_type: ContentType | None = None, - ) -> "StorageObject": - obj = self.create(name, content_type=content_type or _detect_content_type(name), metadata=metadata) - obj.upload_content(data) - obj.complete() - return obj +from ._helpers import UploadData, read_upload_data class StorageObject: @@ -96,6 +23,7 @@ def __init__(self, client: Runloop, object_id: str, upload_url: str | None) -> N self._id = object_id self._upload_url = upload_url + @override def __repr__(self) -> str: return f"" @@ -107,21 +35,85 @@ def id(self) -> str: def upload_url(self) -> str | None: return self._upload_url - def refresh(self, **request_options: Any) -> ObjectView: - return self._client.objects.retrieve(self._id, **request_options) + def refresh( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> ObjectView: + return self._client.objects.retrieve( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) - def complete(self, **request_options: Any) -> ObjectView: - result = self._client.objects.complete(self._id, **request_options) + def complete( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> ObjectView: + result = self._client.objects.complete( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) self._upload_url = None return result - def get_download_url(self, *, duration_seconds: int | None = None, **request_options: Any) -> ObjectDownloadURLView: + def get_download_url( + self, + *, + duration_seconds: int | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> ObjectDownloadURLView: if duration_seconds is None: - return self._client.objects.download(self._id, **request_options) - return self._client.objects.download(self._id, duration_seconds=duration_seconds, **request_options) + return self._client.objects.download( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + return self._client.objects.download( + self._id, + duration_seconds=duration_seconds, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) - def download_as_bytes(self, *, duration_seconds: int | None = None, **request_options: Any) -> bytes: - url_view = self.get_download_url(duration_seconds=duration_seconds, **request_options) + def download_as_bytes( + self, + *, + duration_seconds: int | None = None, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> bytes: + url_view = self.get_download_url( + duration_seconds=duration_seconds, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) response = httpx.get(url_view.download_url) response.raise_for_status() return response.content @@ -131,20 +123,44 @@ def download_as_text( *, duration_seconds: int | None = None, encoding: str = "utf-8", - **request_options: Any, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, ) -> str: - url_view = self.get_download_url(duration_seconds=duration_seconds, **request_options) + url_view = self.get_download_url( + duration_seconds=duration_seconds, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) response = httpx.get(url_view.download_url) response.raise_for_status() response.encoding = encoding return response.text - def delete(self, **request_options: Any) -> Any: - return self._client.objects.delete(self._id, **request_options) + def delete( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> Any: + return self._client.objects.delete( + self._id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ) def upload_content(self, data: UploadData) -> None: url = self._ensure_upload_url() - payload = _read_upload_data(data) + payload = read_upload_data(data) response = httpx.put(url, content=payload) response.raise_for_status() @@ -152,50 +168,3 @@ def _ensure_upload_url(self) -> str: if not self._upload_url: raise RuntimeError("No upload URL available. Create a new object before uploading content.") return self._upload_url - - -_CONTENT_TYPE_MAP: Dict[str, ContentType] = { - ".txt": "text", - ".html": "text", - ".css": "text", - ".js": "text", - ".json": "text", - ".xml": "text", - ".yaml": "text", - ".yml": "text", - ".md": "text", - ".csv": "text", - ".gz": "gzip", - ".tar": "tar", - ".tgz": "tgz", - ".tar.gz": "tgz", -} - - -def _detect_content_type(name: str) -> ContentType: - lower = name.lower() - if lower.endswith(".tar.gz") or lower.endswith(".tgz"): - return "tgz" - ext = Path(lower).suffix - return _CONTENT_TYPE_MAP.get(ext, "unspecified") - - -def _read_upload_data(data: UploadData) -> bytes: - if isinstance(data, bytes): - return data - if isinstance(data, bytearray): - return bytes(data) - if isinstance(data, (Path, os.PathLike)): - return Path(data).read_bytes() - if isinstance(data, str): - return data.encode("utf-8") - if isinstance(data, io.TextIOBase): - return data.read().encode("utf-8") - if isinstance(data, io.BufferedIOBase) or isinstance(data, io.RawIOBase): - return data.read() - if isinstance(data, io.IOBase) and hasattr(data, "read"): - result = data.read() - if isinstance(result, str): - return result.encode("utf-8") - return result - raise TypeError("Unsupported upload data type. Provide str, bytes, path, or file-like object.") From 27273f3354538186e1ecf1c4727182f299873220 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 10 Nov 2025 18:07:48 -0800 Subject: [PATCH 09/56] linting, formattting, and type checking changes --- .../resources/devboxes/disk_snapshots.py | 30 ++++++++++---- src/runloop_api_client/sdk/__init__.py | 8 +++- src/runloop_api_client/sdk/_async.py | 13 +++---- src/runloop_api_client/sdk/_helpers.py | 2 +- src/runloop_api_client/sdk/_sync.py | 17 ++++---- src/runloop_api_client/sdk/async_blueprint.py | 17 ++++---- src/runloop_api_client/sdk/async_devbox.py | 39 ++++++++++--------- src/runloop_api_client/sdk/async_execution.py | 2 +- src/runloop_api_client/sdk/async_snapshot.py | 20 +++++----- .../sdk/async_storage_object.py | 8 ++-- src/runloop_api_client/sdk/blueprint.py | 17 ++++---- src/runloop_api_client/sdk/devbox.py | 35 ++++++++++------- src/runloop_api_client/sdk/snapshot.py | 20 +++++----- src/runloop_api_client/sdk/storage_object.py | 9 ++--- 14 files changed, 124 insertions(+), 113 deletions(-) diff --git a/src/runloop_api_client/resources/devboxes/disk_snapshots.py b/src/runloop_api_client/resources/devboxes/disk_snapshots.py index 81222c2de..cf6cb54e8 100644 --- a/src/runloop_api_client/resources/devboxes/disk_snapshots.py +++ b/src/runloop_api_client/resources/devboxes/disk_snapshots.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Dict, Optional import httpx @@ -17,13 +17,13 @@ async_to_streamed_response_wrapper, ) from ...pagination import SyncDiskSnapshotsCursorIDPage, AsyncDiskSnapshotsCursorIDPage +from ..._exceptions import RunloopError +from ...lib.polling import PollingConfig, poll_until from ..._base_client import AsyncPaginator, make_request_options from ...types.devboxes import disk_snapshot_list_params, disk_snapshot_update_params +from ...lib.polling_async import async_poll_until from ...types.devbox_snapshot_view import DevboxSnapshotView from ...types.devboxes.devbox_snapshot_async_status_view import DevboxSnapshotAsyncStatusView -from ..._exceptions import RunloopError -from ...lib.polling import PollingConfig, poll_until -from ...lib.polling_async import async_poll_until __all__ = ["DiskSnapshotsResource", "AsyncDiskSnapshotsResource"] @@ -247,7 +247,10 @@ def await_completed( id: str, *, polling_config: PollingConfig | None = None, - **request_options: Any, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> DevboxSnapshotAsyncStatusView: """Wait for a disk snapshot operation to complete.""" @@ -257,7 +260,13 @@ def await_completed( def is_terminal(result: DevboxSnapshotAsyncStatusView) -> bool: return result.status in {"complete", "error"} - status = poll_until(lambda: self.query_status(id, **request_options), is_terminal, polling_config) + status = poll_until( + lambda: self.query_status( + id, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + is_terminal, + polling_config, + ) if status.status == "error": message = status.error_message or "Unknown error" @@ -485,7 +494,10 @@ async def await_completed( id: str, *, polling_config: PollingConfig | None = None, - **request_options: Any, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> DevboxSnapshotAsyncStatusView: """Wait asynchronously for a disk snapshot operation to complete.""" @@ -496,7 +508,9 @@ def is_terminal(result: DevboxSnapshotAsyncStatusView) -> bool: return result.status in {"complete", "error"} status = await async_poll_until( - lambda: self.query_status(id, **request_options), + lambda: self.query_status( + id, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), is_terminal, polling_config, ) diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py index 88679ac70..a1b73fc00 100644 --- a/src/runloop_api_client/sdk/__init__.py +++ b/src/runloop_api_client/sdk/__init__.py @@ -1,7 +1,13 @@ from __future__ import annotations from ._sync import RunloopSDK, DevboxClient, SnapshotClient, BlueprintClient, StorageObjectClient -from ._async import AsyncRunloopSDK, AsyncDevboxClient, AsyncSnapshotClient, AsyncBlueprintClient, AsyncStorageObjectClient +from ._async import ( + AsyncRunloopSDK, + AsyncDevboxClient, + AsyncSnapshotClient, + AsyncBlueprintClient, + AsyncStorageObjectClient, +) from .devbox import Devbox from .snapshot import Snapshot from .blueprint import Blueprint diff --git a/src/runloop_api_client/sdk/_async.py b/src/runloop_api_client/sdk/_async.py index 7e8dc0885..f7c20c52c 100644 --- a/src/runloop_api_client/sdk/_async.py +++ b/src/runloop_api_client/sdk/_async.py @@ -1,20 +1,20 @@ from __future__ import annotations +from typing import Any, Dict, Literal, Mapping, Iterable, Optional from pathlib import Path -from typing import Any, Dict, Iterable, Literal, Mapping, Optional import httpx +from .._types import NOT_GIVEN, Body, Omit, Query, Headers, Timeout, NotGiven, SequenceNotStr, omit, not_given from .._client import AsyncRunloop -from .._types import Body, Headers, NotGiven, NOT_GIVEN, Omit, Query, SequenceNotStr, Timeout, not_given, omit +from ._helpers import ContentType, detect_content_type from ..lib.polling import PollingConfig -from ..types.shared_params.code_mount_parameters import CodeMountParameters -from ..types.shared_params.launch_parameters import LaunchParameters from .async_devbox import AsyncDevbox from .async_snapshot import AsyncSnapshot from .async_blueprint import AsyncBlueprint from .async_storage_object import AsyncStorageObject -from ._helpers import ContentType, detect_content_type +from ..types.shared_params.launch_parameters import LaunchParameters +from ..types.shared_params.code_mount_parameters import CodeMountParameters class AsyncDevboxClient: @@ -433,7 +433,6 @@ def __init__( default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, http_client: httpx.AsyncClient | None = None, - _strict_response_validation: bool = False, ) -> None: if max_retries is None: self.api = AsyncRunloop( @@ -443,7 +442,6 @@ def __init__( default_headers=default_headers, default_query=default_query, http_client=http_client, - _strict_response_validation=_strict_response_validation, ) else: self.api = AsyncRunloop( @@ -454,7 +452,6 @@ def __init__( default_headers=default_headers, default_query=default_query, http_client=http_client, - _strict_response_validation=_strict_response_validation, ) self.devbox = AsyncDevboxClient(self.api) diff --git a/src/runloop_api_client/sdk/_helpers.py b/src/runloop_api_client/sdk/_helpers.py index ac50e0648..a84897be8 100644 --- a/src/runloop_api_client/sdk/_helpers.py +++ b/src/runloop_api_client/sdk/_helpers.py @@ -2,8 +2,8 @@ import io import os +from typing import IO, Dict, Union, Literal, Callable, cast from pathlib import Path -from typing import IO, Callable, Dict, Literal, Union, cast from .._types import FileTypes from .._utils import file_from_path diff --git a/src/runloop_api_client/sdk/_sync.py b/src/runloop_api_client/sdk/_sync.py index ae8cbb574..3f3a3e0f5 100644 --- a/src/runloop_api_client/sdk/_sync.py +++ b/src/runloop_api_client/sdk/_sync.py @@ -1,20 +1,20 @@ from __future__ import annotations +from typing import Any, Dict, Literal, Mapping, Iterable, Optional from pathlib import Path -from typing import Any, Dict, Iterable, Literal, Mapping, Optional import httpx -from .._client import Runloop -from .._types import Body, Headers, NotGiven, NOT_GIVEN, Omit, Query, SequenceNotStr, Timeout, not_given, omit -from ..lib.polling import PollingConfig -from ..types.shared_params.code_mount_parameters import CodeMountParameters -from ..types.shared_params.launch_parameters import LaunchParameters from .devbox import Devbox +from .._types import NOT_GIVEN, Body, Omit, Query, Headers, Timeout, NotGiven, SequenceNotStr, omit, not_given +from .._client import Runloop +from ._helpers import ContentType, detect_content_type from .snapshot import Snapshot from .blueprint import Blueprint +from ..lib.polling import PollingConfig from .storage_object import StorageObject -from ._helpers import ContentType, detect_content_type +from ..types.shared_params.launch_parameters import LaunchParameters +from ..types.shared_params.code_mount_parameters import CodeMountParameters class DevboxClient: @@ -434,7 +434,6 @@ def __init__( default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, http_client: httpx.Client | None = None, - _strict_response_validation: bool = False, ) -> None: if max_retries is None: self.api = Runloop( @@ -444,7 +443,6 @@ def __init__( default_headers=default_headers, default_query=default_query, http_client=http_client, - _strict_response_validation=_strict_response_validation, ) else: self.api = Runloop( @@ -455,7 +453,6 @@ def __init__( default_headers=default_headers, default_query=default_query, http_client=http_client, - _strict_response_validation=_strict_response_validation, ) self.devbox = DevboxClient(self.api) diff --git a/src/runloop_api_client/sdk/async_blueprint.py b/src/runloop_api_client/sdk/async_blueprint.py index 479529e74..9205f8590 100644 --- a/src/runloop_api_client/sdk/async_blueprint.py +++ b/src/runloop_api_client/sdk/async_blueprint.py @@ -1,16 +1,17 @@ from __future__ import annotations -from typing import Any, Dict, Iterable, Optional - +from typing import Dict, Iterable, Optional from typing_extensions import override +from ..types import BlueprintView +from ._async import AsyncDevboxClient +from .._types import NOT_GIVEN, Body, Query, Headers, Timeout, NotGiven, not_given from .._client import AsyncRunloop from ..lib.polling import PollingConfig -from .._types import Body, Headers, NotGiven, NOT_GIVEN, Query, Timeout, not_given -from ..types.shared_params.code_mount_parameters import CodeMountParameters -from ..types.shared_params.launch_parameters import LaunchParameters from .async_devbox import AsyncDevbox from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView +from ..types.shared_params.launch_parameters import LaunchParameters +from ..types.shared_params.code_mount_parameters import CodeMountParameters class AsyncBlueprint: @@ -41,7 +42,7 @@ async def get_info( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, - ) -> Any: + ) -> BlueprintView: return await self._client.blueprints.retrieve( self._id, extra_headers=extra_headers, @@ -73,7 +74,7 @@ async def delete( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, - ) -> Any: + ) -> object: return await self._client.blueprints.delete( self._id, extra_headers=extra_headers, @@ -101,8 +102,6 @@ async def create_devbox( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> AsyncDevbox: - from ._async import AsyncDevboxClient - devbox_client = AsyncDevboxClient(self._client) return await devbox_client.create_from_blueprint_id( self._id, diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index d1615383b..d889c062f 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -2,20 +2,24 @@ import asyncio import logging -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Sequence, cast - +from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence, Awaitable, cast from typing_extensions import override -from .._types import Body, Headers, NotGiven, Omit, Query, Timeout, not_given, omit +from ..types import ( + DevboxView, + DevboxTunnelView, + DevboxExecutionDetailView, + DevboxCreateSSHKeyResponse, +) +from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given from .._client import AsyncRunloop from ._helpers import LogCallback, UploadInput, normalize_upload_input from .._streaming import AsyncStream from ..lib.polling import PollingConfig from .async_execution import AsyncExecution, _AsyncStreamingGroup from .async_execution_result import AsyncExecutionResult -from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView from ..types.devboxes.execution_update_chunk import ExecutionUpdateChunk - +from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView StreamFactory = Callable[[], Awaitable[AsyncStream[ExecutionUpdateChunk]]] @@ -57,7 +61,7 @@ async def get_info( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, - ) -> Any: + ) -> DevboxView: return await self._client.devboxes.retrieve( self._id, extra_headers=extra_headers, @@ -66,10 +70,10 @@ async def get_info( timeout=timeout, ) - async def await_running(self, *, polling_config: PollingConfig | None = None) -> Any: + async def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView: return await self._client.devboxes.await_running(self._id, polling_config=polling_config) - async def await_suspended(self, *, polling_config: PollingConfig | None = None) -> Any: + async def await_suspended(self, *, polling_config: PollingConfig | None = None) -> DevboxView: return await self._client.devboxes.await_suspended(self._id, polling_config=polling_config) async def shutdown( @@ -80,7 +84,7 @@ async def shutdown( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> DevboxView: return await self._client.devboxes.shutdown( self._id, extra_headers=extra_headers, @@ -99,7 +103,7 @@ async def suspend( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> DevboxView: await self._client.devboxes.suspend( self._id, extra_headers=extra_headers, @@ -122,7 +126,7 @@ async def resume( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> DevboxView: await self._client.devboxes.resume( self._id, extra_headers=extra_headers, @@ -144,7 +148,7 @@ async def keep_alive( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> object: return await self._client.devboxes.keep_alive( self._id, extra_headers=extra_headers, @@ -185,7 +189,6 @@ async def snapshot_disk( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - idempotency_key=idempotency_key, ) return snapshot @@ -461,7 +464,7 @@ async def write( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> DevboxExecutionDetailView: if isinstance(contents, bytes): contents_str = contents.decode("utf-8") else: @@ -509,7 +512,7 @@ async def upload( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> object: file_param = normalize_upload_input(file) return await self._devbox._client.devboxes.upload_file( self._devbox.id, @@ -535,7 +538,7 @@ async def create_ssh_key( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> DevboxCreateSSHKeyResponse: return await self._devbox._client.devboxes.create_ssh_key( self._devbox.id, extra_headers=extra_headers, @@ -554,7 +557,7 @@ async def create_tunnel( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> DevboxTunnelView: return await self._devbox._client.devboxes.create_tunnel( self._devbox.id, port=port, @@ -574,7 +577,7 @@ async def remove_tunnel( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> object: return await self._devbox._client.devboxes.remove_tunnel( self._devbox.id, port=port, diff --git a/src/runloop_api_client/sdk/async_execution.py b/src/runloop_api_client/sdk/async_execution.py index d4a29c323..bf7b0b45e 100644 --- a/src/runloop_api_client/sdk/async_execution.py +++ b/src/runloop_api_client/sdk/async_execution.py @@ -2,7 +2,7 @@ import asyncio import logging -from typing import Awaitable, Optional, cast +from typing import Optional, Awaitable, cast from .._client import AsyncRunloop from ..lib.polling import PollingConfig diff --git a/src/runloop_api_client/sdk/async_snapshot.py b/src/runloop_api_client/sdk/async_snapshot.py index 38370a45a..058698251 100644 --- a/src/runloop_api_client/sdk/async_snapshot.py +++ b/src/runloop_api_client/sdk/async_snapshot.py @@ -1,16 +1,18 @@ from __future__ import annotations -from typing import Any, Dict, Iterable, Optional - +from typing import TYPE_CHECKING, Dict, Iterable, Optional from typing_extensions import override +from ._async import AsyncDevboxClient + +if TYPE_CHECKING: + from .async_devbox import AsyncDevbox +from .._types import NOT_GIVEN, Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given from .._client import AsyncRunloop from ..lib.polling import PollingConfig -from .._types import Body, Headers, NotGiven, NOT_GIVEN, Omit, Query, Timeout, not_given, omit -from ..types.shared_params.code_mount_parameters import CodeMountParameters -from ..types.shared_params.launch_parameters import LaunchParameters -from .async_devbox import AsyncDevbox from ..types.devbox_snapshot_view import DevboxSnapshotView +from ..types.shared_params.launch_parameters import LaunchParameters +from ..types.shared_params.code_mount_parameters import CodeMountParameters from ..types.devboxes.devbox_snapshot_async_status_view import DevboxSnapshotAsyncStatusView @@ -83,7 +85,7 @@ async def delete( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> object: return await self._client.devboxes.disk_snapshots.delete( self._id, extra_headers=extra_headers, @@ -101,7 +103,6 @@ async def await_completed( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, ) -> DevboxSnapshotAsyncStatusView: return await self._client.devboxes.disk_snapshots.await_completed( self._id, @@ -110,7 +111,6 @@ async def await_completed( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - idempotency_key=idempotency_key, ) async def create_devbox( @@ -132,8 +132,6 @@ async def create_devbox( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> AsyncDevbox: - from ._async import AsyncDevboxClient - devbox_client = AsyncDevboxClient(self._client) return await devbox_client.create_from_snapshot( self._id, diff --git a/src/runloop_api_client/sdk/async_storage_object.py b/src/runloop_api_client/sdk/async_storage_object.py index cdcf6eef3..08b3bcfb1 100644 --- a/src/runloop_api_client/sdk/async_storage_object.py +++ b/src/runloop_api_client/sdk/async_storage_object.py @@ -1,13 +1,11 @@ from __future__ import annotations -from typing import Any +from typing_extensions import override import httpx -from typing_extensions import override - +from .._types import Body, Query, Headers, Timeout, NotGiven, not_given from .._client import AsyncRunloop -from .._types import Body, Headers, NotGiven, Query, Timeout, not_given from ._helpers import UploadData, read_upload_data from ..types.object_view import ObjectView from ..types.object_download_url_view import ObjectDownloadURLView @@ -149,7 +147,7 @@ async def delete( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> ObjectView: return await self._client.objects.delete( self._id, extra_headers=extra_headers, diff --git a/src/runloop_api_client/sdk/blueprint.py b/src/runloop_api_client/sdk/blueprint.py index 1778fa463..b23bcc9e2 100644 --- a/src/runloop_api_client/sdk/blueprint.py +++ b/src/runloop_api_client/sdk/blueprint.py @@ -1,16 +1,17 @@ from __future__ import annotations -from typing import Any, Dict, Iterable, Optional - +from typing import Dict, Iterable, Optional from typing_extensions import override +from ._sync import DevboxClient +from ..types import BlueprintView from .devbox import Devbox +from .._types import NOT_GIVEN, Body, Query, Headers, Timeout, NotGiven, not_given from .._client import Runloop from ..lib.polling import PollingConfig -from .._types import Body, Headers, NotGiven, NOT_GIVEN, Query, Timeout, not_given -from ..types.shared_params.code_mount_parameters import CodeMountParameters -from ..types.shared_params.launch_parameters import LaunchParameters from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView +from ..types.shared_params.launch_parameters import LaunchParameters +from ..types.shared_params.code_mount_parameters import CodeMountParameters class Blueprint: @@ -41,7 +42,7 @@ def get_info( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, - ) -> Any: + ) -> BlueprintView: return self._client.blueprints.retrieve( self._id, extra_headers=extra_headers, @@ -73,7 +74,7 @@ def delete( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, - ) -> Any: + ) -> object: return self._client.blueprints.delete( self._id, extra_headers=extra_headers, @@ -101,8 +102,6 @@ def create_devbox( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> Devbox: - from ._sync import DevboxClient - devbox_client = DevboxClient(self._client) return devbox_client.create_from_blueprint_id( self._id, diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index d82a5fc3c..b4b96b962 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -3,10 +3,15 @@ import logging import threading from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence - from typing_extensions import override -from .._types import Body, Headers, NotGiven, Omit, Query, Timeout, not_given, omit +from ..types import ( + DevboxView, + DevboxTunnelView, + DevboxExecutionDetailView, + DevboxCreateSSHKeyResponse, +) +from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given from .._client import Runloop from ._helpers import LogCallback, UploadInput, normalize_upload_input from .execution import Execution, _StreamingGroup @@ -19,6 +24,7 @@ if TYPE_CHECKING: from .snapshot import Snapshot + class Devbox: """ Object-oriented wrapper around devbox operations. @@ -53,7 +59,7 @@ def get_info( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, - ) -> Any: + ) -> DevboxView: return self._client.devboxes.retrieve( self._id, extra_headers=extra_headers, @@ -62,10 +68,10 @@ def get_info( timeout=timeout, ) - def await_running(self, *, polling_config: PollingConfig | None = None) -> Any: + def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView: return self._client.devboxes.await_running(self._id, polling_config=polling_config) - def await_suspended(self, *, polling_config: PollingConfig | None = None) -> Any: + def await_suspended(self, *, polling_config: PollingConfig | None = None) -> DevboxView: return self._client.devboxes.await_suspended(self._id, polling_config=polling_config) def shutdown( @@ -76,7 +82,7 @@ def shutdown( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> DevboxView: return self._client.devboxes.shutdown( self._id, extra_headers=extra_headers, @@ -95,7 +101,7 @@ def suspend( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> DevboxView: self._client.devboxes.suspend( self._id, extra_headers=extra_headers, @@ -115,7 +121,7 @@ def resume( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> DevboxView: self._client.devboxes.resume( self._id, extra_headers=extra_headers, @@ -134,7 +140,7 @@ def keep_alive( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> object: return self._client.devboxes.keep_alive( self._id, extra_headers=extra_headers, @@ -175,7 +181,6 @@ def snapshot_disk( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - idempotency_key=idempotency_key, ) return snapshot @@ -452,7 +457,7 @@ def write( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> DevboxExecutionDetailView: if isinstance(contents, bytes): contents_str = contents.decode("utf-8") else: @@ -500,7 +505,7 @@ def upload( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> object: file_param = normalize_upload_input(file) return self._devbox._client.devboxes.upload_file( self._devbox.id, @@ -526,7 +531,7 @@ def create_ssh_key( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> DevboxCreateSSHKeyResponse: return self._devbox._client.devboxes.create_ssh_key( self._devbox.id, extra_headers=extra_headers, @@ -545,7 +550,7 @@ def create_tunnel( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> DevboxTunnelView: return self._devbox._client.devboxes.create_tunnel( self._devbox.id, port=port, @@ -565,7 +570,7 @@ def remove_tunnel( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> object: return self._devbox._client.devboxes.remove_tunnel( self._devbox.id, port=port, diff --git a/src/runloop_api_client/sdk/snapshot.py b/src/runloop_api_client/sdk/snapshot.py index 3d53297f1..923386d9b 100644 --- a/src/runloop_api_client/sdk/snapshot.py +++ b/src/runloop_api_client/sdk/snapshot.py @@ -1,16 +1,18 @@ from __future__ import annotations -from typing import Any, Dict, Iterable, Optional - +from typing import TYPE_CHECKING, Dict, Iterable, Optional from typing_extensions import override -from .devbox import Devbox +from ._sync import DevboxClient + +if TYPE_CHECKING: + from .devbox import Devbox +from .._types import NOT_GIVEN, Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given from .._client import Runloop from ..lib.polling import PollingConfig -from .._types import Body, Headers, NotGiven, NOT_GIVEN, Omit, Query, Timeout, not_given, omit -from ..types.shared_params.code_mount_parameters import CodeMountParameters -from ..types.shared_params.launch_parameters import LaunchParameters from ..types.devbox_snapshot_view import DevboxSnapshotView +from ..types.shared_params.launch_parameters import LaunchParameters +from ..types.shared_params.code_mount_parameters import CodeMountParameters from ..types.devboxes.devbox_snapshot_async_status_view import DevboxSnapshotAsyncStatusView @@ -83,7 +85,7 @@ def delete( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> object: return self._client.devboxes.disk_snapshots.delete( self._id, extra_headers=extra_headers, @@ -101,7 +103,6 @@ def await_completed( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, ) -> DevboxSnapshotAsyncStatusView: return self._client.devboxes.disk_snapshots.await_completed( self._id, @@ -110,7 +111,6 @@ def await_completed( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - idempotency_key=idempotency_key, ) def create_devbox( @@ -132,8 +132,6 @@ def create_devbox( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> Devbox: - from ._sync import DevboxClient - devbox_client = DevboxClient(self._client) return devbox_client.create_from_snapshot( self._id, diff --git a/src/runloop_api_client/sdk/storage_object.py b/src/runloop_api_client/sdk/storage_object.py index c10bfd309..95103dec3 100644 --- a/src/runloop_api_client/sdk/storage_object.py +++ b/src/runloop_api_client/sdk/storage_object.py @@ -1,16 +1,14 @@ from __future__ import annotations -from typing import Any - from typing_extensions import override import httpx +from .._types import Body, Query, Headers, Timeout, NotGiven, not_given from .._client import Runloop -from .._types import Body, Headers, NotGiven, Query, Timeout, not_given +from ._helpers import UploadData, read_upload_data from ..types.object_view import ObjectView from ..types.object_download_url_view import ObjectDownloadURLView -from ._helpers import UploadData, read_upload_data class StorageObject: @@ -79,7 +77,6 @@ def get_download_url( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, ) -> ObjectDownloadURLView: if duration_seconds is None: return self._client.objects.download( @@ -148,7 +145,7 @@ def delete( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Any: + ) -> ObjectView: return self._client.objects.delete( self._id, extra_headers=extra_headers, From 6157d7f5198a7ac4d026a6d9e95376764f37edeb Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 10 Nov 2025 18:10:33 -0800 Subject: [PATCH 10/56] lint fixes --- tests/api_resources/devboxes/test_disk_snapshots.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/api_resources/devboxes/test_disk_snapshots.py b/tests/api_resources/devboxes/test_disk_snapshots.py index 6221c8647..d7ae59acd 100644 --- a/tests/api_resources/devboxes/test_disk_snapshots.py +++ b/tests/api_resources/devboxes/test_disk_snapshots.py @@ -12,11 +12,11 @@ from runloop_api_client import Runloop, AsyncRunloop from runloop_api_client.types import DevboxSnapshotView from runloop_api_client.pagination import SyncDiskSnapshotsCursorIDPage, AsyncDiskSnapshotsCursorIDPage +from runloop_api_client._exceptions import RunloopError +from runloop_api_client.lib.polling import PollingConfig, PollingTimeout from runloop_api_client.types.devboxes import ( DevboxSnapshotAsyncStatusView, ) -from runloop_api_client._exceptions import RunloopError -from runloop_api_client.lib.polling import PollingConfig, PollingTimeout base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") From 0cf70bfddeb6b7901b01e2efd46ea8aea9942783 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 10 Nov 2025 18:20:26 -0800 Subject: [PATCH 11/56] circular import fixes --- src/runloop_api_client/sdk/blueprint.py | 10 ++++++---- src/runloop_api_client/sdk/snapshot.py | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/runloop_api_client/sdk/blueprint.py b/src/runloop_api_client/sdk/blueprint.py index b23bcc9e2..c3327c2c6 100644 --- a/src/runloop_api_client/sdk/blueprint.py +++ b/src/runloop_api_client/sdk/blueprint.py @@ -1,11 +1,11 @@ from __future__ import annotations -from typing import Dict, Iterable, Optional +from typing import TYPE_CHECKING, Dict, Iterable, Optional from typing_extensions import override -from ._sync import DevboxClient +if TYPE_CHECKING: + from .devbox import Devbox from ..types import BlueprintView -from .devbox import Devbox from .._types import NOT_GIVEN, Body, Query, Headers, Timeout, NotGiven, not_given from .._client import Runloop from ..lib.polling import PollingConfig @@ -101,7 +101,9 @@ def create_devbox( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Devbox: + ) -> "Devbox": + from ._sync import DevboxClient + devbox_client = DevboxClient(self._client) return devbox_client.create_from_blueprint_id( self._id, diff --git a/src/runloop_api_client/sdk/snapshot.py b/src/runloop_api_client/sdk/snapshot.py index 923386d9b..aface6635 100644 --- a/src/runloop_api_client/sdk/snapshot.py +++ b/src/runloop_api_client/sdk/snapshot.py @@ -3,8 +3,6 @@ from typing import TYPE_CHECKING, Dict, Iterable, Optional from typing_extensions import override -from ._sync import DevboxClient - if TYPE_CHECKING: from .devbox import Devbox from .._types import NOT_GIVEN, Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given @@ -131,7 +129,9 @@ def create_devbox( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> Devbox: + ) -> "Devbox": + from ._sync import DevboxClient + devbox_client = DevboxClient(self._client) return devbox_client.create_from_snapshot( self._id, From e4154edc00df57218cd38b627932fbe0e5722dc8 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 10 Nov 2025 18:23:50 -0800 Subject: [PATCH 12/56] circular import fixes (async) --- src/runloop_api_client/sdk/async_blueprint.py | 10 ++++++---- src/runloop_api_client/sdk/async_snapshot.py | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/runloop_api_client/sdk/async_blueprint.py b/src/runloop_api_client/sdk/async_blueprint.py index 9205f8590..2ea5309d7 100644 --- a/src/runloop_api_client/sdk/async_blueprint.py +++ b/src/runloop_api_client/sdk/async_blueprint.py @@ -1,14 +1,14 @@ from __future__ import annotations -from typing import Dict, Iterable, Optional +from typing import TYPE_CHECKING, Dict, Iterable, Optional from typing_extensions import override +if TYPE_CHECKING: + from .async_devbox import AsyncDevbox from ..types import BlueprintView -from ._async import AsyncDevboxClient from .._types import NOT_GIVEN, Body, Query, Headers, Timeout, NotGiven, not_given from .._client import AsyncRunloop from ..lib.polling import PollingConfig -from .async_devbox import AsyncDevbox from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView from ..types.shared_params.launch_parameters import LaunchParameters from ..types.shared_params.code_mount_parameters import CodeMountParameters @@ -101,7 +101,9 @@ async def create_devbox( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> AsyncDevbox: + ) -> "AsyncDevbox": + from ._async import AsyncDevboxClient + devbox_client = AsyncDevboxClient(self._client) return await devbox_client.create_from_blueprint_id( self._id, diff --git a/src/runloop_api_client/sdk/async_snapshot.py b/src/runloop_api_client/sdk/async_snapshot.py index 058698251..5d4001e3a 100644 --- a/src/runloop_api_client/sdk/async_snapshot.py +++ b/src/runloop_api_client/sdk/async_snapshot.py @@ -3,8 +3,6 @@ from typing import TYPE_CHECKING, Dict, Iterable, Optional from typing_extensions import override -from ._async import AsyncDevboxClient - if TYPE_CHECKING: from .async_devbox import AsyncDevbox from .._types import NOT_GIVEN, Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given @@ -131,7 +129,9 @@ async def create_devbox( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, - ) -> AsyncDevbox: + ) -> "AsyncDevbox": + from ._async import AsyncDevboxClient + devbox_client = AsyncDevboxClient(self._client) return await devbox_client.create_from_snapshot( self._id, From aeb0b3aa7b31c41f1d1d3514a453966aafbc3041 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 10 Nov 2025 18:39:35 -0800 Subject: [PATCH 13/56] lint fix --- tests/smoketests/test_devboxes.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/smoketests/test_devboxes.py b/tests/smoketests/test_devboxes.py index c60b98c7b..d7bbba3c3 100644 --- a/tests/smoketests/test_devboxes.py +++ b/tests/smoketests/test_devboxes.py @@ -111,5 +111,3 @@ def test_await_suspended(client: Runloop) -> None: # Cleanup client.devboxes.shutdown(created.id) - - From d1b0b8313ca725c3cc361a3ffe40a08753b8c2ce Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 11 Nov 2025 13:20:55 -0800 Subject: [PATCH 14/56] unit tests --- .coverage | Bin 0 -> 53248 bytes pyproject.toml | 3 +- tests/sdk/conftest.py | 156 ++++ tests/sdk/test_async_blueprint.py | 90 ++ tests/sdk/test_async_clients.py | 354 ++++++++ tests/sdk/test_async_devbox.py | 901 ++++++++++++++----- tests/sdk/test_async_execution.py | 297 +++++++ tests/sdk/test_async_execution_result.py | 143 +++ tests/sdk/test_async_resources.py | 115 --- tests/sdk/test_async_snapshot.py | 114 +++ tests/sdk/test_async_storage_object.py | 269 ++++++ tests/sdk/test_blueprint.py | 106 +++ tests/sdk/test_clients.py | 338 +++++++ tests/sdk/test_devbox.py | 1020 ++++++++++++++++++---- tests/sdk/test_execution.py | 248 ++++++ tests/sdk/test_execution_result.py | 137 +++ tests/sdk/test_imports.py | 28 - tests/sdk/test_resources.py | 111 --- tests/sdk/test_snapshot.py | 141 +++ tests/sdk/test_storage_object.py | 343 ++++++++ uv.lock | 247 ++++++ 21 files changed, 4513 insertions(+), 648 deletions(-) create mode 100644 .coverage create mode 100644 tests/sdk/conftest.py create mode 100644 tests/sdk/test_async_blueprint.py create mode 100644 tests/sdk/test_async_clients.py create mode 100644 tests/sdk/test_async_execution.py create mode 100644 tests/sdk/test_async_execution_result.py delete mode 100644 tests/sdk/test_async_resources.py create mode 100644 tests/sdk/test_async_snapshot.py create mode 100644 tests/sdk/test_async_storage_object.py create mode 100644 tests/sdk/test_blueprint.py create mode 100644 tests/sdk/test_clients.py create mode 100644 tests/sdk/test_execution.py create mode 100644 tests/sdk/test_execution_result.py delete mode 100644 tests/sdk/test_imports.py delete mode 100644 tests/sdk/test_resources.py create mode 100644 tests/sdk/test_snapshot.py create mode 100644 tests/sdk/test_storage_object.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..43b330a8224224b2acd578427dd6aa9f1d8df9d3 GIT binary patch literal 53248 zcmeI4e{2)y8OQHz$BuuT-;|JLAq}~yq9G)<$x?odXbaiKq-t~yD?cg-n6rIJ4(vPQ zJ3~?do=YoKq)AmP4UPTCq)D4JX#;fvra}|J#3nIqUH=*gO1cfEYF(GL>Yy~ix97d{ z*$$Al$d(Z7x!8X1?tS0yJ9^Dlr7tzYqy>W?tYOY8yTPTyA0XJg-O8oL+)|!o4%y`koU(f!5xDP+>ihg zKmthM|C>N=ozKStbU;Fl&zV&PP^?hGmyM{N<@ryk?Y=HpZ z$ETG&d`e97gHl4|B{?p|1Wl5Mcx_m;N@r9tPA57z2U8s_7&PkBy@(WtTADZn5~;M5 z6w;&oPI0u;C{VbXCXQ(42@nybA(@uo7Z<9x^J#HVOp9_%R81=uOYwG3`+=;-)mUH8 z9uy6qgmerZ-dWzjiIOW%4uVijNyn-6(X=4PhDEiL7m6On6tHtdGrdhK$&jY-2}w5G zlvPQS6qy%C#8^fX<9BtilR~pLfLHDfXlWdsVv6NyTiTHJBZ3Yy~SIYNi#&mA|km&^_l+KUniUKupH46V(ZjRVnK-e*D< z3?WTB8m|-zf~EI0+NiN$10PK&(b=(PM2spz2Z6D`DmIvrW3;QN)Q6a|TTBZ>Vz7{F z4h#1?kRWur+|I@|t#gG=F$5xA#a&OX$?0lrZf2iz8(~yvtwQ3O6+%`^-enlIvXY{p zX42L`@=1fFRiiXYT79ID1MUP|&c?pxx!fs~i*yxsYI81!tFfVh&6z!dj!X1eGs_8j zrql&)n+QI;`6)pg=I^wfo(99)YMhNd4RaYzO^9^SSLcB(HpAqDO$PlW4EpqGpJg&V zQ!@FR;%H#Ekbu!7B}i%LvM>glGXPHu#sw%WM3szY#4kNG!jsNDC3{VQ(y_%JO0^rs zp?mi$sw%;>N~bM#m35YH8x~YL3aM>;R8bP5Ae(ao)Ob>bYf36_*l_2p)LYU}N2$ip z?l!|FYjUB({CengR{w&XWKxjhh53q3!=@^X{j_t^>zIQGROH*BQ>xHKZ_~6y3%PW} zvh;%~U_w=sLfq``v{A|w_Y8}7$eFXz*9A?~q@>8xB8D+WVC@l9K9&|C6Xw$PHv1=q z0d1{F7k#CxZOPzzs^@KL4aD#A2mExgvMouV7DkP+&KrA7NvKB~< zYQu^gQqwUjHv+j4GdHBhcZLLYRE|YdO`*5`N0jIeF{Z&C#2X2ScU4C`3b%E`&xy!X zcY_~vL)Fn$Wg(>wD^zwX2eQ4@ku`r20&cmEq(wE8FlhLyN24eYWSx{|qf?#*0g2nbO zg;Re`HGD4$LnDP9)$lzsEGFRRgH#$sb`Y#Mh>9_})&_Ddm7j+hBuiQ( z0)q7W|2oeJh8*+#$$yqaz4g9P-xs`Ry*oW8IE~c%Q~pNocU;T;8tU=7NB{{S0VIF~ zkN^@u0!V;i1{}E_w$k_1bFBaCHaT)#RT3{>q>+t|T&POAWlJ^xHb<_vO0vauRcXP- zRgp$f9@ZVc^^ROemCP=#wY=YQz%Q;<8W+X&zo#lKcE|ePz0Q$aRI!!ip;g=O$URoE zVp&kRo^j;5D;6xacPX5lRq?$j41rWr72nI&|Bk8zQ86a%PdRce6{ED)|F$r+w7cR6 zFRlOa_y2G{MFL0w2_OL^fCP{L59tWQKtUZb$$LAOR$R z1dsp{Kmter2_OL^fCP}hLrB1G=iD6kFQ$$=#FFb|yZ;P#$p15cKX=LB?QbDYKOrsT zr`(7Bm;AYh(0z0X2_OL^fCP{L57<@NKcOE#b)ezT*}qO4v*|6tyrSQ@sCutY{4F~@77mVQ&z{WJPfnb@ z(Hoo``zZh7RBL!%4IA#DstfI)+I8iVuQlVdaa^HWD>i5|=TeexN0AGtCxGnt?I zLEAsG=k)8nV*`8j=apM+?J`5>*gKLUdrpg870iR7Jq`NbqD>M zn^r=`6aLNFZ(P`VC7iz%9`C)Gzp5YAb=J2GvKDYh9=ZJ1TbKOvm%`B!-)lpc_x-+h z?;ic~*~{;~+uR(2vn%U<@y5uW!#7@;Qf}t+SEpZ}*&iA2op3D(K<0Ay?98qECk{`& zal?OsX>dR4Jkpoi`OD)Q0>re}41(~N$r?By?C9M4{a20s$WPv{fho5)^_o}8J| zJ<~62yU_igYg4VvmF0cyhJa_`d*_N5Co(*L6SI@~eC-sKw-q()}z5R#D zUj1~ke?_&!ZC)+waJ%O(_$bR7_y3cZ88S`&L4HNvBR?lUA>Sv*$;;$Ja)G=_&XeDg zNpkvAI|UX%0!RP}AOR$R1dsp{Kmter2_OL^fCTP=fD=B7VBG;DadjGrv&TqkRvC%C z!$@kE7)f2oNc<~}gj;4LzNJRuU121o+emEfM&bw>iD$V3J_B&m`~R7`doT}+A^{|T z1dsp{Kmter2_OL^fCP{L5=6.7.0", "rich>=13.7.1", "pytest-xdist>=3.6.1", - "uuid-utils>=0.11.0" + "uuid-utils>=0.11.0", + "pytest-cov>=7.0.0", ] [tool.rye.scripts] diff --git a/tests/sdk/conftest.py b/tests/sdk/conftest.py new file mode 100644 index 000000000..d2644f97f --- /dev/null +++ b/tests/sdk/conftest.py @@ -0,0 +1,156 @@ +"""Shared fixtures and utilities for SDK tests.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any +from unittest.mock import Mock, AsyncMock + +import httpx +import pytest + +from runloop_api_client import Runloop, AsyncRunloop + + +def create_mock_httpx_client(methods: dict[str, Any] | None = None) -> AsyncMock: + """ + Create a mock httpx.AsyncClient with proper context manager setup. + + Args: + methods: Optional dict of method names to AsyncMock return values. + Common keys: 'get', 'put' + + Returns: + Configured AsyncMock for httpx.AsyncClient + """ + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + if methods: + for method_name, return_value in methods.items(): + setattr(mock_client, method_name, AsyncMock(return_value=return_value)) + + return mock_client + + +def create_mock_httpx_response(**attrs: Any) -> Mock: + """ + Create a mock httpx.Response with specified attributes. + + Args: + **attrs: Attributes to set on the mock response. + Common: content, text, encoding + + Returns: + Mock configured with httpx.Response spec and attributes + """ + mock_response = Mock(spec=httpx.Response) + for key, value in attrs.items(): + setattr(mock_response, key, value) + return mock_response + + +@pytest.fixture +def mock_client() -> Mock: + """Create a mock Runloop client.""" + return Mock(spec=Runloop) + + +@pytest.fixture +def mock_async_client() -> AsyncMock: + """Create a mock AsyncRunloop client.""" + return AsyncMock(spec=AsyncRunloop) + + +@pytest.fixture +def devbox_view() -> SimpleNamespace: + """Create a mock DevboxView.""" + return SimpleNamespace( + id="dev_123", + status="running", + name="test-devbox", + ) + + +@pytest.fixture +def execution_view() -> SimpleNamespace: + """Create a mock DevboxAsyncExecutionDetailView.""" + return SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="", + ) + + +@pytest.fixture +def snapshot_view() -> SimpleNamespace: + """Create a mock DevboxSnapshotView.""" + return SimpleNamespace( + id="snap_123", + status="completed", + name="test-snapshot", + ) + + +@pytest.fixture +def blueprint_view() -> SimpleNamespace: + """Create a mock BlueprintView.""" + return SimpleNamespace( + id="bp_123", + status="built", + name="test-blueprint", + ) + + +@pytest.fixture +def object_view() -> SimpleNamespace: + """Create a mock ObjectView.""" + return SimpleNamespace( + id="obj_123", + upload_url="https://upload.example.com/obj_123", + name="test-object", + ) + + +@pytest.fixture +def mock_httpx_response() -> Mock: + """Create a mock httpx.Response.""" + response = Mock(spec=httpx.Response) + response.status_code = 200 + response.content = b"test content" + response.text = "test content" + response.encoding = "utf-8" + response.raise_for_status = Mock() + return response + + +@pytest.fixture +def mock_stream() -> Mock: + """Create a mock Stream for testing.""" + stream = Mock() + stream.__iter__ = Mock(return_value=iter([])) + stream.__enter__ = Mock(return_value=stream) + stream.__exit__ = Mock(return_value=None) + stream.close = Mock() + return stream + + +@pytest.fixture +def mock_async_stream() -> AsyncMock: + """Create a mock AsyncStream for testing.""" + + async def async_iter(): + # Empty async iterator + if False: + yield + + stream = AsyncMock() + stream.__aiter__ = Mock(return_value=async_iter()) + stream.__aenter__ = AsyncMock(return_value=stream) + stream.__aexit__ = AsyncMock(return_value=None) + stream.close = AsyncMock() + return stream diff --git a/tests/sdk/test_async_blueprint.py b/tests/sdk/test_async_blueprint.py new file mode 100644 index 000000000..b549d8e3b --- /dev/null +++ b/tests/sdk/test_async_blueprint.py @@ -0,0 +1,90 @@ +"""Comprehensive tests for async Blueprint class.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from runloop_api_client.sdk import AsyncBlueprint + + +class TestAsyncBlueprint: + """Tests for AsyncBlueprint class.""" + + def test_init(self, mock_async_client: AsyncMock) -> None: + """Test AsyncBlueprint initialization.""" + blueprint = AsyncBlueprint(mock_async_client, "bp_123") + assert blueprint.id == "bp_123" + + def test_repr(self, mock_async_client: AsyncMock) -> None: + """Test AsyncBlueprint string representation.""" + blueprint = AsyncBlueprint(mock_async_client, "bp_123") + assert repr(blueprint) == "" + + @pytest.mark.asyncio + async def test_get_info(self, mock_async_client: AsyncMock, blueprint_view: SimpleNamespace) -> None: + """Test get_info method.""" + mock_async_client.blueprints.retrieve = AsyncMock(return_value=blueprint_view) + + blueprint = AsyncBlueprint(mock_async_client, "bp_123") + result = await blueprint.get_info( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == blueprint_view + mock_async_client.blueprints.retrieve.assert_called_once() + + @pytest.mark.asyncio + async def test_logs(self, mock_async_client: AsyncMock) -> None: + """Test logs method.""" + logs_view = SimpleNamespace(logs=[]) + mock_async_client.blueprints.logs = AsyncMock(return_value=logs_view) + + blueprint = AsyncBlueprint(mock_async_client, "bp_123") + result = await blueprint.logs( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == logs_view + mock_async_client.blueprints.logs.assert_called_once() + + @pytest.mark.asyncio + async def test_delete(self, mock_async_client: AsyncMock) -> None: + """Test delete method.""" + # Return value not used - testing side effect only + mock_async_client.blueprints.delete = AsyncMock(return_value=object()) + + blueprint = AsyncBlueprint(mock_async_client, "bp_123") + result = await blueprint.delete( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result is not None + mock_async_client.blueprints.delete.assert_called_once() + + @pytest.mark.asyncio + async def test_create_devbox(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test create_devbox method.""" + mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) + + blueprint = AsyncBlueprint(mock_async_client, "bp_123") + devbox = await blueprint.create_devbox( + name="test-devbox", + metadata={"key": "value"}, + polling_config=None, + extra_headers={"X-Custom": "value"}, + ) + + assert devbox.id == "dev_123" + mock_async_client.devboxes.create_and_await_running.assert_called_once() diff --git a/tests/sdk/test_async_clients.py b/tests/sdk/test_async_clients.py new file mode 100644 index 000000000..951056f46 --- /dev/null +++ b/tests/sdk/test_async_clients.py @@ -0,0 +1,354 @@ +"""Comprehensive tests for async client classes.""" + +from __future__ import annotations + +import tempfile +from types import SimpleNamespace +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from tests.sdk.conftest import create_mock_httpx_client, create_mock_httpx_response +from runloop_api_client.sdk import AsyncDevbox, AsyncSnapshot, AsyncBlueprint, AsyncStorageObject +from runloop_api_client.sdk._async import ( + AsyncRunloopSDK, + AsyncDevboxClient, + AsyncSnapshotClient, + AsyncBlueprintClient, + AsyncStorageObjectClient, +) +from runloop_api_client.lib.polling import PollingConfig + + +class TestAsyncDevboxClient: + """Tests for AsyncDevboxClient class.""" + + @pytest.mark.asyncio + async def test_create(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test create method.""" + mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) + + client = AsyncDevboxClient(mock_async_client) + devbox = await client.create( + name="test-devbox", + metadata={"key": "value"}, + polling_config=PollingConfig(timeout_seconds=60.0), + ) + + assert isinstance(devbox, AsyncDevbox) + assert devbox.id == "dev_123" + mock_async_client.devboxes.create_and_await_running.assert_called_once() + + @pytest.mark.asyncio + async def test_create_from_blueprint_id(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test create_from_blueprint_id method.""" + mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) + + client = AsyncDevboxClient(mock_async_client) + devbox = await client.create_from_blueprint_id( + "bp_123", + name="test-devbox", + ) + + assert isinstance(devbox, AsyncDevbox) + call_kwargs = mock_async_client.devboxes.create_and_await_running.call_args[1] + assert call_kwargs["blueprint_id"] == "bp_123" + + @pytest.mark.asyncio + async def test_create_from_blueprint_name(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test create_from_blueprint_name method.""" + mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) + + client = AsyncDevboxClient(mock_async_client) + devbox = await client.create_from_blueprint_name( + "my-blueprint", + name="test-devbox", + ) + + assert isinstance(devbox, AsyncDevbox) + call_kwargs = mock_async_client.devboxes.create_and_await_running.call_args[1] + assert call_kwargs["blueprint_name"] == "my-blueprint" + + @pytest.mark.asyncio + async def test_create_from_snapshot(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test create_from_snapshot method.""" + mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) + + client = AsyncDevboxClient(mock_async_client) + devbox = await client.create_from_snapshot( + "snap_123", + name="test-devbox", + ) + + assert isinstance(devbox, AsyncDevbox) + call_kwargs = mock_async_client.devboxes.create_and_await_running.call_args[1] + assert call_kwargs["snapshot_id"] == "snap_123" + + def test_from_id(self, mock_async_client: AsyncMock) -> None: + """Test from_id method.""" + client = AsyncDevboxClient(mock_async_client) + devbox = client.from_id("dev_123") + + assert isinstance(devbox, AsyncDevbox) + assert devbox.id == "dev_123" + # Verify from_id does not wait for running status + if hasattr(mock_async_client.devboxes, "await_running"): + assert not mock_async_client.devboxes.await_running.called + + @pytest.mark.asyncio + async def test_list(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test list method.""" + page = SimpleNamespace(devboxes=[devbox_view]) + mock_async_client.devboxes.list = AsyncMock(return_value=page) + + client = AsyncDevboxClient(mock_async_client) + devboxes = await client.list( + limit=10, + status="running", + starting_after="dev_000", + ) + + assert len(devboxes) == 1 + assert isinstance(devboxes[0], AsyncDevbox) + assert devboxes[0].id == "dev_123" + mock_async_client.devboxes.list.assert_called_once() + + +class TestAsyncSnapshotClient: + """Tests for AsyncSnapshotClient class.""" + + @pytest.mark.asyncio + async def test_list(self, mock_async_client: AsyncMock, snapshot_view: SimpleNamespace) -> None: + """Test list method.""" + page = SimpleNamespace(disk_snapshots=[snapshot_view]) + mock_async_client.devboxes.disk_snapshots.list = AsyncMock(return_value=page) + + client = AsyncSnapshotClient(mock_async_client) + snapshots = await client.list( + devbox_id="dev_123", + limit=10, + starting_after="snap_000", + ) + + assert len(snapshots) == 1 + assert isinstance(snapshots[0], AsyncSnapshot) + assert snapshots[0].id == "snap_123" + mock_async_client.devboxes.disk_snapshots.list.assert_called_once() + + def test_from_id(self, mock_async_client: AsyncMock) -> None: + """Test from_id method.""" + client = AsyncSnapshotClient(mock_async_client) + snapshot = client.from_id("snap_123") + + assert isinstance(snapshot, AsyncSnapshot) + assert snapshot.id == "snap_123" + + +class TestAsyncBlueprintClient: + """Tests for AsyncBlueprintClient class.""" + + @pytest.mark.asyncio + async def test_create(self, mock_async_client: AsyncMock, blueprint_view: SimpleNamespace) -> None: + """Test create method.""" + mock_async_client.blueprints.create_and_await_build_complete = AsyncMock(return_value=blueprint_view) + + client = AsyncBlueprintClient(mock_async_client) + blueprint = await client.create( + name="test-blueprint", + polling_config=PollingConfig(timeout_seconds=60.0), + ) + + assert isinstance(blueprint, AsyncBlueprint) + assert blueprint.id == "bp_123" + mock_async_client.blueprints.create_and_await_build_complete.assert_called_once() + + def test_from_id(self, mock_async_client: AsyncMock) -> None: + """Test from_id method.""" + client = AsyncBlueprintClient(mock_async_client) + blueprint = client.from_id("bp_123") + + assert isinstance(blueprint, AsyncBlueprint) + assert blueprint.id == "bp_123" + + @pytest.mark.asyncio + async def test_list(self, mock_async_client: AsyncMock, blueprint_view: SimpleNamespace) -> None: + """Test list method.""" + page = SimpleNamespace(blueprints=[blueprint_view]) + mock_async_client.blueprints.list = AsyncMock(return_value=page) + + client = AsyncBlueprintClient(mock_async_client) + blueprints = await client.list( + limit=10, + name="test", + starting_after="bp_000", + ) + + assert len(blueprints) == 1 + assert isinstance(blueprints[0], AsyncBlueprint) + assert blueprints[0].id == "bp_123" + mock_async_client.blueprints.list.assert_called_once() + + +class TestAsyncStorageObjectClient: + """Tests for AsyncStorageObjectClient class.""" + + @pytest.mark.asyncio + async def test_create(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + """Test create method.""" + mock_async_client.objects.create = AsyncMock(return_value=object_view) + + client = AsyncStorageObjectClient(mock_async_client) + obj = await client.create("test.txt", content_type="text", metadata={"key": "value"}) + + assert isinstance(obj, AsyncStorageObject) + assert obj.id == "obj_123" + assert obj.upload_url == "https://upload.example.com/obj_123" + mock_async_client.objects.create.assert_called_once() + + @pytest.mark.asyncio + async def test_create_auto_detect_content_type( + self, mock_async_client: AsyncMock, object_view: SimpleNamespace + ) -> None: + """Test create auto-detects content type.""" + mock_async_client.objects.create = AsyncMock(return_value=object_view) + + client = AsyncStorageObjectClient(mock_async_client) + obj = await client.create("test.txt") + + assert isinstance(obj, AsyncStorageObject) + call_kwargs = mock_async_client.objects.create.call_args[1] + assert call_kwargs["content_type"] == "text" + + def test_from_id(self, mock_async_client: AsyncMock) -> None: + """Test from_id method.""" + client = AsyncStorageObjectClient(mock_async_client) + obj = client.from_id("obj_123") + + assert isinstance(obj, AsyncStorageObject) + assert obj.id == "obj_123" + assert obj.upload_url is None + + @pytest.mark.asyncio + async def test_list(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + """Test list method.""" + page = SimpleNamespace(objects=[object_view]) + mock_async_client.objects.list = AsyncMock(return_value=page) + + client = AsyncStorageObjectClient(mock_async_client) + objects = await client.list( + content_type="text", + limit=10, + name="test", + search="query", + starting_after="obj_000", + state="ready", + ) + + assert len(objects) == 1 + assert isinstance(objects[0], AsyncStorageObject) + assert objects[0].id == "obj_123" + mock_async_client.objects.list.assert_called_once() + + @pytest.mark.asyncio + async def test_upload_from_file(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + """Test upload_from_file method.""" + mock_async_client.objects.create = AsyncMock(return_value=object_view) + mock_async_client.objects.complete = AsyncMock(return_value=object_view) + + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: + f.write("test content") + temp_path = Path(f.name) + + try: + with patch("httpx.AsyncClient") as mock_client_class: + mock_response = create_mock_httpx_response() + mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) + mock_client_class.return_value = mock_http_client + + client = AsyncStorageObjectClient(mock_async_client) + obj = await client.upload_from_file(temp_path, name="test.txt") + + assert isinstance(obj, AsyncStorageObject) + assert obj.id == "obj_123" + mock_async_client.objects.create.assert_called_once() + mock_async_client.objects.complete.assert_called_once() + finally: + temp_path.unlink() + + @pytest.mark.asyncio + async def test_upload_from_text(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + """Test upload_from_text method.""" + mock_async_client.objects.create = AsyncMock(return_value=object_view) + mock_async_client.objects.complete = AsyncMock(return_value=object_view) + + with patch("httpx.AsyncClient") as mock_client_class: + mock_response = create_mock_httpx_response() + mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) + mock_client_class.return_value = mock_http_client + + client = AsyncStorageObjectClient(mock_async_client) + obj = await client.upload_from_text("test content", "test.txt", metadata={"key": "value"}) + + assert isinstance(obj, AsyncStorageObject) + assert obj.id == "obj_123" + mock_async_client.objects.create.assert_called_once() + call_kwargs = mock_async_client.objects.create.call_args[1] + assert call_kwargs["content_type"] == "text" + mock_async_client.objects.complete.assert_called_once() + + @pytest.mark.asyncio + async def test_upload_from_bytes(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + """Test upload_from_bytes method.""" + mock_async_client.objects.create = AsyncMock(return_value=object_view) + mock_async_client.objects.complete = AsyncMock(return_value=object_view) + + with patch("httpx.AsyncClient") as mock_client_class: + mock_response = create_mock_httpx_response() + mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) + mock_client_class.return_value = mock_http_client + + client = AsyncStorageObjectClient(mock_async_client) + obj = await client.upload_from_bytes(b"test content", "test.bin", content_type="binary") + + assert isinstance(obj, AsyncStorageObject) + assert obj.id == "obj_123" + mock_async_client.objects.create.assert_called_once() + call_kwargs = mock_async_client.objects.create.call_args[1] + assert call_kwargs["content_type"] == "binary" + mock_async_client.objects.complete.assert_called_once() + + +class TestAsyncRunloopSDK: + """Tests for AsyncRunloopSDK class.""" + + def test_init(self) -> None: + """Test AsyncRunloopSDK initialization.""" + sdk = AsyncRunloopSDK(bearer_token="test-token") + assert sdk.api is not None + assert isinstance(sdk.devbox, AsyncDevboxClient) + assert isinstance(sdk.snapshot, AsyncSnapshotClient) + assert isinstance(sdk.blueprint, AsyncBlueprintClient) + assert isinstance(sdk.storage_object, AsyncStorageObjectClient) + + @pytest.mark.asyncio + async def test_aclose(self) -> None: + """Test aclose method.""" + sdk = AsyncRunloopSDK(bearer_token="test-token") + # Verify aclose doesn't raise + await sdk.aclose() + + @pytest.mark.asyncio + async def test_context_manager(self) -> None: + """Test context manager behavior.""" + async with AsyncRunloopSDK(bearer_token="test-token") as sdk: + assert sdk.api is not None + # Verify context manager properly closes (implementation detail of context manager protocol) + + def test_api_property(self) -> None: + """Test api property access.""" + sdk = AsyncRunloopSDK(bearer_token="test-token") + assert sdk.api is not None + assert hasattr(sdk.api, "devboxes") + assert hasattr(sdk.api, "blueprints") + assert hasattr(sdk.api, "objects") diff --git a/tests/sdk/test_async_devbox.py b/tests/sdk/test_async_devbox.py index 1141db0db..d7dbd7ccd 100644 --- a/tests/sdk/test_async_devbox.py +++ b/tests/sdk/test_async_devbox.py @@ -1,224 +1,689 @@ +"""Comprehensive tests for async Devbox class.""" + from __future__ import annotations +import asyncio +import tempfile from types import SimpleNamespace -from typing import List +from typing import AsyncIterator +from pathlib import Path +from unittest.mock import Mock, AsyncMock +import httpx import pytest -from runloop_api_client import AsyncRunloopSDK -from runloop_api_client.sdk import AsyncDevbox, AsyncExecution, AsyncExecutionResult - - -@pytest.fixture() -async def async_sdk() -> AsyncRunloopSDK: - sdk = AsyncRunloopSDK(bearer_token="test-token") - try: - yield sdk - finally: - await sdk.aclose() - - -@pytest.mark.asyncio -async def test_async_create_returns_devbox(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: - devboxes_resource = async_sdk.api.devboxes - - async def fake_create_and_await_running(**_kwargs): - return SimpleNamespace(id="adev_1") - - monkeypatch.setattr(devboxes_resource, "create_and_await_running", fake_create_and_await_running) - - devbox = await async_sdk.devbox.create(name="async") - - assert isinstance(devbox, AsyncDevbox) - assert devbox.id == "adev_1" - - -@pytest.mark.asyncio -async def test_async_context_manager(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: - devboxes_resource = async_sdk.api.devboxes - calls: List[str] = [] - - async def fake_shutdown(devbox_id: str, **_kwargs): - calls.append(devbox_id) - - monkeypatch.setattr(devboxes_resource, "shutdown", fake_shutdown) - - async with async_sdk.devbox.from_id("adev_ctx") as devbox: - assert devbox.id == "adev_ctx" - - assert calls == ["adev_ctx"] - - -@pytest.mark.asyncio -async def test_async_exec_without_streaming(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: - devboxes_resource = async_sdk.api.devboxes - - result = SimpleNamespace( - execution_id="exec-async-1", - devbox_id="adev_exec", - stdout="async hello", - stderr="", - exit_status=0, - status="completed", - ) - - async def fake_execute_and_await_completion(devbox_id: str, **kwargs): - assert devbox_id == "adev_exec" - assert kwargs["command"] == "echo hi" - return result - - monkeypatch.setattr(devboxes_resource, "execute_and_await_completion", fake_execute_and_await_completion) - - devbox = async_sdk.devbox.from_id("adev_exec") - execution_result = await devbox.cmd.exec("echo hi") - - assert isinstance(execution_result, AsyncExecutionResult) - assert execution_result.exit_code == 0 - assert await execution_result.stdout() == "async hello" - - -@pytest.mark.asyncio -async def test_async_exec_with_streaming(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: - devboxes_resource = async_sdk.api.devboxes - executions_resource = devboxes_resource.executions - - execution = SimpleNamespace( - execution_id="exec-stream-async", - devbox_id="adev_stream", - stdout="", - stderr="", - exit_status=None, - status="running", - ) - - final = SimpleNamespace( - execution_id="exec-stream-async", - devbox_id="adev_stream", - stdout="done", - stderr="", - exit_status=0, - status="completed", - ) - - async def fake_execute_async(devbox_id: str, **kwargs): - assert kwargs["command"] == "long async task" - return execution - - async def fake_await_completed(execution_id: str, *, devbox_id: str, **_kwargs): - assert execution_id == "exec-stream-async" - assert devbox_id == "adev_stream" - return final - - class DummyAsyncStream: - def __init__(self, values: list[str]): - self._values = values - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - def __aiter__(self): - async def generator(): - for value in self._values: - yield SimpleNamespace(output=value) - - return generator() - - monkeypatch.setattr(devboxes_resource, "execute_async", fake_execute_async) - monkeypatch.setattr(executions_resource, "await_completed", fake_await_completed) - monkeypatch.setattr( - executions_resource, - "stream_stdout_updates", - lambda execution_id, *, devbox_id: DummyAsyncStream(["a", "b"]), - ) - monkeypatch.setattr( - executions_resource, - "stream_stderr_updates", - lambda execution_id, *, devbox_id: DummyAsyncStream([]), - ) - - stdout_logs: list[str] = [] - combined_logs: list[str] = [] - - async def capture_stdout(line: str) -> None: - stdout_logs.append(line) - - async def capture_output(line: str) -> None: - combined_logs.append(line) - - devbox = async_sdk.devbox.from_id("adev_stream") - result = await devbox.cmd.exec( - "long async task", - stdout=capture_stdout, - output=capture_output, - ) - - assert stdout_logs == ["a", "b"] - assert combined_logs == ["a", "b"] - assert await result.stdout() == "done" - - -@pytest.mark.asyncio -async def test_async_exec_async_returns_execution(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: - devboxes_resource = async_sdk.api.devboxes - executions_resource = devboxes_resource.executions - - execution = SimpleNamespace( - execution_id="exec-async-bg", - devbox_id="adev_bg", - stdout="", - stderr="", - exit_status=None, - status="running", - ) - - final = SimpleNamespace( - execution_id="exec-async-bg", - devbox_id="adev_bg", - stdout="finished", - stderr="", - exit_status=0, - status="completed", - ) - - async def fake_execute_async(devbox_id: str, **kwargs): - return execution - - async def fake_await_completed(execution_id: str, *, devbox_id: str, **_kwargs): - return final - - class EmptyAsyncStream: - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - def __aiter__(self): - async def generator(): - if False: - yield # pragma: no cover - - return generator() - - monkeypatch.setattr(devboxes_resource, "execute_async", fake_execute_async) - monkeypatch.setattr(executions_resource, "await_completed", fake_await_completed) - monkeypatch.setattr( - executions_resource, - "stream_stdout_updates", - lambda execution_id, *, devbox_id: EmptyAsyncStream(), - ) - monkeypatch.setattr( - executions_resource, - "stream_stderr_updates", - lambda execution_id, *, devbox_id: EmptyAsyncStream(), - ) - - devbox = async_sdk.devbox.from_id("adev_bg") - execution_obj = await devbox.cmd.exec_async("background async task") - - assert isinstance(execution_obj, AsyncExecution) - result = await execution_obj.result() - assert await result.stdout() == "finished" +from runloop_api_client.sdk import AsyncDevbox +from runloop_api_client._types import NotGiven +from runloop_api_client._streaming import AsyncStream +from runloop_api_client.lib.polling import PollingConfig +from runloop_api_client.sdk.async_devbox import ( + _AsyncFileInterface, + _AsyncCommandInterface, + _AsyncNetworkInterface, +) +from runloop_api_client.sdk.async_execution import _AsyncStreamingGroup + + +class TestAsyncDevbox: + """Tests for AsyncDevbox class.""" + + def test_init(self, mock_async_client: AsyncMock) -> None: + """Test AsyncDevbox initialization.""" + devbox = AsyncDevbox(mock_async_client, "dev_123") + assert devbox.id == "dev_123" + + def test_repr(self, mock_async_client: AsyncMock) -> None: + """Test AsyncDevbox string representation.""" + devbox = AsyncDevbox(mock_async_client, "dev_123") + assert repr(devbox) == "" + + @pytest.mark.asyncio + async def test_context_manager_enter_exit(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test context manager behavior with successful shutdown.""" + mock_async_client.devboxes.shutdown = AsyncMock(return_value=devbox_view) + + async with AsyncDevbox(mock_async_client, "dev_123") as devbox: + assert devbox.id == "dev_123" + + call_kwargs = mock_async_client.devboxes.shutdown.call_args[1] + assert isinstance(call_kwargs["timeout"], NotGiven) + + @pytest.mark.asyncio + async def test_context_manager_exception_handling(self, mock_async_client: AsyncMock) -> None: + """Test context manager handles exceptions during shutdown.""" + mock_async_client.devboxes.shutdown = AsyncMock(side_effect=RuntimeError("Shutdown failed")) + + with pytest.raises(ValueError, match="Test error"): + async with AsyncDevbox(mock_async_client, "dev_123"): + raise ValueError("Test error") + + # Shutdown should be called even when body raises exception + mock_async_client.devboxes.shutdown.assert_called_once() + + @pytest.mark.asyncio + async def test_get_info(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test get_info method.""" + mock_async_client.devboxes.retrieve = AsyncMock(return_value=devbox_view) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.get_info( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == devbox_view + mock_async_client.devboxes.retrieve.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + @pytest.mark.asyncio + async def test_await_running(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test await_running method.""" + mock_async_client.devboxes.await_running = AsyncMock(return_value=devbox_view) + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.await_running(polling_config=polling_config) + + assert result == devbox_view + mock_async_client.devboxes.await_running.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + @pytest.mark.asyncio + async def test_await_suspended(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test await_suspended method.""" + mock_async_client.devboxes.await_suspended = AsyncMock(return_value=devbox_view) + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.await_suspended(polling_config=polling_config) + + assert result == devbox_view + mock_async_client.devboxes.await_suspended.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + @pytest.mark.asyncio + async def test_shutdown(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test shutdown method.""" + mock_async_client.devboxes.shutdown = AsyncMock(return_value=devbox_view) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.shutdown( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == devbox_view + mock_async_client.devboxes.shutdown.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + @pytest.mark.asyncio + async def test_suspend(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test suspend method.""" + mock_async_client.devboxes.suspend = AsyncMock(return_value=None) + mock_async_client.devboxes.await_suspended = AsyncMock(return_value=devbox_view) + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.suspend( + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == devbox_view + mock_async_client.devboxes.suspend.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + mock_async_client.devboxes.await_suspended.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + @pytest.mark.asyncio + async def test_resume(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test resume method.""" + mock_async_client.devboxes.resume = AsyncMock(return_value=None) + mock_async_client.devboxes.await_running = AsyncMock(return_value=devbox_view) + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.resume( + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == devbox_view + mock_async_client.devboxes.resume.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + mock_async_client.devboxes.await_running.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + @pytest.mark.asyncio + async def test_keep_alive(self, mock_async_client: AsyncMock) -> None: + """Test keep_alive method.""" + # Return value not used - testing parameter passing only + mock_async_client.devboxes.keep_alive = AsyncMock(return_value=object()) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.keep_alive( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result is not None + mock_async_client.devboxes.keep_alive.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + @pytest.mark.asyncio + async def test_snapshot_disk(self, mock_async_client: AsyncMock) -> None: + """Test snapshot_disk waits for completion.""" + snapshot_data = SimpleNamespace(id="snap_123") + snapshot_status = SimpleNamespace(status="completed") + + mock_async_client.devboxes.snapshot_disk_async = AsyncMock(return_value=snapshot_data) + mock_async_client.devboxes.disk_snapshots.await_completed = AsyncMock(return_value=snapshot_status) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + polling_config = PollingConfig(timeout_seconds=60.0) + snapshot = await devbox.snapshot_disk( + name="test-snapshot", + metadata={"key": "value"}, + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + ) + + assert snapshot.id == "snap_123" + mock_async_client.devboxes.snapshot_disk_async.assert_called_once() + mock_async_client.devboxes.disk_snapshots.await_completed.assert_called_once() + + @pytest.mark.asyncio + async def test_snapshot_disk_async(self, mock_async_client: AsyncMock) -> None: + """Test snapshot_disk_async returns immediately.""" + snapshot_data = SimpleNamespace(id="snap_123") + mock_async_client.devboxes.snapshot_disk_async = AsyncMock(return_value=snapshot_data) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + snapshot = await devbox.snapshot_disk_async( + name="test-snapshot", + metadata={"key": "value"}, + extra_headers={"X-Custom": "value"}, + ) + + assert snapshot.id == "snap_123" + mock_async_client.devboxes.snapshot_disk_async.assert_called_once() + + @pytest.mark.asyncio + async def test_close(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test close method calls shutdown.""" + mock_async_client.devboxes.shutdown = AsyncMock(return_value=devbox_view) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + await devbox.close() + + mock_async_client.devboxes.shutdown.assert_called_once() + + def test_cmd_property(self, mock_async_client: AsyncMock) -> None: + """Test cmd property returns AsyncCommandInterface.""" + devbox = AsyncDevbox(mock_async_client, "dev_123") + cmd = devbox.cmd + assert isinstance(cmd, _AsyncCommandInterface) + assert cmd._devbox is devbox + + def test_file_property(self, mock_async_client: AsyncMock) -> None: + """Test file property returns AsyncFileInterface.""" + devbox = AsyncDevbox(mock_async_client, "dev_123") + file_interface = devbox.file + assert isinstance(file_interface, _AsyncFileInterface) + assert file_interface._devbox is devbox + + def test_net_property(self, mock_async_client: AsyncMock) -> None: + """Test net property returns AsyncNetworkInterface.""" + devbox = AsyncDevbox(mock_async_client, "dev_123") + net = devbox.net + assert isinstance(net, _AsyncNetworkInterface) + assert net._devbox is devbox + + +class TestAsyncCommandInterface: + """Tests for _AsyncCommandInterface.""" + + @pytest.mark.asyncio + async def test_exec_without_callbacks(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test exec without streaming callbacks.""" + mock_async_client.devboxes.execute_and_await_completion = AsyncMock(return_value=execution_view) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.cmd.exec("echo hello") + + assert result.exit_code == 0 + assert await result.stdout() == "output" + call_kwargs = mock_async_client.devboxes.execute_and_await_completion.call_args[1] + assert call_kwargs["command"] == "echo hello" + assert isinstance(call_kwargs["shell_name"], NotGiven) or call_kwargs["shell_name"] is None + assert isinstance(call_kwargs["timeout"], NotGiven) + + @pytest.mark.asyncio + async def test_exec_with_stdout_callback(self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock) -> None: + """Test exec with stdout callback.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + execution_completed = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="", + ) + + mock_async_client.devboxes.execute_async = AsyncMock(return_value=execution_async) + mock_async_client.devboxes.executions.await_completed = AsyncMock(return_value=execution_completed) + mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) + + stdout_calls: list[str] = [] + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.cmd.exec("echo hello", stdout=stdout_calls.append) + + assert result.exit_code == 0 + mock_async_client.devboxes.execute_async.assert_called_once() + + @pytest.mark.asyncio + async def test_exec_async_returns_execution( + self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock + ) -> None: + """Test exec_async returns AsyncExecution object.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + + mock_async_client.devboxes.execute_async = AsyncMock(return_value=execution_async) + mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + execution = await devbox.cmd.exec_async("long-running command") + + assert execution.execution_id == "exec_123" + assert execution.devbox_id == "dev_123" + mock_async_client.devboxes.execute_async.assert_called_once() + + +class TestAsyncFileInterface: + """Tests for _AsyncFileInterface.""" + + @pytest.mark.asyncio + async def test_read(self, mock_async_client: AsyncMock) -> None: + """Test file read.""" + mock_async_client.devboxes.read_file_contents = AsyncMock(return_value="file content") + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.file.read("/path/to/file") + + assert result == "file content" + mock_async_client.devboxes.read_file_contents.assert_called_once() + + @pytest.mark.asyncio + async def test_write_string(self, mock_async_client: AsyncMock) -> None: + """Test file write with string.""" + execution_detail = SimpleNamespace() + mock_async_client.devboxes.write_file_contents = AsyncMock(return_value=execution_detail) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.file.write("/path/to/file", "content") + + assert result == execution_detail + mock_async_client.devboxes.write_file_contents.assert_called_once() + + @pytest.mark.asyncio + async def test_write_bytes(self, mock_async_client: AsyncMock) -> None: + """Test file write with bytes.""" + execution_detail = SimpleNamespace() + mock_async_client.devboxes.write_file_contents = AsyncMock(return_value=execution_detail) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.file.write("/path/to/file", b"content") + + assert result == execution_detail + mock_async_client.devboxes.write_file_contents.assert_called_once() + + @pytest.mark.asyncio + async def test_download(self, mock_async_client: AsyncMock) -> None: + """Test file download.""" + mock_response = AsyncMock() + mock_response.read = AsyncMock(return_value=b"file content") + mock_async_client.devboxes.download_file = AsyncMock(return_value=mock_response) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.file.download("/path/to/file") + + assert result == b"file content" + mock_async_client.devboxes.download_file.assert_called_once() + + @pytest.mark.asyncio + async def test_upload(self, mock_async_client: AsyncMock) -> None: + """Test file upload.""" + execution_detail = SimpleNamespace() + mock_async_client.devboxes.upload_file = AsyncMock(return_value=execution_detail) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + # Create a temporary file for upload + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("test content") + temp_path = Path(f.name) + + try: + result = await devbox.file.upload("/remote/path", temp_path) + finally: + temp_path.unlink() + + assert result == execution_detail + mock_async_client.devboxes.upload_file.assert_called_once() + + +class TestAsyncNetworkInterface: + """Tests for _AsyncNetworkInterface.""" + + @pytest.mark.asyncio + async def test_create_ssh_key(self, mock_async_client: AsyncMock) -> None: + """Test create SSH key.""" + ssh_key_response = SimpleNamespace(public_key="ssh-rsa ...") + mock_async_client.devboxes.create_ssh_key = AsyncMock(return_value=ssh_key_response) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.net.create_ssh_key( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == ssh_key_response + mock_async_client.devboxes.create_ssh_key.assert_called_once() + + @pytest.mark.asyncio + async def test_create_tunnel(self, mock_async_client: AsyncMock) -> None: + """Test create tunnel.""" + tunnel_view = SimpleNamespace(tunnel_id="tunnel_123") + mock_async_client.devboxes.create_tunnel = AsyncMock(return_value=tunnel_view) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.net.create_tunnel( + port=8080, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == tunnel_view + mock_async_client.devboxes.create_tunnel.assert_called_once() + + @pytest.mark.asyncio + async def test_remove_tunnel(self, mock_async_client: AsyncMock) -> None: + """Test remove tunnel.""" + # Return value not used - testing parameter passing only + mock_async_client.devboxes.remove_tunnel = AsyncMock(return_value=object()) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.net.remove_tunnel( + port=8080, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result is not None + mock_async_client.devboxes.remove_tunnel.assert_called_once() + + +class TestAsyncDevboxStreaming: + """Tests for AsyncDevbox streaming methods.""" + + def test_start_streaming_no_callbacks(self, mock_async_client: AsyncMock) -> None: + """Test _start_streaming returns None when no callbacks.""" + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = devbox._start_streaming("exec_123", stdout=None, stderr=None, output=None) + assert result is None + + @pytest.mark.asyncio + async def test_start_streaming_stdout_only( + self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock + ) -> None: + """Test _start_streaming with stdout callback only.""" + + # Create a proper async iterator + async def async_iter(): + yield SimpleNamespace(output="line 1") + yield SimpleNamespace(output="line 2") + + mock_async_stream.__aiter__ = Mock(return_value=async_iter()) + mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) + mock_async_stream.__aexit__ = AsyncMock(return_value=None) + + mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + stdout_calls: list[str] = [] + result = devbox._start_streaming("exec_123", stdout=stdout_calls.append, stderr=None, output=None) + + assert result is not None + assert isinstance(result, _AsyncStreamingGroup) + assert len(result._tasks) == 1 + # Give the task a moment to start + TASK_START_DELAY = 0.1 + await asyncio.sleep(TASK_START_DELAY) + mock_async_client.devboxes.executions.stream_stdout_updates.assert_called_once() + # Clean up tasks + for task in result._tasks: + task.cancel() + try: + await task + except (Exception, asyncio.CancelledError): + pass + + @pytest.mark.asyncio + async def test_start_streaming_stderr_only( + self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock + ) -> None: + """Test _start_streaming with stderr callback only.""" + + # Create a proper async iterator + async def async_iter(): + yield SimpleNamespace(output="line 1") + yield SimpleNamespace(output="line 2") + + mock_async_stream.__aiter__ = Mock(return_value=async_iter()) + mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) + mock_async_stream.__aexit__ = AsyncMock(return_value=None) + + mock_async_client.devboxes.executions.stream_stderr_updates = AsyncMock(return_value=mock_async_stream) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + stderr_calls: list[str] = [] + result = devbox._start_streaming("exec_123", stdout=None, stderr=stderr_calls.append, output=None) + + assert result is not None + assert isinstance(result, _AsyncStreamingGroup) + assert len(result._tasks) == 1 + # Give the task a moment to start + TASK_START_DELAY = 0.1 + await asyncio.sleep(TASK_START_DELAY) + mock_async_client.devboxes.executions.stream_stderr_updates.assert_called_once() + # Clean up tasks + for task in result._tasks: + task.cancel() + try: + await task + except (Exception, asyncio.CancelledError): + pass + + @pytest.mark.asyncio + async def test_start_streaming_output_only( + self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock + ) -> None: + """Test _start_streaming with output callback only.""" + + # Create a proper async iterator + async def async_iter(): + yield SimpleNamespace(output="line 1") + yield SimpleNamespace(output="line 2") + + mock_async_stream.__aiter__ = Mock(return_value=async_iter()) + mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) + mock_async_stream.__aexit__ = AsyncMock(return_value=None) + + mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) + mock_async_client.devboxes.executions.stream_stderr_updates = AsyncMock(return_value=mock_async_stream) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + output_calls: list[str] = [] + result = devbox._start_streaming("exec_123", stdout=None, stderr=None, output=output_calls.append) + + assert result is not None + assert isinstance(result, _AsyncStreamingGroup) + assert len(result._tasks) == 2 # Both stdout and stderr streams + # Give tasks a moment to start + TASK_START_DELAY = 0.1 + await asyncio.sleep(TASK_START_DELAY) + # Clean up tasks + for task in result._tasks: + task.cancel() + try: + await task + except (Exception, asyncio.CancelledError): + pass + + @pytest.mark.asyncio + async def test_stream_worker(self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock) -> None: + """Test _stream_worker processes chunks.""" + chunks = [ + SimpleNamespace(output="line 1"), + SimpleNamespace(output="line 2"), + ] + + async def async_iter() -> AsyncIterator: + for chunk in chunks: + yield chunk + + mock_async_stream.__aiter__ = Mock(return_value=async_iter()) + mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) + mock_async_stream.__aexit__ = AsyncMock(return_value=None) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + calls: list[str] = [] + + async def stream_factory() -> AsyncStream: + return mock_async_stream + + await devbox._stream_worker( + name="test", + stream_factory=stream_factory, + callbacks=[calls.append], + ) + + # Note: In a real scenario, calls would be populated, but with mocks + # we're mainly testing that the method doesn't raise + + @pytest.mark.asyncio + async def test_stream_worker_cancelled(self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock) -> None: + """Test _stream_worker handles cancellation.""" + LONG_SLEEP = 1.0 + + async def async_iter() -> AsyncIterator: + await asyncio.sleep(LONG_SLEEP) # Long-running + yield SimpleNamespace(output="line") + + mock_async_stream.__aiter__ = Mock(return_value=async_iter()) + mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) + mock_async_stream.__aexit__ = AsyncMock(return_value=None) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + calls: list[str] = [] + + async def stream_factory() -> AsyncStream: + return mock_async_stream + + task = asyncio.create_task( + devbox._stream_worker( + name="test", + stream_factory=stream_factory, + callbacks=[calls.append], + ) + ) + + await asyncio.sleep(0.01) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + +class TestAsyncDevboxErrorHandling: + """Tests for AsyncDevbox error handling scenarios.""" + + @pytest.mark.asyncio + async def test_async_network_error(self, mock_async_client: AsyncMock) -> None: + """Test handling of network errors in async.""" + mock_async_client.devboxes.retrieve = AsyncMock(side_effect=httpx.NetworkError("Connection failed")) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + with pytest.raises(httpx.NetworkError): + await devbox.get_info() diff --git a/tests/sdk/test_async_execution.py b/tests/sdk/test_async_execution.py new file mode 100644 index 000000000..77cb74464 --- /dev/null +++ b/tests/sdk/test_async_execution.py @@ -0,0 +1,297 @@ +"""Comprehensive tests for AsyncExecution class.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from runloop_api_client.sdk.async_execution import AsyncExecution, _AsyncStreamingGroup + +# Test constants +SHORT_SLEEP = 0.01 # Brief pause for task/thread startup +LONG_SLEEP = 1.0 # Simulates long-running operation for cancellation tests + + +class TestAsyncStreamingGroup: + """Tests for _AsyncStreamingGroup.""" + + @pytest.mark.asyncio + async def test_wait(self) -> None: + """Test wait method.""" + + async def task() -> None: + await asyncio.sleep(SHORT_SLEEP) + + tasks = [asyncio.create_task(task())] + group = _AsyncStreamingGroup(tasks) + await group.wait() + + assert all(task.done() for task in tasks) + + @pytest.mark.asyncio + async def test_cancel(self) -> None: + """Test cancel method.""" + + async def task() -> None: + await asyncio.sleep(LONG_SLEEP) # Long-running task + + tasks = [asyncio.create_task(task())] + group = _AsyncStreamingGroup(tasks) + await group.cancel() + + # All tasks should be cancelled + assert all(task.cancelled() for task in tasks) + + @pytest.mark.asyncio + async def test_wait_multiple_tasks(self) -> None: + """Test wait with multiple tasks.""" + MEDIUM_SLEEP = 0.02 + + async def task1() -> None: + await asyncio.sleep(SHORT_SLEEP) + + async def task2() -> None: + await asyncio.sleep(MEDIUM_SLEEP) + + tasks = [asyncio.create_task(task1()), asyncio.create_task(task2())] + group = _AsyncStreamingGroup(tasks) + await group.wait() + + assert all(task.done() for task in tasks) + + @pytest.mark.asyncio + async def test_cancel_multiple_tasks(self) -> None: + """Test cancel with multiple tasks.""" + + async def task1() -> None: + await asyncio.sleep(1.0) + + async def task2() -> None: + await asyncio.sleep(1.0) + + tasks = [asyncio.create_task(task1()), asyncio.create_task(task2())] + group = _AsyncStreamingGroup(tasks) + await group.cancel() + + assert all(task.cancelled() for task in tasks) + + +class TestAsyncExecution: + """Tests for AsyncExecution class.""" + + def test_init(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test AsyncExecution initialization.""" + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + assert execution.execution_id == "exec_123" + assert execution.devbox_id == "dev_123" + assert execution._latest == execution_view + + @pytest.mark.asyncio + async def test_init_with_streaming_group( + self, mock_async_client: AsyncMock, execution_view: SimpleNamespace + ) -> None: + """Test AsyncExecution initialization with streaming group.""" + + async def task() -> None: + await asyncio.sleep(SHORT_SLEEP) + + tasks = [asyncio.create_task(task())] + streaming_group = _AsyncStreamingGroup(tasks) + + execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) + assert execution._streaming_group is streaming_group + # Clean up tasks + for task in tasks: + task.cancel() + try: + await task + except (Exception, asyncio.CancelledError): + pass + + def test_properties(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test AsyncExecution properties.""" + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + assert execution.execution_id == "exec_123" + assert execution.devbox_id == "dev_123" + + @pytest.mark.asyncio + async def test_result_already_completed( + self, mock_async_client: AsyncMock, execution_view: SimpleNamespace + ) -> None: + """Test result when execution is already completed.""" + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + result = await execution.result() + + assert result.exit_code == 0 + assert await result.stdout() == "output" + # Verify await_completed is not called when already completed + if hasattr(mock_async_client.devboxes.executions, "await_completed"): + assert not mock_async_client.devboxes.executions.await_completed.called + + @pytest.mark.asyncio + async def test_result_needs_polling(self, mock_async_client: AsyncMock) -> None: + """Test result when execution needs polling.""" + running_execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + completed_execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="", + ) + + mock_async_client.devboxes.executions.await_completed = AsyncMock(return_value=completed_execution) + + execution = AsyncExecution(mock_async_client, "dev_123", running_execution) + result = await execution.result() + + assert result.exit_code == 0 + assert await result.stdout() == "output" + mock_async_client.devboxes.executions.await_completed.assert_called_once() + + @pytest.mark.asyncio + async def test_result_with_streaming_group(self, mock_async_client: AsyncMock) -> None: + """Test result with streaming group cleanup.""" + running_execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + completed_execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="", + ) + + mock_async_client.devboxes.executions.await_completed = AsyncMock(return_value=completed_execution) + + async def task() -> None: + await asyncio.sleep(SHORT_SLEEP) + + tasks = [asyncio.create_task(task())] + streaming_group = _AsyncStreamingGroup(tasks) + + execution = AsyncExecution(mock_async_client, "dev_123", running_execution, streaming_group) + result = await execution.result() + + assert result.exit_code == 0 + assert execution._streaming_group is None # Should be cleaned up + + @pytest.mark.asyncio + async def test_get_state(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test get_state method.""" + updated_execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + mock_async_client.devboxes.executions.retrieve = AsyncMock(return_value=updated_execution) + + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + result = await execution.get_state() + + assert result == updated_execution + assert execution._latest == updated_execution + mock_async_client.devboxes.executions.retrieve.assert_called_once() + + @pytest.mark.asyncio + async def test_kill(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test kill method.""" + mock_async_client.devboxes.executions.kill = AsyncMock(return_value=None) + + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + await execution.kill() + + mock_async_client.devboxes.executions.kill.assert_called_once_with( + "exec_123", + devbox_id="dev_123", + kill_process_group=None, + ) + + @pytest.mark.asyncio + async def test_kill_with_process_group(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test kill with kill_process_group.""" + mock_async_client.devboxes.executions.kill = AsyncMock(return_value=None) + + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + await execution.kill(kill_process_group=True) + + mock_async_client.devboxes.executions.kill.assert_called_once_with( + "exec_123", + devbox_id="dev_123", + kill_process_group=True, + ) + + @pytest.mark.asyncio + async def test_kill_with_streaming_cleanup( + self, mock_async_client: AsyncMock, execution_view: SimpleNamespace + ) -> None: + """Test kill cleans up streaming.""" + mock_async_client.devboxes.executions.kill = AsyncMock(return_value=None) + + async def task() -> None: + await asyncio.sleep(LONG_SLEEP) # Long-running task + + tasks = [asyncio.create_task(task())] + streaming_group = _AsyncStreamingGroup(tasks) + + execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) + await execution.kill() + + assert execution._streaming_group is None # Should be cleaned up + assert all(task.cancelled() for task in tasks) # Tasks should be cancelled + + @pytest.mark.asyncio + async def test_settle_streaming_no_group( + self, mock_async_client: AsyncMock, execution_view: SimpleNamespace + ) -> None: + """Test _settle_streaming when no streaming group.""" + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + await execution._settle_streaming(cancel=True) # Should not raise + + @pytest.mark.asyncio + async def test_settle_streaming_with_group_cancel( + self, mock_async_client: AsyncMock, execution_view: SimpleNamespace + ) -> None: + """Test _settle_streaming with streaming group and cancel.""" + + async def task() -> None: + await asyncio.sleep(LONG_SLEEP) # Long-running task + + tasks = [asyncio.create_task(task())] + streaming_group = _AsyncStreamingGroup(tasks) + + execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) + await execution._settle_streaming(cancel=True) + + assert execution._streaming_group is None + assert all(task.cancelled() for task in tasks) + + @pytest.mark.asyncio + async def test_settle_streaming_with_group_wait( + self, mock_async_client: AsyncMock, execution_view: SimpleNamespace + ) -> None: + """Test _settle_streaming with streaming group and wait.""" + + async def task() -> None: + await asyncio.sleep(SHORT_SLEEP) + + tasks = [asyncio.create_task(task())] + streaming_group = _AsyncStreamingGroup(tasks) + + execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) + await execution._settle_streaming(cancel=False) + + assert execution._streaming_group is None + assert all(task.done() for task in tasks) diff --git a/tests/sdk/test_async_execution_result.py b/tests/sdk/test_async_execution_result.py new file mode 100644 index 000000000..0f8991fa2 --- /dev/null +++ b/tests/sdk/test_async_execution_result.py @@ -0,0 +1,143 @@ +"""Comprehensive tests for AsyncExecutionResult class.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from runloop_api_client.sdk.async_execution_result import AsyncExecutionResult + + +class TestAsyncExecutionResult: + """Tests for AsyncExecutionResult class.""" + + def test_init(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test AsyncExecutionResult initialization.""" + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + # Verify via public API + assert result.devbox_id == "dev_123" + assert result.execution_id == "exec_123" + + def test_devbox_id_property(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test devbox_id property.""" + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + assert result.devbox_id == "dev_123" + + def test_execution_id_property(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test execution_id property.""" + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + assert result.execution_id == "exec_123" + + def test_exit_code_property(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test exit_code property.""" + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + assert result.exit_code == 0 + + def test_exit_code_none(self, mock_async_client: AsyncMock) -> None: + """Test exit_code property when exit_status is None.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + exit_status=None, + stdout="", + stderr="", + ) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) + assert result.exit_code is None + + def test_success_property(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test success property.""" + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + assert result.success is True + + def test_success_false(self, mock_async_client: AsyncMock) -> None: + """Test success property when exit code is non-zero.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=1, + stdout="", + stderr="error", + ) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) + assert result.success is False + + def test_failed_property(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test failed property when exit code is zero.""" + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + assert result.failed is False + + def test_failed_true(self, mock_async_client: AsyncMock) -> None: + """Test failed property when exit code is non-zero.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=1, + stdout="", + stderr="error", + ) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) + assert result.failed is True + + def test_failed_none(self, mock_async_client: AsyncMock) -> None: + """Test failed property when exit_status is None.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + exit_status=None, + stdout="", + stderr="", + ) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) + assert result.failed is False + + @pytest.mark.asyncio + async def test_stdout(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test stdout method.""" + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + assert await result.stdout() == "output" + + @pytest.mark.asyncio + async def test_stdout_empty(self, mock_async_client: AsyncMock) -> None: + """Test stdout method when stdout is None.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout=None, + stderr="", + ) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) + assert await result.stdout() == "" + + @pytest.mark.asyncio + async def test_stderr(self, mock_async_client: AsyncMock) -> None: + """Test stderr method.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=1, + stdout="", + stderr="error message", + ) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) + assert await result.stderr() == "error message" + + @pytest.mark.asyncio + async def test_stderr_empty(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test stderr method when stderr is None.""" + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + assert await result.stderr() == "" + + def test_raw_property(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + """Test raw property.""" + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + assert result.raw == execution_view diff --git a/tests/sdk/test_async_resources.py b/tests/sdk/test_async_resources.py deleted file mode 100644 index fb911024d..000000000 --- a/tests/sdk/test_async_resources.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -from typing import List - -import httpx -import pytest - -from runloop_api_client import AsyncRunloopSDK -from runloop_api_client.sdk import AsyncSnapshot, AsyncBlueprint, AsyncStorageObject - - -@pytest.fixture() -async def async_sdk() -> AsyncRunloopSDK: - sdk = AsyncRunloopSDK(bearer_token="test-token") - try: - yield sdk - finally: - await sdk.aclose() - - -@pytest.mark.asyncio -async def test_async_blueprint_create(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: - blueprints_resource = async_sdk.api.blueprints - - async def fake_create_and_await_build_complete(**kwargs): - return SimpleNamespace(id="abp-1") - - monkeypatch.setattr(blueprints_resource, "create_and_await_build_complete", fake_create_and_await_build_complete) - - devbox_calls: List[dict[str, object]] = [] - - async def fake_devbox_create(**kwargs): - devbox_calls.append(kwargs) - return SimpleNamespace(id="adev-1") - - monkeypatch.setattr(async_sdk.devbox, "create", fake_devbox_create) - - blueprint = await async_sdk.blueprint.create(name="async-blueprint") - assert isinstance(blueprint, AsyncBlueprint) - - await blueprint.create_devbox() - assert devbox_calls[0]["blueprint_id"] == "abp-1" - - -@pytest.mark.asyncio -async def test_async_snapshot_list(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: - disk_snapshots_resource = async_sdk.api.devboxes.disk_snapshots - - async def fake_list(**kwargs): - return SimpleNamespace(disk_snapshots=[SimpleNamespace(id="asnap-1")]) - - monkeypatch.setattr(disk_snapshots_resource, "list", fake_list) - - snapshots = await async_sdk.snapshot.list() - assert isinstance(snapshots[0], AsyncSnapshot) - - -@pytest.mark.asyncio -async def test_async_storage_object_upload(monkeypatch: pytest.MonkeyPatch, async_sdk: AsyncRunloopSDK) -> None: - objects_resource = async_sdk.api.objects - - async def fake_create(**kwargs): - return SimpleNamespace(id="aobj-1", upload_url="https://async-upload.example.com") - - async def fake_complete(object_id: str, **kwargs): - return SimpleNamespace(id=object_id, upload_url=None) - - async def fake_download(object_id: str, **kwargs): - return SimpleNamespace(download_url=f"https://async-download.example.com/{object_id}") - - monkeypatch.setattr(objects_resource, "create", fake_create) - monkeypatch.setattr(objects_resource, "complete", fake_complete) - monkeypatch.setattr(objects_resource, "download", fake_download) - - class DummyAsyncResponse: - def __init__(self, content: bytes) -> None: - self.content = content - self.text = content.decode("utf-8") - self.encoding = "utf-8" - - def raise_for_status(self) -> None: - return None - - class DummyAsyncClient: - def __init__(self, response: DummyAsyncResponse) -> None: - self._response = response - self.calls: List[tuple[str, bytes | None]] = [] - - async def __aenter__(self) -> "DummyAsyncClient": - return self - - async def __aexit__(self, *exc) -> None: - return None - - async def put(self, url: str, *, content: bytes) -> DummyAsyncResponse: - self.calls.append((url, content)) - return self._response - - async def get(self, url: str) -> DummyAsyncResponse: - self.calls.append((url, None)) - return self._response - - dummy_response = DummyAsyncResponse(b"hello async") - - def client_factory(*args, **kwargs): - return DummyAsyncClient(dummy_response) - - monkeypatch.setattr(httpx, "AsyncClient", client_factory) - - obj = await async_sdk.storage_object.upload_from_text("hello async", name="message.txt") - assert isinstance(obj, AsyncStorageObject) - - content = await obj.download_as_text() - assert content == "hello async" diff --git a/tests/sdk/test_async_snapshot.py b/tests/sdk/test_async_snapshot.py new file mode 100644 index 000000000..cdb3c0e30 --- /dev/null +++ b/tests/sdk/test_async_snapshot.py @@ -0,0 +1,114 @@ +"""Comprehensive tests for async Snapshot class.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from runloop_api_client.sdk import AsyncSnapshot +from runloop_api_client.lib.polling import PollingConfig + + +class TestAsyncSnapshot: + """Tests for AsyncSnapshot class.""" + + def test_init(self, mock_async_client: AsyncMock) -> None: + """Test AsyncSnapshot initialization.""" + snapshot = AsyncSnapshot(mock_async_client, "snap_123") + assert snapshot.id == "snap_123" + + def test_repr(self, mock_async_client: AsyncMock) -> None: + """Test AsyncSnapshot string representation.""" + snapshot = AsyncSnapshot(mock_async_client, "snap_123") + assert repr(snapshot) == "" + + @pytest.mark.asyncio + async def test_get_info(self, mock_async_client: AsyncMock, snapshot_view: SimpleNamespace) -> None: + """Test get_info method.""" + mock_async_client.devboxes.disk_snapshots.query_status = AsyncMock(return_value=snapshot_view) + + snapshot = AsyncSnapshot(mock_async_client, "snap_123") + result = await snapshot.get_info( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == snapshot_view + mock_async_client.devboxes.disk_snapshots.query_status.assert_called_once() + + @pytest.mark.asyncio + async def test_update(self, mock_async_client: AsyncMock) -> None: + """Test update method.""" + updated_snapshot = SimpleNamespace(id="snap_123", name="updated-name") + mock_async_client.devboxes.disk_snapshots.update = AsyncMock(return_value=updated_snapshot) + + snapshot = AsyncSnapshot(mock_async_client, "snap_123") + result = await snapshot.update( + commit_message="Update message", + metadata={"key": "value"}, + name="updated-name", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == updated_snapshot + mock_async_client.devboxes.disk_snapshots.update.assert_called_once() + + @pytest.mark.asyncio + async def test_delete(self, mock_async_client: AsyncMock) -> None: + """Test delete method.""" + # Return value not used - testing side effect only + mock_async_client.devboxes.disk_snapshots.delete = AsyncMock(return_value=object()) + + snapshot = AsyncSnapshot(mock_async_client, "snap_123") + result = await snapshot.delete( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result is not None + mock_async_client.devboxes.disk_snapshots.delete.assert_called_once() + + @pytest.mark.asyncio + async def test_await_completed(self, mock_async_client: AsyncMock, snapshot_view: SimpleNamespace) -> None: + """Test await_completed method.""" + mock_async_client.devboxes.disk_snapshots.await_completed = AsyncMock(return_value=snapshot_view) + polling_config = PollingConfig(timeout_seconds=60.0) + + snapshot = AsyncSnapshot(mock_async_client, "snap_123") + result = await snapshot.await_completed( + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == snapshot_view + mock_async_client.devboxes.disk_snapshots.await_completed.assert_called_once() + + @pytest.mark.asyncio + async def test_create_devbox(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + """Test create_devbox method.""" + mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) + + snapshot = AsyncSnapshot(mock_async_client, "snap_123") + devbox = await snapshot.create_devbox( + name="test-devbox", + metadata={"key": "value"}, + polling_config=PollingConfig(timeout_seconds=60.0), + extra_headers={"X-Custom": "value"}, + ) + + assert devbox.id == "dev_123" + mock_async_client.devboxes.create_and_await_running.assert_called_once() diff --git a/tests/sdk/test_async_storage_object.py b/tests/sdk/test_async_storage_object.py new file mode 100644 index 000000000..e7219ab18 --- /dev/null +++ b/tests/sdk/test_async_storage_object.py @@ -0,0 +1,269 @@ +"""Comprehensive tests for async StorageObject class.""" + +from __future__ import annotations + +import tempfile +from types import SimpleNamespace +from pathlib import Path +from unittest.mock import Mock, AsyncMock, patch + +import pytest + +from tests.sdk.conftest import create_mock_httpx_client, create_mock_httpx_response +from runloop_api_client.sdk import AsyncStorageObject + + +class TestAsyncStorageObject: + """Tests for AsyncStorageObject class.""" + + def test_init(self, mock_async_client: AsyncMock) -> None: + """Test AsyncStorageObject initialization.""" + obj = AsyncStorageObject(mock_async_client, "obj_123", "https://upload.example.com") + assert obj.id == "obj_123" + assert obj.upload_url == "https://upload.example.com" + + def test_init_no_upload_url(self, mock_async_client: AsyncMock) -> None: + """Test AsyncStorageObject initialization without upload URL.""" + obj = AsyncStorageObject(mock_async_client, "obj_123", None) + assert obj.id == "obj_123" + assert obj.upload_url is None + + def test_repr(self, mock_async_client: AsyncMock) -> None: + """Test AsyncStorageObject string representation.""" + obj = AsyncStorageObject(mock_async_client, "obj_123", None) + assert repr(obj) == "" + + @pytest.mark.asyncio + async def test_refresh(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + """Test refresh method.""" + mock_async_client.objects.retrieve = AsyncMock(return_value=object_view) + + obj = AsyncStorageObject(mock_async_client, "obj_123", None) + result = await obj.refresh( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == object_view + mock_async_client.objects.retrieve.assert_called_once() + + @pytest.mark.asyncio + async def test_complete(self, mock_async_client: AsyncMock) -> None: + """Test complete method updates upload_url to None.""" + completed_view = SimpleNamespace(id="obj_123", upload_url=None) + mock_async_client.objects.complete = AsyncMock(return_value=completed_view) + + obj = AsyncStorageObject(mock_async_client, "obj_123", "https://upload.example.com") + assert obj.upload_url == "https://upload.example.com" + + result = await obj.complete( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == completed_view + assert obj.upload_url is None + mock_async_client.objects.complete.assert_called_once() + + @pytest.mark.asyncio + async def test_get_download_url_without_duration(self, mock_async_client: AsyncMock) -> None: + """Test get_download_url without duration_seconds.""" + download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") + mock_async_client.objects.download = AsyncMock(return_value=download_url_view) + + obj = AsyncStorageObject(mock_async_client, "obj_123", None) + result = await obj.get_download_url( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == download_url_view + mock_async_client.objects.download.assert_called_once() + + @pytest.mark.asyncio + async def test_get_download_url_with_duration(self, mock_async_client: AsyncMock) -> None: + """Test get_download_url with duration_seconds.""" + download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") + mock_async_client.objects.download = AsyncMock(return_value=download_url_view) + + obj = AsyncStorageObject(mock_async_client, "obj_123", None) + result = await obj.get_download_url( + duration_seconds=3600, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == download_url_view + mock_async_client.objects.download.assert_called_once() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient") + async def test_download_as_bytes(self, mock_client_class: Mock, mock_async_client: AsyncMock) -> None: + """Test download_as_bytes method.""" + download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") + mock_async_client.objects.download = AsyncMock(return_value=download_url_view) + + mock_response = create_mock_httpx_response(content=b"file content") + mock_http_client = create_mock_httpx_client(methods={"get": mock_response}) + mock_client_class.return_value = mock_http_client + + obj = AsyncStorageObject(mock_async_client, "obj_123", None) + result = await obj.download_as_bytes( + duration_seconds=3600, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == b"file content" + # Verify get was called + mock_http_client.get.assert_called_once() + mock_response.raise_for_status.assert_called_once() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient") + async def test_download_as_text_default_encoding( + self, mock_client_class: Mock, mock_async_client: AsyncMock + ) -> None: + """Test download_as_text with default encoding.""" + download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") + mock_async_client.objects.download = AsyncMock(return_value=download_url_view) + + mock_response = create_mock_httpx_response(text="file content", encoding="utf-8") + mock_http_client = create_mock_httpx_client(methods={"get": mock_response}) + mock_client_class.return_value = mock_http_client + + obj = AsyncStorageObject(mock_async_client, "obj_123", None) + result = await obj.download_as_text() + + assert result == "file content" + assert mock_response.encoding == "utf-8" + # Verify get was called + mock_http_client.get.assert_called_once() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient") + async def test_download_as_text_custom_encoding( + self, mock_client_class: Mock, mock_async_client: AsyncMock + ) -> None: + """Test download_as_text with custom encoding.""" + download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") + mock_async_client.objects.download = AsyncMock(return_value=download_url_view) + + mock_response = create_mock_httpx_response(text="file content", encoding="utf-8") + mock_http_client = create_mock_httpx_client(methods={"get": mock_response}) + mock_client_class.return_value = mock_http_client + + obj = AsyncStorageObject(mock_async_client, "obj_123", None) + result = await obj.download_as_text(encoding="latin-1") + + assert result == "file content" + assert mock_response.encoding == "latin-1" + mock_http_client.get.assert_called_once() + + @pytest.mark.asyncio + async def test_delete(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + """Test delete method.""" + mock_async_client.objects.delete = AsyncMock(return_value=object_view) + + obj = AsyncStorageObject(mock_async_client, "obj_123", None) + result = await obj.delete( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == object_view + mock_async_client.objects.delete.assert_called_once() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient") + async def test_upload_content_string(self, mock_client_class: Mock, mock_async_client: AsyncMock) -> None: + """Test upload_content with string.""" + mock_response = create_mock_httpx_response() + mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) + mock_client_class.return_value = mock_http_client + + obj = AsyncStorageObject(mock_async_client, "obj_123", "https://upload.example.com") + await obj.upload_content("test content") + + # Verify put was called with correct URL + mock_http_client.put.assert_called_once() + call_args = mock_http_client.put.call_args + assert call_args[0][0] == "https://upload.example.com" + mock_response.raise_for_status.assert_called_once() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient") + async def test_upload_content_bytes(self, mock_client_class: Mock, mock_async_client: AsyncMock) -> None: + """Test upload_content with bytes.""" + mock_response = create_mock_httpx_response() + mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) + mock_client_class.return_value = mock_http_client + + obj = AsyncStorageObject(mock_async_client, "obj_123", "https://upload.example.com") + await obj.upload_content(b"test content") + + # Verify put was called with correct URL + mock_http_client.put.assert_called_once() + call_args = mock_http_client.put.call_args + assert call_args[0][0] == "https://upload.example.com" + mock_response.raise_for_status.assert_called_once() + + @pytest.mark.asyncio + @patch("httpx.AsyncClient") + async def test_upload_content_path(self, mock_client_class: Mock, mock_async_client: AsyncMock) -> None: + """Test upload_content with Path.""" + mock_response = create_mock_httpx_response() + mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) + mock_client_class.return_value = mock_http_client + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("test content") + temp_path = Path(f.name) + + try: + obj = AsyncStorageObject(mock_async_client, "obj_123", "https://upload.example.com") + await obj.upload_content(temp_path) + + # Verify put was called + mock_http_client.put.assert_called_once() + call_args = mock_http_client.put.call_args + assert call_args[0][0] == "https://upload.example.com" + assert call_args[1]["content"] == b"test content" + mock_response.raise_for_status.assert_called_once() + finally: + temp_path.unlink() + + @pytest.mark.asyncio + async def test_upload_content_no_url(self, mock_async_client: AsyncMock) -> None: + """Test upload_content raises error when no upload URL.""" + obj = AsyncStorageObject(mock_async_client, "obj_123", None) + + with pytest.raises(RuntimeError, match="No upload URL available"): + await obj.upload_content("test content") + + def test_ensure_upload_url_with_url(self, mock_async_client: AsyncMock) -> None: + """Test _ensure_upload_url returns URL when available.""" + obj = AsyncStorageObject(mock_async_client, "obj_123", "https://upload.example.com") + url = obj._ensure_upload_url() + assert url == "https://upload.example.com" + + def test_ensure_upload_url_no_url(self, mock_async_client: AsyncMock) -> None: + """Test _ensure_upload_url raises error when no URL.""" + obj = AsyncStorageObject(mock_async_client, "obj_123", None) + + with pytest.raises(RuntimeError, match="No upload URL available"): + obj._ensure_upload_url() diff --git a/tests/sdk/test_blueprint.py b/tests/sdk/test_blueprint.py new file mode 100644 index 000000000..5dcdbc2b4 --- /dev/null +++ b/tests/sdk/test_blueprint.py @@ -0,0 +1,106 @@ +"""Comprehensive tests for sync Blueprint class.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +from runloop_api_client.sdk import Blueprint + + +class TestBlueprint: + """Tests for Blueprint class.""" + + def test_init(self, mock_client: Mock) -> None: + """Test Blueprint initialization.""" + blueprint = Blueprint(mock_client, "bp_123") + assert blueprint.id == "bp_123" + + def test_repr(self, mock_client: Mock) -> None: + """Test Blueprint string representation.""" + blueprint = Blueprint(mock_client, "bp_123") + assert repr(blueprint) == "" + + def test_get_info(self, mock_client: Mock, blueprint_view: SimpleNamespace) -> None: + """Test get_info method.""" + mock_client.blueprints.retrieve.return_value = blueprint_view + + blueprint = Blueprint(mock_client, "bp_123") + result = blueprint.get_info( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == blueprint_view + mock_client.blueprints.retrieve.assert_called_once_with( + "bp_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + def test_logs(self, mock_client: Mock) -> None: + """Test logs method.""" + logs_view = SimpleNamespace(logs=[]) + mock_client.blueprints.logs.return_value = logs_view + + blueprint = Blueprint(mock_client, "bp_123") + result = blueprint.logs( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == logs_view + mock_client.blueprints.logs.assert_called_once_with( + "bp_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + def test_delete(self, mock_client: Mock) -> None: + """Test delete method.""" + # Return value not used - testing side effect only + mock_client.blueprints.delete.return_value = object() + + blueprint = Blueprint(mock_client, "bp_123") + result = blueprint.delete( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result is not None + mock_client.blueprints.delete.assert_called_once_with( + "bp_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + def test_create_devbox(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test create_devbox method.""" + mock_client.devboxes.create_and_await_running.return_value = devbox_view + + blueprint = Blueprint(mock_client, "bp_123") + devbox = blueprint.create_devbox( + name="test-devbox", + metadata={"key": "value"}, + polling_config=None, + extra_headers={"X-Custom": "value"}, + ) + + assert devbox.id == "dev_123" + mock_client.devboxes.create_and_await_running.assert_called_once() + call_kwargs = mock_client.devboxes.create_and_await_running.call_args[1] + assert call_kwargs["blueprint_id"] == "bp_123" + assert call_kwargs["name"] == "test-devbox" + assert call_kwargs["metadata"] == {"key": "value"} diff --git a/tests/sdk/test_clients.py b/tests/sdk/test_clients.py new file mode 100644 index 000000000..08c8b511c --- /dev/null +++ b/tests/sdk/test_clients.py @@ -0,0 +1,338 @@ +"""Comprehensive tests for sync client classes.""" + +from __future__ import annotations + +import tempfile +from types import SimpleNamespace +from pathlib import Path +from unittest.mock import Mock, patch + +from tests.sdk.conftest import create_mock_httpx_response +from runloop_api_client.sdk import Devbox, Snapshot, Blueprint, StorageObject +from runloop_api_client.sdk._sync import ( + RunloopSDK, + DevboxClient, + SnapshotClient, + BlueprintClient, + StorageObjectClient, +) +from runloop_api_client.lib.polling import PollingConfig + + +class TestDevboxClient: + """Tests for DevboxClient class.""" + + def test_create(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test create method.""" + mock_client.devboxes.create_and_await_running.return_value = devbox_view + + client = DevboxClient(mock_client) + devbox = client.create( + name="test-devbox", + metadata={"key": "value"}, + polling_config=PollingConfig(timeout_seconds=60.0), + ) + + assert isinstance(devbox, Devbox) + assert devbox.id == "dev_123" + mock_client.devboxes.create_and_await_running.assert_called_once() + + def test_create_from_blueprint_id(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test create_from_blueprint_id method.""" + mock_client.devboxes.create_and_await_running.return_value = devbox_view + + client = DevboxClient(mock_client) + devbox = client.create_from_blueprint_id( + "bp_123", + name="test-devbox", + metadata={"key": "value"}, + ) + + assert isinstance(devbox, Devbox) + assert devbox.id == "dev_123" + call_kwargs = mock_client.devboxes.create_and_await_running.call_args[1] + assert call_kwargs["blueprint_id"] == "bp_123" + + def test_create_from_blueprint_name(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test create_from_blueprint_name method.""" + mock_client.devboxes.create_and_await_running.return_value = devbox_view + + client = DevboxClient(mock_client) + devbox = client.create_from_blueprint_name( + "my-blueprint", + name="test-devbox", + ) + + assert isinstance(devbox, Devbox) + call_kwargs = mock_client.devboxes.create_and_await_running.call_args[1] + assert call_kwargs["blueprint_name"] == "my-blueprint" + + def test_create_from_snapshot(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test create_from_snapshot method.""" + mock_client.devboxes.create_and_await_running.return_value = devbox_view + + client = DevboxClient(mock_client) + devbox = client.create_from_snapshot( + "snap_123", + name="test-devbox", + ) + + assert isinstance(devbox, Devbox) + call_kwargs = mock_client.devboxes.create_and_await_running.call_args[1] + assert call_kwargs["snapshot_id"] == "snap_123" + + def test_from_id(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test from_id method waits for running.""" + mock_client.devboxes.await_running.return_value = devbox_view + + client = DevboxClient(mock_client) + devbox = client.from_id("dev_123") + + assert isinstance(devbox, Devbox) + assert devbox.id == "dev_123" + mock_client.devboxes.await_running.assert_called_once_with("dev_123") + + def test_list(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test list method.""" + page = SimpleNamespace(devboxes=[devbox_view]) + mock_client.devboxes.list.return_value = page + + client = DevboxClient(mock_client) + devboxes = client.list( + limit=10, + status="running", + starting_after="dev_000", + ) + + assert len(devboxes) == 1 + assert isinstance(devboxes[0], Devbox) + assert devboxes[0].id == "dev_123" + mock_client.devboxes.list.assert_called_once() + + +class TestSnapshotClient: + """Tests for SnapshotClient class.""" + + def test_list(self, mock_client: Mock, snapshot_view: SimpleNamespace) -> None: + """Test list method.""" + page = SimpleNamespace(disk_snapshots=[snapshot_view]) + mock_client.devboxes.disk_snapshots.list.return_value = page + + client = SnapshotClient(mock_client) + snapshots = client.list( + devbox_id="dev_123", + limit=10, + starting_after="snap_000", + ) + + assert len(snapshots) == 1 + assert isinstance(snapshots[0], Snapshot) + assert snapshots[0].id == "snap_123" + mock_client.devboxes.disk_snapshots.list.assert_called_once() + + def test_from_id(self, mock_client: Mock) -> None: + """Test from_id method.""" + client = SnapshotClient(mock_client) + snapshot = client.from_id("snap_123") + + assert isinstance(snapshot, Snapshot) + assert snapshot.id == "snap_123" + + +class TestBlueprintClient: + """Tests for BlueprintClient class.""" + + def test_create(self, mock_client: Mock, blueprint_view: SimpleNamespace) -> None: + """Test create method.""" + mock_client.blueprints.create_and_await_build_complete.return_value = blueprint_view + + client = BlueprintClient(mock_client) + blueprint = client.create( + name="test-blueprint", + polling_config=PollingConfig(timeout_seconds=60.0), + ) + + assert isinstance(blueprint, Blueprint) + assert blueprint.id == "bp_123" + mock_client.blueprints.create_and_await_build_complete.assert_called_once() + + def test_from_id(self, mock_client: Mock) -> None: + """Test from_id method.""" + client = BlueprintClient(mock_client) + blueprint = client.from_id("bp_123") + + assert isinstance(blueprint, Blueprint) + assert blueprint.id == "bp_123" + + def test_list(self, mock_client: Mock, blueprint_view: SimpleNamespace) -> None: + """Test list method.""" + page = SimpleNamespace(blueprints=[blueprint_view]) + mock_client.blueprints.list.return_value = page + + client = BlueprintClient(mock_client) + blueprints = client.list( + limit=10, + name="test", + starting_after="bp_000", + ) + + assert len(blueprints) == 1 + assert isinstance(blueprints[0], Blueprint) + assert blueprints[0].id == "bp_123" + mock_client.blueprints.list.assert_called_once() + + +class TestStorageObjectClient: + """Tests for StorageObjectClient class.""" + + def test_create(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + """Test create method.""" + mock_client.objects.create.return_value = object_view + + client = StorageObjectClient(mock_client) + obj = client.create("test.txt", content_type="text", metadata={"key": "value"}) + + assert isinstance(obj, StorageObject) + assert obj.id == "obj_123" + assert obj.upload_url == "https://upload.example.com/obj_123" + mock_client.objects.create.assert_called_once() + + def test_create_auto_detect_content_type(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + """Test create auto-detects content type.""" + mock_client.objects.create.return_value = object_view + + client = StorageObjectClient(mock_client) + obj = client.create("test.txt") + + assert isinstance(obj, StorageObject) + # Should detect "text" from .txt extension + call_kwargs = mock_client.objects.create.call_args[1] + assert call_kwargs["content_type"] == "text" + + def test_from_id(self, mock_client: Mock) -> None: + """Test from_id method.""" + client = StorageObjectClient(mock_client) + obj = client.from_id("obj_123") + + assert isinstance(obj, StorageObject) + assert obj.id == "obj_123" + assert obj.upload_url is None + + def test_list(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + """Test list method.""" + page = SimpleNamespace(objects=[object_view]) + mock_client.objects.list.return_value = page + + client = StorageObjectClient(mock_client) + objects = client.list( + content_type="text", + limit=10, + name="test", + search="query", + starting_after="obj_000", + state="ready", + ) + + assert len(objects) == 1 + assert isinstance(objects[0], StorageObject) + assert objects[0].id == "obj_123" + mock_client.objects.list.assert_called_once() + + def test_upload_from_file(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + """Test upload_from_file method.""" + mock_client.objects.create.return_value = object_view + + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: + f.write("test content") + temp_path = Path(f.name) + + try: + with patch("httpx.put") as mock_put: + mock_response = create_mock_httpx_response() + mock_put.return_value = mock_response + + client = StorageObjectClient(mock_client) + obj = client.upload_from_file(temp_path, name="test.txt") + + assert isinstance(obj, StorageObject) + assert obj.id == "obj_123" + mock_client.objects.create.assert_called_once() + mock_client.objects.complete.assert_called_once() + mock_put.assert_called_once() + finally: + temp_path.unlink() + + def test_upload_from_text(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + """Test upload_from_text method.""" + mock_client.objects.create.return_value = object_view + + with patch("httpx.put") as mock_put: + mock_response = create_mock_httpx_response() + mock_put.return_value = mock_response + + client = StorageObjectClient(mock_client) + obj = client.upload_from_text("test content", "test.txt", metadata={"key": "value"}) + + assert isinstance(obj, StorageObject) + assert obj.id == "obj_123" + mock_client.objects.create.assert_called_once() + call_kwargs = mock_client.objects.create.call_args[1] + assert call_kwargs["content_type"] == "text" + assert call_kwargs["metadata"] == {"key": "value"} + mock_client.objects.complete.assert_called_once() + + def test_upload_from_bytes(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + """Test upload_from_bytes method.""" + mock_client.objects.create.return_value = object_view + + with patch("httpx.put") as mock_put: + mock_response = create_mock_httpx_response() + mock_put.return_value = mock_response + + client = StorageObjectClient(mock_client) + obj = client.upload_from_bytes(b"test content", "test.bin", content_type="binary") + + assert isinstance(obj, StorageObject) + assert obj.id == "obj_123" + mock_client.objects.create.assert_called_once() + call_kwargs = mock_client.objects.create.call_args[1] + assert call_kwargs["content_type"] == "binary" + mock_client.objects.complete.assert_called_once() + + +class TestRunloopSDK: + """Tests for RunloopSDK class.""" + + def test_init(self) -> None: + """Test RunloopSDK initialization.""" + sdk = RunloopSDK(bearer_token="test-token") + assert sdk.api is not None + assert isinstance(sdk.devbox, DevboxClient) + assert isinstance(sdk.snapshot, SnapshotClient) + assert isinstance(sdk.blueprint, BlueprintClient) + assert isinstance(sdk.storage_object, StorageObjectClient) + + def test_init_with_max_retries(self) -> None: + """Test RunloopSDK initialization with max_retries.""" + sdk = RunloopSDK(bearer_token="test-token", max_retries=3) + assert sdk.api is not None + + def test_close(self) -> None: + """Test close method.""" + sdk = RunloopSDK(bearer_token="test-token") + # Verify close doesn't raise + sdk.close() + + def test_context_manager(self) -> None: + """Test context manager behavior.""" + with RunloopSDK(bearer_token="test-token") as sdk: + assert sdk.api is not None + # Verify context manager properly closes (implementation detail of context manager protocol) + + def test_api_property(self) -> None: + """Test api property access.""" + sdk = RunloopSDK(bearer_token="test-token") + assert sdk.api is not None + assert hasattr(sdk.api, "devboxes") + assert hasattr(sdk.api, "blueprints") + assert hasattr(sdk.api, "objects") diff --git a/tests/sdk/test_devbox.py b/tests/sdk/test_devbox.py index 882f78bcd..c960f5172 100644 --- a/tests/sdk/test_devbox.py +++ b/tests/sdk/test_devbox.py @@ -1,208 +1,878 @@ +"""Comprehensive tests for sync Devbox class.""" + from __future__ import annotations +import time +import tempfile +import threading from types import SimpleNamespace -from typing import List +from pathlib import Path +from unittest.mock import Mock, patch +import httpx import pytest -from runloop_api_client import RunloopSDK -from runloop_api_client.sdk import Devbox, Execution, ExecutionResult - - -@pytest.fixture() -def sdk() -> RunloopSDK: - return RunloopSDK(bearer_token="test-token") - - -def test_create_returns_devbox(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: - devboxes_resource = sdk.api.devboxes - - captured_kwargs: dict[str, object] = {} - - def fake_create_and_await_running(**kwargs): - captured_kwargs.update(kwargs) - return SimpleNamespace(id="dev_123") - - monkeypatch.setattr(devboxes_resource, "create_and_await_running", fake_create_and_await_running) - - devbox = sdk.devbox.create(name="my-devbox") - - assert isinstance(devbox, Devbox) - assert devbox.id == "dev_123" - assert captured_kwargs["name"] == "my-devbox" - - -def test_context_manager_shuts_down(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: - devboxes_resource = sdk.api.devboxes - shutdown_calls: List[str] = [] - - def fake_shutdown(devbox_id: str, **_kwargs): - shutdown_calls.append(devbox_id) - return None - - monkeypatch.setattr(devboxes_resource, "shutdown", fake_shutdown) - - with sdk.devbox.from_id("dev_ctx") as devbox: - assert devbox.id == "dev_ctx" - - assert shutdown_calls == ["dev_ctx"] - - -def test_exec_without_streaming(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: - devboxes_resource = sdk.api.devboxes - - result = SimpleNamespace( - execution_id="exec-1", - devbox_id="dev_456", - stdout="hello", - stderr="", - exit_status=0, - status="completed", +from tests.sdk.conftest import create_mock_httpx_response +from runloop_api_client.sdk import Devbox, StorageObject +from runloop_api_client._types import NotGiven, omit +from runloop_api_client._streaming import Stream +from runloop_api_client.sdk.devbox import ( + _FileInterface, + _CommandInterface, + _NetworkInterface, +) +from runloop_api_client._exceptions import APIStatusError +from runloop_api_client.lib.polling import PollingConfig +from runloop_api_client.sdk.execution import _StreamingGroup + +# Test constants +SHORT_SLEEP = 0.1 # Brief pause for thread operations +NUM_CONCURRENT_THREADS = 5 # Number of threads for concurrent operation tests + + +class TestDevbox: + """Tests for Devbox class.""" + + def test_init(self, mock_client: Mock) -> None: + """Test Devbox initialization.""" + devbox = Devbox(mock_client, "dev_123") + assert devbox.id == "dev_123" + + def test_repr(self, mock_client: Mock) -> None: + """Test Devbox string representation.""" + devbox = Devbox(mock_client, "dev_123") + assert repr(devbox) == "" + + def test_context_manager_enter_exit(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test context manager behavior with successful shutdown.""" + mock_client.devboxes.shutdown.return_value = devbox_view + + with Devbox(mock_client, "dev_123") as devbox: + assert devbox.id == "dev_123" + + call_kwargs = mock_client.devboxes.shutdown.call_args[1] + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_context_manager_exception_handling(self, mock_client: Mock) -> None: + """Test context manager handles exceptions during shutdown.""" + mock_client.devboxes.shutdown.side_effect = RuntimeError("Shutdown failed") + + with pytest.raises(ValueError, match="Test error"): + with Devbox(mock_client, "dev_123") as devbox: + raise ValueError("Test error") + + # Shutdown should be called even when body raises exception + mock_client.devboxes.shutdown.assert_called_once() + + def test_get_info(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test get_info method.""" + mock_client.devboxes.retrieve.return_value = devbox_view + + devbox = Devbox(mock_client, "dev_123") + result = devbox.get_info( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == devbox_view + mock_client.devboxes.retrieve.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + def test_await_running(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test await_running method.""" + mock_client.devboxes.await_running.return_value = devbox_view + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = Devbox(mock_client, "dev_123") + result = devbox.await_running(polling_config=polling_config) + + assert result == devbox_view + mock_client.devboxes.await_running.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + def test_await_suspended(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test await_suspended method.""" + mock_client.devboxes.await_suspended.return_value = devbox_view + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = Devbox(mock_client, "dev_123") + result = devbox.await_suspended(polling_config=polling_config) + + assert result == devbox_view + mock_client.devboxes.await_suspended.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + def test_shutdown(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test shutdown method.""" + mock_client.devboxes.shutdown.return_value = devbox_view + + devbox = Devbox(mock_client, "dev_123") + result = devbox.shutdown( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == devbox_view + mock_client.devboxes.shutdown.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + def test_suspend(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test suspend method.""" + mock_client.devboxes.suspend.return_value = None + mock_client.devboxes.await_suspended.return_value = devbox_view + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = Devbox(mock_client, "dev_123") + result = devbox.suspend( + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == devbox_view + mock_client.devboxes.suspend.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + mock_client.devboxes.await_suspended.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + def test_resume(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test resume method.""" + mock_client.devboxes.resume.return_value = None + mock_client.devboxes.await_running.return_value = devbox_view + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = Devbox(mock_client, "dev_123") + result = devbox.resume( + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == devbox_view + mock_client.devboxes.resume.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + mock_client.devboxes.await_running.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + def test_keep_alive(self, mock_client: Mock) -> None: + """Test keep_alive method.""" + # Return value not used - testing parameter passing only + mock_client.devboxes.keep_alive.return_value = object() + + devbox = Devbox(mock_client, "dev_123") + result = devbox.keep_alive( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result is not None + mock_client.devboxes.keep_alive.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + def test_snapshot_disk(self, mock_client: Mock) -> None: + """Test snapshot_disk waits for completion.""" + snapshot_data = SimpleNamespace(id="snap_123") + snapshot_status = SimpleNamespace(status="completed") + + mock_client.devboxes.snapshot_disk_async.return_value = snapshot_data + mock_client.devboxes.disk_snapshots.await_completed.return_value = snapshot_status + + devbox = Devbox(mock_client, "dev_123") + polling_config = PollingConfig(timeout_seconds=60.0) + snapshot = devbox.snapshot_disk( + name="test-snapshot", + metadata={"key": "value"}, + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + ) + + assert snapshot.id == "snap_123" + call_kwargs = mock_client.devboxes.snapshot_disk_async.call_args[1] + assert call_kwargs["commit_message"] is omit or call_kwargs["commit_message"] is None + assert call_kwargs["metadata"] == {"key": "value"} + assert call_kwargs["name"] == "test-snapshot" + assert call_kwargs["extra_headers"] == {"X-Custom": "value"} + assert isinstance(call_kwargs["timeout"], NotGiven) + call_kwargs2 = mock_client.devboxes.disk_snapshots.await_completed.call_args[1] + assert call_kwargs2["polling_config"] == polling_config + assert isinstance(call_kwargs2["timeout"], NotGiven) + + def test_snapshot_disk_async(self, mock_client: Mock) -> None: + """Test snapshot_disk_async returns immediately.""" + snapshot_data = SimpleNamespace(id="snap_123") + mock_client.devboxes.snapshot_disk_async.return_value = snapshot_data + + devbox = Devbox(mock_client, "dev_123") + snapshot = devbox.snapshot_disk_async( + name="test-snapshot", + metadata={"key": "value"}, + extra_headers={"X-Custom": "value"}, + ) + + assert snapshot.id == "snap_123" + call_kwargs = mock_client.devboxes.snapshot_disk_async.call_args[1] + assert call_kwargs["commit_message"] is omit or call_kwargs["commit_message"] is None + assert call_kwargs["metadata"] == {"key": "value"} + assert call_kwargs["name"] == "test-snapshot" + assert call_kwargs["extra_headers"] == {"X-Custom": "value"} + assert isinstance(call_kwargs["timeout"], NotGiven) + # Verify async method does not wait for completion + if hasattr(mock_client.devboxes.disk_snapshots, "await_completed"): + assert not mock_client.devboxes.disk_snapshots.await_completed.called + + def test_close(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test close method calls shutdown.""" + mock_client.devboxes.shutdown.return_value = devbox_view + + devbox = Devbox(mock_client, "dev_123") + devbox.close() + + call_kwargs = mock_client.devboxes.shutdown.call_args[1] + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_cmd_property(self, mock_client: Mock) -> None: + """Test cmd property returns CommandInterface.""" + devbox = Devbox(mock_client, "dev_123") + cmd = devbox.cmd + assert isinstance(cmd, _CommandInterface) + assert cmd._devbox is devbox + + def test_file_property(self, mock_client: Mock) -> None: + """Test file property returns FileInterface.""" + devbox = Devbox(mock_client, "dev_123") + file_interface = devbox.file + assert isinstance(file_interface, _FileInterface) + assert file_interface._devbox is devbox + + def test_net_property(self, mock_client: Mock) -> None: + """Test net property returns NetworkInterface.""" + devbox = Devbox(mock_client, "dev_123") + net = devbox.net + assert isinstance(net, _NetworkInterface) + assert net._devbox is devbox + + +class TestCommandInterface: + """Tests for _CommandInterface.""" + + def test_exec_without_callbacks(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test exec without streaming callbacks.""" + mock_client.devboxes.execute_and_await_completion.return_value = execution_view + + devbox = Devbox(mock_client, "dev_123") + result = devbox.cmd.exec("echo hello") + + assert result.exit_code == 0 + assert result.stdout() == "output" + call_kwargs = mock_client.devboxes.execute_and_await_completion.call_args[1] + assert call_kwargs["command"] == "echo hello" + assert isinstance(call_kwargs["shell_name"], NotGiven) or call_kwargs["shell_name"] is None + assert call_kwargs["polling_config"] is None + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_exec_with_stdout_callback(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test exec with stdout callback.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + execution_completed = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="", + ) + + mock_client.devboxes.execute_async.return_value = execution_async + mock_client.devboxes.executions.await_completed.return_value = execution_completed + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + + stdout_calls: list[str] = [] + + devbox = Devbox(mock_client, "dev_123") + result = devbox.cmd.exec("echo hello", stdout=stdout_calls.append) + + assert result.exit_code == 0 + mock_client.devboxes.execute_async.assert_called_once() + mock_client.devboxes.executions.await_completed.assert_called_once() + + def test_exec_with_stderr_callback(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test exec with stderr callback.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + execution_completed = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="", + stderr="error", + ) + + mock_client.devboxes.execute_async.return_value = execution_async + mock_client.devboxes.executions.await_completed.return_value = execution_completed + mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream + + stderr_calls: list[str] = [] + + devbox = Devbox(mock_client, "dev_123") + result = devbox.cmd.exec("echo hello", stderr=stderr_calls.append) + + assert result.exit_code == 0 + mock_client.devboxes.execute_async.assert_called_once() + + def test_exec_with_output_callback(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test exec with output callback.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + execution_completed = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="", + ) + + mock_client.devboxes.execute_async.return_value = execution_async + mock_client.devboxes.executions.await_completed.return_value = execution_completed + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream + + output_calls: list[str] = [] + + devbox = Devbox(mock_client, "dev_123") + result = devbox.cmd.exec("echo hello", output=output_calls.append) + + assert result.exit_code == 0 + mock_client.devboxes.execute_async.assert_called_once() + + def test_exec_with_all_callbacks(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test exec with all callbacks.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + execution_completed = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="error", + ) + + mock_client.devboxes.execute_async.return_value = execution_async + mock_client.devboxes.executions.await_completed.return_value = execution_completed + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream + + stdout_calls: list[str] = [] + stderr_calls: list[str] = [] + output_calls: list[str] = [] + + devbox = Devbox(mock_client, "dev_123") + result = devbox.cmd.exec( + "echo hello", + stdout=stdout_calls.append, + stderr=stderr_calls.append, + output=output_calls.append, + ) + + assert result.exit_code == 0 + mock_client.devboxes.execute_async.assert_called_once() + + def test_exec_async_returns_execution(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test exec_async returns Execution object.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + + mock_client.devboxes.execute_async.return_value = execution_async + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + + devbox = Devbox(mock_client, "dev_123") + execution = devbox.cmd.exec_async("long-running command") + + assert execution.execution_id == "exec_123" + assert execution.devbox_id == "dev_123" + mock_client.devboxes.execute_async.assert_called_once() + + +class TestFileInterface: + """Tests for _FileInterface.""" + + def test_read(self, mock_client: Mock) -> None: + """Test file read.""" + mock_client.devboxes.read_file_contents.return_value = "file content" + + devbox = Devbox(mock_client, "dev_123") + result = devbox.file.read("/path/to/file") + + assert result == "file content" + call_kwargs = mock_client.devboxes.read_file_contents.call_args[1] + assert call_kwargs["file_path"] == "/path/to/file" + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_write_string(self, mock_client: Mock) -> None: + """Test file write with string.""" + execution_detail = SimpleNamespace() + mock_client.devboxes.write_file_contents.return_value = execution_detail + + devbox = Devbox(mock_client, "dev_123") + result = devbox.file.write("/path/to/file", "content") + + assert result == execution_detail + call_kwargs = mock_client.devboxes.write_file_contents.call_args[1] + assert call_kwargs["file_path"] == "/path/to/file" + assert call_kwargs["contents"] == "content" + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_write_bytes(self, mock_client: Mock) -> None: + """Test file write with bytes.""" + execution_detail = SimpleNamespace() + mock_client.devboxes.write_file_contents.return_value = execution_detail + + devbox = Devbox(mock_client, "dev_123") + result = devbox.file.write("/path/to/file", b"content") + + assert result == execution_detail + call_kwargs = mock_client.devboxes.write_file_contents.call_args[1] + assert call_kwargs["file_path"] == "/path/to/file" + assert call_kwargs["contents"] == "content" + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_download(self, mock_client: Mock) -> None: + """Test file download.""" + mock_response = Mock() + mock_response.read.return_value = b"file content" + mock_client.devboxes.download_file.return_value = mock_response + + devbox = Devbox(mock_client, "dev_123") + result = devbox.file.download("/path/to/file") + + assert result == b"file content" + call_kwargs = mock_client.devboxes.download_file.call_args[1] + assert call_kwargs["path"] == "/path/to/file" + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_upload(self, mock_client: Mock) -> None: + """Test file upload.""" + execution_detail = SimpleNamespace() + mock_client.devboxes.upload_file.return_value = execution_detail + + devbox = Devbox(mock_client, "dev_123") + # Create a temporary file for upload + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("test content") + temp_path = Path(f.name) + + try: + result = devbox.file.upload("/remote/path", temp_path) + finally: + temp_path.unlink() + + assert result == execution_detail + call_kwargs = mock_client.devboxes.upload_file.call_args[1] + assert call_kwargs["path"] == "/remote/path" + assert call_kwargs["file"] is not None # File object from temp_path + assert isinstance(call_kwargs["timeout"], NotGiven) + + +class TestNetworkInterface: + """Tests for _NetworkInterface.""" + + def test_create_ssh_key(self, mock_client: Mock) -> None: + """Test create SSH key.""" + ssh_key_response = SimpleNamespace(public_key="ssh-rsa ...") + mock_client.devboxes.create_ssh_key.return_value = ssh_key_response + + devbox = Devbox(mock_client, "dev_123") + result = devbox.net.create_ssh_key( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == ssh_key_response + mock_client.devboxes.create_ssh_key.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + def test_create_tunnel(self, mock_client: Mock) -> None: + """Test create tunnel.""" + tunnel_view = SimpleNamespace(port=8080) + mock_client.devboxes.create_tunnel.return_value = tunnel_view + + devbox = Devbox(mock_client, "dev_123") + result = devbox.net.create_tunnel( + port=8080, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == tunnel_view + mock_client.devboxes.create_tunnel.assert_called_once_with( + "dev_123", + port=8080, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + def test_remove_tunnel(self, mock_client: Mock) -> None: + """Test remove tunnel.""" + # Return value not used - testing parameter passing only + mock_client.devboxes.remove_tunnel.return_value = object() + + devbox = Devbox(mock_client, "dev_123") + result = devbox.net.remove_tunnel( + port=8080, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result is not None + mock_client.devboxes.remove_tunnel.assert_called_once_with( + "dev_123", + port=8080, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + +class TestDevboxStreaming: + """Tests for Devbox streaming methods.""" + + def test_start_streaming_no_callbacks(self, mock_client: Mock) -> None: + """Test _start_streaming returns None when no callbacks.""" + devbox = Devbox(mock_client, "dev_123") + result = devbox._start_streaming("exec_123", stdout=None, stderr=None, output=None) + assert result is None + + def test_start_streaming_stdout_only(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test _start_streaming with stdout callback only.""" + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + + devbox = Devbox(mock_client, "dev_123") + stdout_calls: list[str] = [] + result = devbox._start_streaming("exec_123", stdout=stdout_calls.append, stderr=None, output=None) + + assert result is not None + assert isinstance(result, _StreamingGroup) + assert len(result._threads) == 1 + mock_client.devboxes.executions.stream_stdout_updates.assert_called_once() + + def test_start_streaming_stderr_only(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test _start_streaming with stderr callback only.""" + mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream + + devbox = Devbox(mock_client, "dev_123") + stderr_calls: list[str] = [] + result = devbox._start_streaming("exec_123", stdout=None, stderr=stderr_calls.append, output=None) + + assert result is not None + assert isinstance(result, _StreamingGroup) + assert len(result._threads) == 1 + mock_client.devboxes.executions.stream_stderr_updates.assert_called_once() + + def test_start_streaming_output_only(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test _start_streaming with output callback only.""" + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream + + devbox = Devbox(mock_client, "dev_123") + output_calls: list[str] = [] + result = devbox._start_streaming("exec_123", stdout=None, stderr=None, output=output_calls.append) + + assert result is not None + assert isinstance(result, _StreamingGroup) + assert len(result._threads) == 2 # Both stdout and stderr streams + + def test_start_streaming_all_callbacks(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test _start_streaming with all callbacks.""" + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream + + devbox = Devbox(mock_client, "dev_123") + stdout_calls: list[str] = [] + stderr_calls: list[str] = [] + output_calls: list[str] = [] + result = devbox._start_streaming( + "exec_123", + stdout=stdout_calls.append, + stderr=stderr_calls.append, + output=output_calls.append, + ) + + assert result is not None + assert isinstance(result, _StreamingGroup) + assert len(result._threads) == 2 # Both stdout and stderr streams + + def test_spawn_stream_thread(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test _spawn_stream_thread creates and starts thread.""" + mock_stream.__iter__ = Mock( + return_value=iter( + [ + SimpleNamespace(output="line 1"), + SimpleNamespace(output="line 2"), + ] + ) + ) + mock_stream.__enter__ = Mock(return_value=mock_stream) + mock_stream.__exit__ = Mock(return_value=None) + + devbox = Devbox(mock_client, "dev_123") + stop_event = threading.Event() + calls: list[str] = [] + + def stream_factory() -> Stream: + return mock_stream + + thread = devbox._spawn_stream_thread( + name="test", + stream_factory=stream_factory, + callbacks=[calls.append], + stop_event=stop_event, + ) + + assert isinstance(thread, threading.Thread) + # Give thread time to start + time.sleep(SHORT_SLEEP) + # Thread may have already finished if stream is short + if thread.is_alive(): + stop_event.set() + thread.join(timeout=1.0) + assert not thread.is_alive() + + def test_spawn_stream_thread_stop_event(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test _spawn_stream_thread respects stop event.""" + mock_stream.__iter__ = Mock( + return_value=iter( + [ + SimpleNamespace(output="line 1"), + SimpleNamespace(output="line 2"), + ] + ) + ) + mock_stream.__enter__ = Mock(return_value=mock_stream) + mock_stream.__exit__ = Mock(return_value=None) + + devbox = Devbox(mock_client, "dev_123") + stop_event = threading.Event() + calls: list[str] = [] + + def stream_factory() -> Stream: + return mock_stream + + thread = devbox._spawn_stream_thread( + name="test", + stream_factory=stream_factory, + callbacks=[calls.append], + stop_event=stop_event, + ) + + stop_event.set() + thread.join(timeout=1.0) + assert not thread.is_alive() + + +class TestDevboxErrorHandling: + """Tests for Devbox error handling scenarios.""" + + def test_network_error(self, mock_client: Mock) -> None: + """Test handling of network errors.""" + mock_client.devboxes.retrieve.side_effect = httpx.NetworkError("Connection failed") + + devbox = Devbox(mock_client, "dev_123") + with pytest.raises(httpx.NetworkError): + devbox.get_info() + + @pytest.mark.parametrize( + "status_code,message", + [ + (404, "Not Found"), + (500, "Internal Server Error"), + (503, "Service Unavailable"), + ], ) + def test_api_error(self, mock_client: Mock, status_code: int, message: str) -> None: + """Test handling of API errors with various status codes.""" + response = create_mock_httpx_response(status_code=status_code, headers={}, text=message) + error = APIStatusError(message=message, response=response, body=None) - def fake_execute_and_await_completion(devbox_id: str, **kwargs): - assert devbox_id == "dev_456" - assert kwargs["command"] == "echo hello" - return result - - monkeypatch.setattr(devboxes_resource, "execute_and_await_completion", fake_execute_and_await_completion) + mock_client.devboxes.retrieve.side_effect = error - devbox = sdk.devbox.from_id("dev_456") - execution_result = devbox.cmd.exec("echo hello") + devbox = Devbox(mock_client, "dev_123") + with pytest.raises(APIStatusError): + devbox.get_info() - assert isinstance(execution_result, ExecutionResult) - assert execution_result.exit_code == 0 - assert execution_result.stdout() == "hello" + def test_timeout_error(self, mock_client: Mock) -> None: + """Test handling of timeout errors.""" + mock_client.devboxes.retrieve.side_effect = httpx.TimeoutException("Request timed out") + devbox = Devbox(mock_client, "dev_123") + with pytest.raises(httpx.TimeoutException): + devbox.get_info(timeout=1.0) -def test_exec_with_streaming_callbacks(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: - devboxes_resource = sdk.api.devboxes - executions_resource = devboxes_resource.executions - - execution = SimpleNamespace( - execution_id="exec-stream", - devbox_id="dev_stream", - stdout="", - stderr="", - exit_status=None, - status="running", - ) - - final = SimpleNamespace( - execution_id="exec-stream", - devbox_id="dev_stream", - stdout="done", - stderr="", - exit_status=0, - status="completed", - ) - def fake_execute_async(devbox_id: str, **kwargs): - assert kwargs["command"] == "long task" - assert devbox_id == "dev_stream" - return execution +class TestDevboxEdgeCases: + """Tests for Devbox edge cases.""" - def fake_await_completed(execution_id: str, devbox_id: str, **_kwargs): - assert execution_id == "exec-stream" - assert devbox_id == "dev_stream" - return final + def test_empty_responses(self, mock_client: Mock) -> None: + """Test handling of empty responses.""" + empty_view = SimpleNamespace(id="dev_123", status="", name="") + mock_client.devboxes.retrieve.return_value = empty_view - class DummyStream: - def __init__(self, values: list[str]): - self._values = values + devbox = Devbox(mock_client, "dev_123") + result = devbox.get_info() + assert result == empty_view - def __iter__(self): - for value in self._values: - yield SimpleNamespace(output=value) + def test_none_values(self, mock_client: Mock) -> None: + """Test handling of None values.""" + view_with_none = SimpleNamespace(id="dev_123", status=None, name=None) + mock_client.devboxes.retrieve.return_value = view_with_none - def __enter__(self): - return self + devbox = Devbox(mock_client, "dev_123") + result = devbox.get_info() + assert result.status is None + assert result.name is None - def __exit__(self, *exc): - return False + def test_concurrent_operations(self, mock_client: Mock) -> None: + """Test concurrent operations.""" + mock_client.devboxes.retrieve.return_value = SimpleNamespace(id="dev_123", status="running") - monkeypatch.setattr(devboxes_resource, "execute_async", fake_execute_async) - monkeypatch.setattr(executions_resource, "await_completed", fake_await_completed) - monkeypatch.setattr( - executions_resource, - "stream_stdout_updates", - lambda execution_id, *, devbox_id: DummyStream(["line 1", "line 2"]), - ) - monkeypatch.setattr( - executions_resource, - "stream_stderr_updates", - lambda execution_id, *, devbox_id: DummyStream([]), - ) + devbox = Devbox(mock_client, "dev_123") + results = [] - stdout_logs: list[str] = [] - combined_logs: list[str] = [] + def get_info() -> None: + results.append(devbox.get_info()) - devbox = sdk.devbox.from_id("dev_stream") - result = devbox.cmd.exec( - "long task", - stdout=stdout_logs.append, - output=combined_logs.append, - ) + threads = [threading.Thread(target=get_info) for _ in range(NUM_CONCURRENT_THREADS)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() - assert stdout_logs == ["line 1", "line 2"] - assert combined_logs == ["line 1", "line 2"] - assert result.exit_code == 0 - assert result.stdout() == "done" + assert len(results) == NUM_CONCURRENT_THREADS -def test_exec_async_returns_execution(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: - devboxes_resource = sdk.api.devboxes - executions_resource = devboxes_resource.executions +class TestDevboxPythonSpecific: + """Tests for Python-specific Devbox behavior.""" - execution = SimpleNamespace( - execution_id="exec-async", - devbox_id="dev_async", - stdout="", - stderr="", - exit_status=None, - status="running", - ) + def test_context_manager_vs_manual_cleanup(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test context manager provides automatic cleanup.""" + mock_client.devboxes.shutdown.return_value = devbox_view - final = SimpleNamespace( - execution_id="exec-async", - devbox_id="dev_async", - stdout="async complete", - stderr="", - exit_status=0, - status="completed", - ) + # Context manager approach (Pythonic) + with Devbox(mock_client, "dev_123"): + pass - monkeypatch.setattr(devboxes_resource, "execute_async", lambda devbox_id, **kwargs: execution) - monkeypatch.setattr( - executions_resource, - "await_completed", - lambda execution_id, *, devbox_id, **kwargs: final, - ) + mock_client.devboxes.shutdown.assert_called_once() - class EmptyStream: - def __iter__(self): - return iter(()) + # Manual cleanup (TypeScript-like) + devbox = Devbox(mock_client, "dev_123") + devbox.shutdown() + assert mock_client.devboxes.shutdown.call_count == 2 - def __enter__(self): - return self + def test_path_handling(self, mock_client: Mock) -> None: + """Test Path handling (Python-specific).""" + object_view = SimpleNamespace(id="obj_123", upload_url="https://upload.example.com") + mock_client.objects.create.return_value = object_view - def __exit__(self, *exc): - return False + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("test") + temp_path = Path(f.name) - monkeypatch.setattr( - executions_resource, - "stream_stdout_updates", - lambda execution_id, *, devbox_id: EmptyStream(), - ) - monkeypatch.setattr( - executions_resource, - "stream_stderr_updates", - lambda execution_id, *, devbox_id: EmptyStream(), - ) + try: + with patch("httpx.put") as mock_put: + mock_response = create_mock_httpx_response() + mock_put.return_value = mock_response - devbox = sdk.devbox.from_id("dev_async") - execution_obj = devbox.cmd.exec_async("background task") + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + obj.upload_content(temp_path) # Path object works - assert isinstance(execution_obj, Execution) - result = execution_obj.result() - assert result.stdout() == "async complete" + mock_put.assert_called_once() + finally: + temp_path.unlink() diff --git a/tests/sdk/test_execution.py b/tests/sdk/test_execution.py new file mode 100644 index 000000000..5dd7624ef --- /dev/null +++ b/tests/sdk/test_execution.py @@ -0,0 +1,248 @@ +"""Comprehensive tests for Execution class.""" + +from __future__ import annotations + +import time +import threading +from types import SimpleNamespace +from unittest.mock import Mock + +from runloop_api_client.sdk.execution import Execution, _StreamingGroup + +# Test constants +SHORT_SLEEP = 0.1 # Brief pause for thread startup +MEDIUM_SLEEP = 0.2 # Slightly longer pause +LONG_SLEEP = 1.0 # Simulates long-running operation for cancellation tests + + +class TestStreamingGroup: + """Tests for _StreamingGroup.""" + + def test_init(self) -> None: + """Test _StreamingGroup initialization.""" + threads = [threading.Thread(target=lambda: None)] + stop_event = threading.Event() + group = _StreamingGroup(threads, stop_event) + assert group._threads == threads + assert group._stop_event is stop_event + + def test_stop(self) -> None: + """Test stop method sets event.""" + stop_event = threading.Event() + threads = [threading.Thread(target=lambda: None)] + group = _StreamingGroup(threads, stop_event) + + assert not stop_event.is_set() + group.stop() + assert stop_event.is_set() + + def test_join(self) -> None: + """Test join waits for threads.""" + stop_event = threading.Event() + thread = threading.Thread(target=lambda: time.sleep(SHORT_SLEEP)) + thread.start() + group = _StreamingGroup([thread], stop_event) + + group.join(timeout=1.0) + assert not thread.is_alive() + + def test_active_property(self) -> None: + """Test active property.""" + stop_event = threading.Event() + thread = threading.Thread(target=lambda: time.sleep(MEDIUM_SLEEP)) + thread.start() + group = _StreamingGroup([thread], stop_event) + + assert group.active is True + thread.join() + assert group.active is False + + def test_active_multiple_threads(self) -> None: + """Test active property with multiple threads.""" + stop_event = threading.Event() + thread1 = threading.Thread(target=lambda: time.sleep(SHORT_SLEEP)) + thread2 = threading.Thread(target=lambda: time.sleep(MEDIUM_SLEEP)) + thread1.start() + thread2.start() + group = _StreamingGroup([thread1, thread2], stop_event) + + assert group.active is True + thread1.join() + assert group.active is True # thread2 still active + thread2.join() + assert group.active is False + + +class TestExecution: + """Tests for Execution class.""" + + def test_init(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test Execution initialization.""" + execution = Execution(mock_client, "dev_123", execution_view) + assert execution.execution_id == "exec_123" + assert execution.devbox_id == "dev_123" + assert execution._latest == execution_view + + def test_init_with_streaming_group(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test Execution initialization with streaming group.""" + threads = [threading.Thread(target=lambda: None)] + stop_event = threading.Event() + streaming_group = _StreamingGroup(threads, stop_event) + + execution = Execution(mock_client, "dev_123", execution_view, streaming_group) + assert execution._streaming_group is streaming_group + + def test_properties(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test Execution properties.""" + execution = Execution(mock_client, "dev_123", execution_view) + assert execution.execution_id == "exec_123" + assert execution.devbox_id == "dev_123" + + def test_result_already_completed(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test result when execution is already completed.""" + execution = Execution(mock_client, "dev_123", execution_view) + result = execution.result() + + assert result.exit_code == 0 + assert result.stdout() == "output" + # Verify await_completed is not called when already completed + if hasattr(mock_client.devboxes.executions, "await_completed"): + assert not mock_client.devboxes.executions.await_completed.called + + def test_result_needs_polling(self, mock_client: Mock) -> None: + """Test result when execution needs polling.""" + running_execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + completed_execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="", + ) + + mock_client.devboxes.executions.await_completed.return_value = completed_execution + + execution = Execution(mock_client, "dev_123", running_execution) + result = execution.result() + + assert result.exit_code == 0 + assert result.stdout() == "output" + mock_client.devboxes.executions.await_completed.assert_called_once_with( + "exec_123", + devbox_id="dev_123", + polling_config=None, + ) + + def test_result_with_streaming_group(self, mock_client: Mock) -> None: + """Test result with streaming group cleanup.""" + running_execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + completed_execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="", + ) + + mock_client.devboxes.executions.await_completed.return_value = completed_execution + + stop_event = threading.Event() + thread = threading.Thread(target=lambda: time.sleep(SHORT_SLEEP)) + thread.start() + streaming_group = _StreamingGroup([thread], stop_event) + + execution = Execution(mock_client, "dev_123", running_execution, streaming_group) + result = execution.result() + + assert result.exit_code == 0 + assert execution._streaming_group is None # Should be cleaned up + + def test_get_state(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test get_state method.""" + updated_execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + mock_client.devboxes.executions.retrieve.return_value = updated_execution + + execution = Execution(mock_client, "dev_123", execution_view) + result = execution.get_state() + + assert result == updated_execution + assert execution._latest == updated_execution + mock_client.devboxes.executions.retrieve.assert_called_once_with( + "exec_123", + devbox_id="dev_123", + ) + + def test_kill(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test kill method.""" + mock_client.devboxes.executions.kill.return_value = None + + execution = Execution(mock_client, "dev_123", execution_view) + execution.kill() + + mock_client.devboxes.executions.kill.assert_called_once_with( + "exec_123", + devbox_id="dev_123", + kill_process_group=None, + ) + + def test_kill_with_process_group(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test kill with kill_process_group.""" + mock_client.devboxes.executions.kill.return_value = None + + execution = Execution(mock_client, "dev_123", execution_view) + execution.kill(kill_process_group=True) + + mock_client.devboxes.executions.kill.assert_called_once_with( + "exec_123", + devbox_id="dev_123", + kill_process_group=True, + ) + + def test_kill_with_streaming_cleanup(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test kill cleans up streaming.""" + mock_client.devboxes.executions.kill.return_value = None + + stop_event = threading.Event() + # Thread needs to be started to be joinable + thread = threading.Thread(target=lambda: time.sleep(LONG_SLEEP)) + thread.start() + streaming_group = _StreamingGroup([thread], stop_event) + + execution = Execution(mock_client, "dev_123", execution_view, streaming_group) + execution.kill() + + assert execution._streaming_group is None # Should be cleaned up + assert stop_event.is_set() # Should be stopped + + def test_stop_streaming_no_group(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test _stop_streaming when no streaming group.""" + execution = Execution(mock_client, "dev_123", execution_view) + execution._stop_streaming() # Should not raise + + def test_stop_streaming_with_group(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test _stop_streaming with streaming group.""" + stop_event = threading.Event() + # Thread needs to be started to be joinable + thread = threading.Thread(target=lambda: time.sleep(LONG_SLEEP)) + thread.start() + streaming_group = _StreamingGroup([thread], stop_event) + + execution = Execution(mock_client, "dev_123", execution_view, streaming_group) + execution._stop_streaming() + + assert execution._streaming_group is None + assert stop_event.is_set() diff --git a/tests/sdk/test_execution_result.py b/tests/sdk/test_execution_result.py new file mode 100644 index 000000000..3eda81d23 --- /dev/null +++ b/tests/sdk/test_execution_result.py @@ -0,0 +1,137 @@ +"""Comprehensive tests for ExecutionResult class.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +from runloop_api_client.sdk.execution_result import ExecutionResult + + +class TestExecutionResult: + """Tests for ExecutionResult class.""" + + def test_init(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test ExecutionResult initialization.""" + result = ExecutionResult(mock_client, "dev_123", execution_view) + # Verify via public API + assert result.devbox_id == "dev_123" + assert result.execution_id == "exec_123" + + def test_devbox_id_property(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test devbox_id property.""" + result = ExecutionResult(mock_client, "dev_123", execution_view) + assert result.devbox_id == "dev_123" + + def test_execution_id_property(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test execution_id property.""" + result = ExecutionResult(mock_client, "dev_123", execution_view) + assert result.execution_id == "exec_123" + + def test_exit_code_property(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test exit_code property.""" + result = ExecutionResult(mock_client, "dev_123", execution_view) + assert result.exit_code == 0 + + def test_exit_code_none(self, mock_client: Mock) -> None: + """Test exit_code property when exit_status is None.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + exit_status=None, + stdout="", + stderr="", + ) + result = ExecutionResult(mock_client, "dev_123", execution) + assert result.exit_code is None + + def test_success_property(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test success property.""" + result = ExecutionResult(mock_client, "dev_123", execution_view) + assert result.success is True + + def test_success_false(self, mock_client: Mock) -> None: + """Test success property when exit code is non-zero.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=1, + stdout="", + stderr="error", + ) + result = ExecutionResult(mock_client, "dev_123", execution) + assert result.success is False + + def test_failed_property(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test failed property when exit code is zero.""" + result = ExecutionResult(mock_client, "dev_123", execution_view) + assert result.failed is False + + def test_failed_true(self, mock_client: Mock) -> None: + """Test failed property when exit code is non-zero.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=1, + stdout="", + stderr="error", + ) + result = ExecutionResult(mock_client, "dev_123", execution) + assert result.failed is True + + def test_failed_none(self, mock_client: Mock) -> None: + """Test failed property when exit_status is None.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + exit_status=None, + stdout="", + stderr="", + ) + result = ExecutionResult(mock_client, "dev_123", execution) + assert result.failed is False + + def test_stdout(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test stdout method.""" + result = ExecutionResult(mock_client, "dev_123", execution_view) + assert result.stdout() == "output" + + def test_stdout_empty(self, mock_client: Mock) -> None: + """Test stdout method when stdout is None.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout=None, + stderr="", + ) + result = ExecutionResult(mock_client, "dev_123", execution) + assert result.stdout() == "" + + def test_stderr(self, mock_client: Mock) -> None: + """Test stderr method.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=1, + stdout="", + stderr="error message", + ) + result = ExecutionResult(mock_client, "dev_123", execution) + assert result.stderr() == "error message" + + def test_stderr_empty(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test stderr method when stderr is None.""" + result = ExecutionResult(mock_client, "dev_123", execution_view) + assert result.stderr() == "" + + def test_raw_property(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + """Test raw property.""" + result = ExecutionResult(mock_client, "dev_123", execution_view) + assert result.raw == execution_view diff --git a/tests/sdk/test_imports.py b/tests/sdk/test_imports.py deleted file mode 100644 index cf2cd8284..000000000 --- a/tests/sdk/test_imports.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -import pytest - -from runloop_api_client import Runloop, RunloopSDK, AsyncRunloop, AsyncRunloopSDK - - -def test_runloop_sdk_exposes_api() -> None: - sdk = RunloopSDK(bearer_token="test-token") - try: - assert isinstance(sdk.api, Runloop) - from runloop_api_client.sdk import DevboxClient - - assert isinstance(sdk.devbox, DevboxClient) - finally: - sdk.close() - - -@pytest.mark.asyncio -async def test_async_runloop_sdk_exposes_api() -> None: - sdk = AsyncRunloopSDK(bearer_token="test-token") - try: - assert isinstance(sdk.api, AsyncRunloop) - from runloop_api_client.sdk import AsyncDevboxClient - - assert isinstance(sdk.devbox, AsyncDevboxClient) - finally: - await sdk.aclose() diff --git a/tests/sdk/test_resources.py b/tests/sdk/test_resources.py deleted file mode 100644 index 5e61a0f37..000000000 --- a/tests/sdk/test_resources.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -from typing import List - -import httpx -import pytest - -from runloop_api_client import RunloopSDK -from runloop_api_client.sdk import Blueprint, StorageObject - - -@pytest.fixture() -def sdk() -> RunloopSDK: - return RunloopSDK(bearer_token="test-token") - - -def test_blueprint_create_and_devbox(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: - blueprints_resource = sdk.api.blueprints - created: List[str] = [] - - def fake_create_and_await_build_complete(**kwargs): - created.append(kwargs["name"]) - return SimpleNamespace(id="bp-001") - - monkeypatch.setattr(blueprints_resource, "create_and_await_build_complete", fake_create_and_await_build_complete) - - devbox_calls: List[dict[str, object]] = [] - - monkeypatch.setattr( - sdk.devbox, - "create", - lambda **kwargs: (devbox_calls.append(kwargs), SimpleNamespace(id="dev-123"))[1], - ) - - blueprint = sdk.blueprint.create(name="my-blueprint") - assert isinstance(blueprint, Blueprint) - blueprint.create_devbox() - - assert created == ["my-blueprint"] - assert devbox_calls[0]["blueprint_id"] == "bp-001" - - -def test_snapshot_list_and_devbox(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: - disk_snapshots_resource = sdk.api.devboxes.disk_snapshots - - page = SimpleNamespace(disk_snapshots=[SimpleNamespace(id="snap-1"), SimpleNamespace(id="snap-2")]) - monkeypatch.setattr(disk_snapshots_resource, "list", lambda **kwargs: page) - - devbox_calls: List[dict[str, object]] = [] - monkeypatch.setattr( - sdk.devbox, - "create", - lambda **kwargs: (devbox_calls.append(kwargs), SimpleNamespace(id="dev-from-snap"))[1], - ) - - snapshots = sdk.snapshot.list() - assert [snap.id for snap in snapshots] == ["snap-1", "snap-2"] - snapshots[0].create_devbox() - - assert devbox_calls[0]["snapshot_id"] == "snap-1" - - -def test_storage_object_upload_and_download(monkeypatch: pytest.MonkeyPatch, sdk: RunloopSDK) -> None: - objects_resource = sdk.api.objects - - created_objects: List[dict[str, object]] = [] - - def fake_create(**kwargs): - created_objects.append(kwargs) - return SimpleNamespace(id="obj-1", upload_url="https://upload.example.com") - - completed_ids: List[str] = [] - - def fake_complete(object_id: str, **_kwargs): - completed_ids.append(object_id) - return SimpleNamespace(id=object_id, upload_url=None) - - download_urls: List[str] = [] - - def fake_download(object_id: str, **kwargs): - url = f"https://download.example.com/{object_id}" - download_urls.append(url) - return SimpleNamespace(download_url=url) - - monkeypatch.setattr(objects_resource, "create", fake_create) - monkeypatch.setattr(objects_resource, "complete", fake_complete) - monkeypatch.setattr(objects_resource, "download", fake_download) - - put_calls: List[tuple[str, bytes]] = [] - get_calls: List[str] = [] - - def fake_put(url: str, *, content: bytes, **_kwargs): - put_calls.append((url, content)) - return SimpleNamespace(raise_for_status=lambda: None) - - def fake_get(url: str, **_kwargs): - get_calls.append(url) - return SimpleNamespace(raise_for_status=lambda: None, content=b"hello", text="hello", encoding="utf-8") - - monkeypatch.setattr(httpx, "put", fake_put) - monkeypatch.setattr(httpx, "get", fake_get) - - obj = sdk.storage_object.upload_from_text("hello", name="greeting.txt") - assert isinstance(obj, StorageObject) - assert put_calls == [("https://upload.example.com", b"hello")] - assert completed_ids == ["obj-1"] - - data = obj.download_as_text() - assert data == "hello" - assert get_calls == ["https://download.example.com/obj-1"] diff --git a/tests/sdk/test_snapshot.py b/tests/sdk/test_snapshot.py new file mode 100644 index 000000000..a778f2860 --- /dev/null +++ b/tests/sdk/test_snapshot.py @@ -0,0 +1,141 @@ +"""Comprehensive tests for sync Snapshot class.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +from runloop_api_client.sdk import Snapshot +from runloop_api_client.lib.polling import PollingConfig + + +class TestSnapshot: + """Tests for Snapshot class.""" + + def test_init(self, mock_client: Mock) -> None: + """Test Snapshot initialization.""" + snapshot = Snapshot(mock_client, "snap_123") + assert snapshot.id == "snap_123" + + def test_repr(self, mock_client: Mock) -> None: + """Test Snapshot string representation.""" + snapshot = Snapshot(mock_client, "snap_123") + assert repr(snapshot) == "" + + def test_get_info(self, mock_client: Mock, snapshot_view: SimpleNamespace) -> None: + """Test get_info method.""" + mock_client.devboxes.disk_snapshots.query_status.return_value = snapshot_view + + snapshot = Snapshot(mock_client, "snap_123") + result = snapshot.get_info( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == snapshot_view + mock_client.devboxes.disk_snapshots.query_status.assert_called_once_with( + "snap_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + def test_update(self, mock_client: Mock) -> None: + """Test update method.""" + updated_snapshot = SimpleNamespace(id="snap_123", name="updated-name") + mock_client.devboxes.disk_snapshots.update.return_value = updated_snapshot + + snapshot = Snapshot(mock_client, "snap_123") + result = snapshot.update( + commit_message="Update message", + metadata={"key": "value"}, + name="updated-name", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == updated_snapshot + mock_client.devboxes.disk_snapshots.update.assert_called_once_with( + "snap_123", + commit_message="Update message", + metadata={"key": "value"}, + name="updated-name", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + def test_delete(self, mock_client: Mock) -> None: + """Test delete method.""" + # Return value not used - testing side effect only + mock_client.devboxes.disk_snapshots.delete.return_value = object() + + snapshot = Snapshot(mock_client, "snap_123") + result = snapshot.delete( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result is not None + mock_client.devboxes.disk_snapshots.delete.assert_called_once_with( + "snap_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + def test_await_completed(self, mock_client: Mock, snapshot_view: SimpleNamespace) -> None: + """Test await_completed method.""" + mock_client.devboxes.disk_snapshots.await_completed.return_value = snapshot_view + polling_config = PollingConfig(timeout_seconds=60.0) + + snapshot = Snapshot(mock_client, "snap_123") + result = snapshot.await_completed( + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == snapshot_view + mock_client.devboxes.disk_snapshots.await_completed.assert_called_once_with( + "snap_123", + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + def test_create_devbox(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + """Test create_devbox method.""" + mock_client.devboxes.create_and_await_running.return_value = devbox_view + + snapshot = Snapshot(mock_client, "snap_123") + devbox = snapshot.create_devbox( + name="test-devbox", + metadata={"key": "value"}, + polling_config=PollingConfig(timeout_seconds=60.0), + extra_headers={"X-Custom": "value"}, + ) + + assert devbox.id == "dev_123" + mock_client.devboxes.create_and_await_running.assert_called_once() + call_kwargs = mock_client.devboxes.create_and_await_running.call_args[1] + assert call_kwargs["snapshot_id"] == "snap_123" + assert call_kwargs["name"] == "test-devbox" + assert call_kwargs["metadata"] == {"key": "value"} diff --git a/tests/sdk/test_storage_object.py b/tests/sdk/test_storage_object.py new file mode 100644 index 000000000..d096101e2 --- /dev/null +++ b/tests/sdk/test_storage_object.py @@ -0,0 +1,343 @@ +"""Comprehensive tests for sync StorageObject class.""" + +from __future__ import annotations + +import tempfile +from types import SimpleNamespace +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from tests.sdk.conftest import create_mock_httpx_response +from runloop_api_client.sdk import StorageObject +from runloop_api_client.sdk._sync import StorageObjectClient + + +class TestStorageObject: + """Tests for StorageObject class.""" + + def test_init(self, mock_client: Mock) -> None: + """Test StorageObject initialization.""" + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + assert obj.id == "obj_123" + assert obj.upload_url == "https://upload.example.com" + + def test_init_no_upload_url(self, mock_client: Mock) -> None: + """Test StorageObject initialization without upload URL.""" + obj = StorageObject(mock_client, "obj_123", None) + assert obj.id == "obj_123" + assert obj.upload_url is None + + def test_repr(self, mock_client: Mock) -> None: + """Test StorageObject string representation.""" + obj = StorageObject(mock_client, "obj_123", None) + assert repr(obj) == "" + + def test_refresh(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + """Test refresh method.""" + mock_client.objects.retrieve.return_value = object_view + + obj = StorageObject(mock_client, "obj_123", None) + result = obj.refresh( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == object_view + mock_client.objects.retrieve.assert_called_once_with( + "obj_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + def test_complete(self, mock_client: Mock) -> None: + """Test complete method updates upload_url to None.""" + completed_view = SimpleNamespace(id="obj_123", upload_url=None) + mock_client.objects.complete.return_value = completed_view + + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + assert obj.upload_url == "https://upload.example.com" + + result = obj.complete( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == completed_view + assert obj.upload_url is None + mock_client.objects.complete.assert_called_once_with( + "obj_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + def test_get_download_url_without_duration(self, mock_client: Mock) -> None: + """Test get_download_url without duration_seconds.""" + download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") + mock_client.objects.download.return_value = download_url_view + + obj = StorageObject(mock_client, "obj_123", None) + result = obj.get_download_url( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == download_url_view + mock_client.objects.download.assert_called_once_with( + "obj_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + def test_get_download_url_with_duration(self, mock_client: Mock) -> None: + """Test get_download_url with duration_seconds.""" + download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") + mock_client.objects.download.return_value = download_url_view + + obj = StorageObject(mock_client, "obj_123", None) + result = obj.get_download_url( + duration_seconds=3600, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == download_url_view + mock_client.objects.download.assert_called_once_with( + "obj_123", + duration_seconds=3600, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + @patch("httpx.get") + def test_download_as_bytes(self, mock_get: Mock, mock_client: Mock) -> None: + """Test download_as_bytes method.""" + download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") + mock_client.objects.download.return_value = download_url_view + + mock_response = create_mock_httpx_response(content=b"file content") + mock_get.return_value = mock_response + + obj = StorageObject(mock_client, "obj_123", None) + result = obj.download_as_bytes( + duration_seconds=3600, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == b"file content" + mock_get.assert_called_once_with("https://download.example.com/obj_123") + mock_response.raise_for_status.assert_called_once() + + @patch("httpx.get") + def test_download_as_text_default_encoding(self, mock_get: Mock, mock_client: Mock) -> None: + """Test download_as_text with default encoding.""" + download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") + mock_client.objects.download.return_value = download_url_view + + mock_response = create_mock_httpx_response(text="file content", encoding="utf-8") + mock_get.return_value = mock_response + + obj = StorageObject(mock_client, "obj_123", None) + result = obj.download_as_text() + + assert result == "file content" + assert mock_response.encoding == "utf-8" + mock_get.assert_called_once() + + @patch("httpx.get") + def test_download_as_text_custom_encoding(self, mock_get: Mock, mock_client: Mock) -> None: + """Test download_as_text with custom encoding.""" + download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") + mock_client.objects.download.return_value = download_url_view + + mock_response = create_mock_httpx_response(text="file content", encoding="utf-8") + mock_get.return_value = mock_response + + obj = StorageObject(mock_client, "obj_123", None) + result = obj.download_as_text(encoding="latin-1") + + assert result == "file content" + assert mock_response.encoding == "latin-1" + mock_get.assert_called_once() + + def test_delete(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + """Test delete method.""" + mock_client.objects.delete.return_value = object_view + + obj = StorageObject(mock_client, "obj_123", None) + result = obj.delete( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == object_view + mock_client.objects.delete.assert_called_once_with( + "obj_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + @patch("httpx.put") + def test_upload_content_string(self, mock_put: Mock, mock_client: Mock) -> None: + """Test upload_content with string.""" + mock_response = create_mock_httpx_response() + mock_put.return_value = mock_response + + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + obj.upload_content("test content") + + mock_put.assert_called_once_with("https://upload.example.com", content=b"test content") + mock_response.raise_for_status.assert_called_once() + + @patch("httpx.put") + def test_upload_content_bytes(self, mock_put: Mock, mock_client: Mock) -> None: + """Test upload_content with bytes.""" + mock_response = create_mock_httpx_response() + mock_put.return_value = mock_response + + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + obj.upload_content(b"test content") + + mock_put.assert_called_once_with("https://upload.example.com", content=b"test content") + mock_response.raise_for_status.assert_called_once() + + @patch("httpx.put") + def test_upload_content_path(self, mock_put: Mock, mock_client: Mock) -> None: + """Test upload_content with Path.""" + mock_response = create_mock_httpx_response() + mock_put.return_value = mock_response + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("test content") + temp_path = Path(f.name) + + try: + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + obj.upload_content(temp_path) + + mock_put.assert_called_once() + call_args = mock_put.call_args + assert call_args[0][0] == "https://upload.example.com" + assert call_args[1]["content"] == b"test content" + mock_response.raise_for_status.assert_called_once() + finally: + temp_path.unlink() + + def test_upload_content_no_url(self, mock_client: Mock) -> None: + """Test upload_content raises error when no upload URL.""" + obj = StorageObject(mock_client, "obj_123", None) + + with pytest.raises(RuntimeError, match="No upload URL available"): + obj.upload_content("test content") + + def test_ensure_upload_url_with_url(self, mock_client: Mock) -> None: + """Test _ensure_upload_url returns URL when available.""" + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + url = obj._ensure_upload_url() + assert url == "https://upload.example.com" + + def test_ensure_upload_url_no_url(self, mock_client: Mock) -> None: + """Test _ensure_upload_url raises error when no URL.""" + obj = StorageObject(mock_client, "obj_123", None) + + with pytest.raises(RuntimeError, match="No upload URL available"): + obj._ensure_upload_url() + + +class TestStorageObjectEdgeCases: + """Tests for StorageObject edge cases.""" + + def test_large_file_upload(self, mock_client: Mock) -> None: + """Test handling of large file uploads.""" + LARGE_FILE_SIZE = 10 * 1024 * 1024 # 10MB + + object_view = SimpleNamespace(id="obj_123", upload_url="https://upload.example.com") + mock_client.objects.create.return_value = object_view + + with patch("httpx.put") as mock_put: + mock_response = create_mock_httpx_response() + mock_put.return_value = mock_response + + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + large_content = b"x" * LARGE_FILE_SIZE # 10MB + obj.upload_content(large_content) + + mock_put.assert_called_once() + call_args = mock_put.call_args + assert len(call_args[1]["content"]) == LARGE_FILE_SIZE + + +class TestStorageObjectPythonSpecific: + """Tests for Python-specific StorageObject behavior.""" + + def test_content_type_detection(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + """Test content type detection differences.""" + mock_client.objects.create.return_value = object_view + + client = StorageObjectClient(mock_client) + + # Python detects from extension + client.create("test.txt") + call1 = mock_client.objects.create.call_args[1] + assert call1["content_type"] == "text" + + # Explicit content type + client.create("test.bin", content_type="binary") + call2 = mock_client.objects.create.call_args[1] + assert call2["content_type"] == "binary" + + def test_upload_data_types(self, mock_client: Mock) -> None: + """Test Python supports more upload data types.""" + object_view = SimpleNamespace(id="obj_123", upload_url="https://upload.example.com") + + with patch("httpx.put") as mock_put: + mock_response = create_mock_httpx_response() + mock_put.return_value = mock_response + + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + + # String + obj.upload_content("string content") + + # Bytes + obj.upload_content(b"bytes content") + + # Path (Python-specific) + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("file content") + temp_path = Path(f.name) + + try: + obj.upload_content(temp_path) + finally: + temp_path.unlink() + + assert mock_put.call_count == 3 diff --git a/uv.lock b/uv.lock index 2e16ed412..41d171c3c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] [[package]] name = "aiohappyeyeballs" @@ -187,6 +191,232 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "coverage" +version = "7.11.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/9698d57a3b11704c7b89b21d69e9d23ecf80d538cabb536c8b63f4a12322/coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b", size = 815210, upload-time = "2025-11-10T00:13:17.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/68/b53157115ef76d50d1d916d6240e5cd5b3c14dba8ba1b984632b8221fc2e/coverage-7.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c986537abca9b064510f3fd104ba33e98d3036608c7f2f5537f869bc10e1ee5", size = 216377, upload-time = "2025-11-10T00:10:27.317Z" }, + { url = "https://files.pythonhosted.org/packages/14/c1/d2f9d8e37123fe6e7ab8afcaab8195f13bc84a8b2f449a533fd4812ac724/coverage-7.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28c5251b3ab1d23e66f1130ca0c419747edfbcb4690de19467cd616861507af7", size = 216892, upload-time = "2025-11-10T00:10:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/83/73/18f05d8010149b650ed97ee5c9f7e4ae68c05c7d913391523281e41c2495/coverage-7.11.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4f2bb4ee8dd40f9b2a80bb4adb2aecece9480ba1fa60d9382e8c8e0bd558e2eb", size = 243650, upload-time = "2025-11-10T00:10:32.392Z" }, + { url = "https://files.pythonhosted.org/packages/63/3c/c0cbb296c0ecc6dcbd70f4b473fcd7fe4517bbef8b09f4326d78f38adb87/coverage-7.11.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e5f4bfac975a2138215a38bda599ef00162e4143541cf7dd186da10a7f8e69f1", size = 245478, upload-time = "2025-11-10T00:10:34.157Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9a/dad288cf9faa142a14e75e39dc646d968b93d74e15c83e9b13fd628f2cb3/coverage-7.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4cbfff5cf01fa07464439a8510affc9df281535f41a1f5312fbd2b59b4ab5c", size = 247337, upload-time = "2025-11-10T00:10:35.655Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ba/f6148ebf5547b3502013175e41bf3107a4e34b7dd19f9793a6ce0e1cd61f/coverage-7.11.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:31663572f20bf3406d7ac00d6981c7bbbcec302539d26b5ac596ca499664de31", size = 244328, upload-time = "2025-11-10T00:10:37.459Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4d/b93784d0b593c5df89a0d48cbbd2d0963e0ca089eaf877405849792e46d3/coverage-7.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9799bd6a910961cb666196b8583ed0ee125fa225c6fdee2cbf00232b861f29d2", size = 245381, upload-time = "2025-11-10T00:10:39.229Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/6735bfd4f0f736d457642ee056a570d704c9d57fdcd5c91ea5d6b15c944e/coverage-7.11.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:097acc18bedf2c6e3144eaf09b5f6034926c3c9bb9e10574ffd0942717232507", size = 243390, upload-time = "2025-11-10T00:10:40.984Z" }, + { url = "https://files.pythonhosted.org/packages/db/3d/7ba68ed52d1873d450aefd8d2f5a353e67b421915cb6c174e4222c7b918c/coverage-7.11.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6f033dec603eea88204589175782290a038b436105a8f3637a81c4359df27832", size = 243654, upload-time = "2025-11-10T00:10:42.496Z" }, + { url = "https://files.pythonhosted.org/packages/14/26/be2720c4c7bf73c6591ae4ab503a7b5a31c7a60ced6dba855cfcb4a5af7e/coverage-7.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dd9ca2d44ed8018c90efb72f237a2a140325a4c3339971364d758e78b175f58e", size = 244272, upload-time = "2025-11-10T00:10:44.39Z" }, + { url = "https://files.pythonhosted.org/packages/90/20/086f5697780df146dbc0df4ae9b6db2b23ddf5aa550f977b2825137728e9/coverage-7.11.3-cp310-cp310-win32.whl", hash = "sha256:900580bc99c145e2561ea91a2d207e639171870d8a18756eb57db944a017d4bb", size = 218969, upload-time = "2025-11-10T00:10:45.863Z" }, + { url = "https://files.pythonhosted.org/packages/98/5c/cc6faba945ede5088156da7770e30d06c38b8591785ac99bcfb2074f9ef6/coverage-7.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:c8be5bfcdc7832011b2652db29ed7672ce9d353dd19bce5272ca33dbcf60aaa8", size = 219903, upload-time = "2025-11-10T00:10:47.676Z" }, + { url = "https://files.pythonhosted.org/packages/92/92/43a961c0f57b666d01c92bcd960c7f93677de5e4ee7ca722564ad6dee0fa/coverage-7.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1", size = 216504, upload-time = "2025-11-10T00:10:49.524Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5c/dbfc73329726aef26dbf7fefef81b8a2afd1789343a579ea6d99bf15d26e/coverage-7.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06", size = 217006, upload-time = "2025-11-10T00:10:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e0/878c84fb6661964bc435beb1e28c050650aa30e4c1cdc12341e298700bda/coverage-7.11.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80", size = 247415, upload-time = "2025-11-10T00:10:52.805Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/0677e78b1e6a13527f39c4b39c767b351e256b333050539861c63f98bd61/coverage-7.11.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa", size = 249332, upload-time = "2025-11-10T00:10:54.35Z" }, + { url = "https://files.pythonhosted.org/packages/54/90/25fc343e4ce35514262451456de0953bcae5b37dda248aed50ee51234cee/coverage-7.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297", size = 251443, upload-time = "2025-11-10T00:10:55.832Z" }, + { url = "https://files.pythonhosted.org/packages/13/56/bc02bbc890fd8b155a64285c93e2ab38647486701ac9c980d457cdae857a/coverage-7.11.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362", size = 247554, upload-time = "2025-11-10T00:10:57.829Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ab/0318888d091d799a82d788c1e8d8bd280f1d5c41662bbb6e11187efe33e8/coverage-7.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87", size = 249139, upload-time = "2025-11-10T00:10:59.465Z" }, + { url = "https://files.pythonhosted.org/packages/79/d8/3ee50929c4cd36fcfcc0f45d753337001001116c8a5b8dd18d27ea645737/coverage-7.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200", size = 247209, upload-time = "2025-11-10T00:11:01.432Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/3cf06e327401c293e60c962b4b8a2ceb7167c1a428a02be3adbd1d7c7e4c/coverage-7.11.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4", size = 246936, upload-time = "2025-11-10T00:11:02.964Z" }, + { url = "https://files.pythonhosted.org/packages/99/0b/ffc03dc8f4083817900fd367110015ef4dd227b37284104a5eb5edc9c106/coverage-7.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060", size = 247835, upload-time = "2025-11-10T00:11:04.405Z" }, + { url = "https://files.pythonhosted.org/packages/17/4d/dbe54609ee066553d0bcdcdf108b177c78dab836292bee43f96d6a5674d1/coverage-7.11.3-cp311-cp311-win32.whl", hash = "sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7", size = 218994, upload-time = "2025-11-10T00:11:05.966Z" }, + { url = "https://files.pythonhosted.org/packages/94/11/8e7155df53f99553ad8114054806c01a2c0b08f303ea7e38b9831652d83d/coverage-7.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55", size = 219926, upload-time = "2025-11-10T00:11:07.936Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/bea91b6a9e35d89c89a1cd5824bc72e45151a9c2a9ca0b50d9e9a85e3ae3/coverage-7.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc", size = 218599, upload-time = "2025-11-10T00:11:09.578Z" }, + { url = "https://files.pythonhosted.org/packages/c2/39/af056ec7a27c487e25c7f6b6e51d2ee9821dba1863173ddf4dc2eebef4f7/coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f", size = 216676, upload-time = "2025-11-10T00:11:11.566Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f8/21126d34b174d037b5d01bea39077725cbb9a0da94a95c5f96929c695433/coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e", size = 217034, upload-time = "2025-11-10T00:11:13.12Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3f/0fd35f35658cdd11f7686303214bd5908225838f374db47f9e457c8d6df8/coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a", size = 248531, upload-time = "2025-11-10T00:11:15.023Z" }, + { url = "https://files.pythonhosted.org/packages/8f/59/0bfc5900fc15ce4fd186e092451de776bef244565c840c9c026fd50857e1/coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1", size = 251290, upload-time = "2025-11-10T00:11:16.628Z" }, + { url = "https://files.pythonhosted.org/packages/71/88/d5c184001fa2ac82edf1b8f2cd91894d2230d7c309e937c54c796176e35b/coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd", size = 252375, upload-time = "2025-11-10T00:11:18.249Z" }, + { url = "https://files.pythonhosted.org/packages/5c/29/f60af9f823bf62c7a00ce1ac88441b9a9a467e499493e5cc65028c8b8dd2/coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5", size = 248946, upload-time = "2025-11-10T00:11:20.202Z" }, + { url = "https://files.pythonhosted.org/packages/67/16/4662790f3b1e03fce5280cad93fd18711c35980beb3c6f28dca41b5230c6/coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e", size = 250310, upload-time = "2025-11-10T00:11:21.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/dd6c2e28308a83e5fc1ee602f8204bd3aa5af685c104cb54499230cf56db/coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044", size = 248461, upload-time = "2025-11-10T00:11:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/16/fe/b71af12be9f59dc9eb060688fa19a95bf3223f56c5af1e9861dfa2275d2c/coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7", size = 248039, upload-time = "2025-11-10T00:11:25.07Z" }, + { url = "https://files.pythonhosted.org/packages/11/b8/023b2003a2cd96bdf607afe03d9b96c763cab6d76e024abe4473707c4eb8/coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405", size = 249903, upload-time = "2025-11-10T00:11:26.992Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/5f1076311aa67b1fa4687a724cc044346380e90ce7d94fec09fd384aa5fd/coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e", size = 219201, upload-time = "2025-11-10T00:11:28.619Z" }, + { url = "https://files.pythonhosted.org/packages/4f/24/d21688f48fe9fcc778956680fd5aaf69f4e23b245b7c7a4755cbd421d25b/coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055", size = 220012, upload-time = "2025-11-10T00:11:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9e/d5eb508065f291456378aa9b16698b8417d87cb084c2b597f3beb00a8084/coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f", size = 218652, upload-time = "2025-11-10T00:11:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f6/d8572c058211c7d976f24dab71999a565501fb5b3cdcb59cf782f19c4acb/coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36", size = 216694, upload-time = "2025-11-10T00:11:34.296Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f6/b6f9764d90c0ce1bce8d995649fa307fff21f4727b8d950fa2843b7b0de5/coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e", size = 217065, upload-time = "2025-11-10T00:11:36.281Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/a12cb424063019fd077b5be474258a0ed8369b92b6d0058e673f0a945982/coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2", size = 248062, upload-time = "2025-11-10T00:11:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9c/dab1a4e8e75ce053d14259d3d7485d68528a662e286e184685ea49e71156/coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63", size = 250657, upload-time = "2025-11-10T00:11:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/a14f256438324f33bae36f9a1a7137729bf26b0a43f5eda60b147ec7c8c7/coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3", size = 251900, upload-time = "2025-11-10T00:11:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/04/07/75b0d476eb349f1296486b1418b44f2d8780cc8db47493de3755e5340076/coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5", size = 248254, upload-time = "2025-11-10T00:11:43.27Z" }, + { url = "https://files.pythonhosted.org/packages/5a/4b/0c486581fa72873489ca092c52792d008a17954aa352809a7cbe6cf0bf07/coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5", size = 250041, upload-time = "2025-11-10T00:11:45.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/a3/0059dafb240ae3e3291f81b8de00e9c511d3dd41d687a227dd4b529be591/coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7", size = 248004, upload-time = "2025-11-10T00:11:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/83/93/967d9662b1eb8c7c46917dcc7e4c1875724ac3e73c3cb78e86d7a0ac719d/coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5", size = 247828, upload-time = "2025-11-10T00:11:48.563Z" }, + { url = "https://files.pythonhosted.org/packages/4c/1c/5077493c03215701e212767e470b794548d817dfc6247a4718832cc71fac/coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094", size = 249588, upload-time = "2025-11-10T00:11:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a5/77f64de461016e7da3e05d7d07975c89756fe672753e4cf74417fc9b9052/coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c", size = 219223, upload-time = "2025-11-10T00:11:52.184Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1c/ec51a3c1a59d225b44bdd3a4d463135b3159a535c2686fac965b698524f4/coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2", size = 220033, upload-time = "2025-11-10T00:11:53.871Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/e0ce39746ed558564c16f2cc25fa95ce6fc9fa8bfb3b9e62855d4386b886/coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944", size = 218661, upload-time = "2025-11-10T00:11:55.597Z" }, + { url = "https://files.pythonhosted.org/packages/46/cb/483f130bc56cbbad2638248915d97b185374d58b19e3cc3107359715949f/coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428", size = 217389, upload-time = "2025-11-10T00:11:57.59Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ae/81f89bae3afef75553cf10e62feb57551535d16fd5859b9ee5a2a97ddd27/coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a", size = 217742, upload-time = "2025-11-10T00:11:59.519Z" }, + { url = "https://files.pythonhosted.org/packages/db/6e/a0fb897041949888191a49c36afd5c6f5d9f5fd757e0b0cd99ec198a324b/coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655", size = 259049, upload-time = "2025-11-10T00:12:01.592Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/d13acc67eb402d91eb94b9bd60593411799aed09ce176ee8d8c0e39c94ca/coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7", size = 261113, upload-time = "2025-11-10T00:12:03.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/07/a6868893c48191d60406df4356aa7f0f74e6de34ef1f03af0d49183e0fa1/coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d", size = 263546, upload-time = "2025-11-10T00:12:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/e5/28598f70b2c1098332bac47925806353b3313511d984841111e6e760c016/coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f", size = 258260, upload-time = "2025-11-10T00:12:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/0e/58/58e2d9e6455a4ed746a480c4b9cf96dc3cb2a6b8f3efbee5efd33ae24b06/coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0", size = 261121, upload-time = "2025-11-10T00:12:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/17/57/38803eefb9b0409934cbc5a14e3978f0c85cb251d2b6f6a369067a7105a0/coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739", size = 258736, upload-time = "2025-11-10T00:12:11.195Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/f94683167156e93677b3442be1d4ca70cb33718df32a2eea44a5898f04f6/coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71", size = 257625, upload-time = "2025-11-10T00:12:12.843Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/42d0bf1bc6bfa7d65f52299a31daaa866b4c11000855d753857fe78260ac/coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76", size = 259827, upload-time = "2025-11-10T00:12:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/d3/76/5682719f5d5fbedb0c624c9851ef847407cae23362deb941f185f489c54e/coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c", size = 219897, upload-time = "2025-11-10T00:12:17.274Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/1da511d0ac3d39e6676fa6cc5ec35320bbf1cebb9b24e9ee7548ee4e931a/coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac", size = 220959, upload-time = "2025-11-10T00:12:19.292Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9d/e255da6a04e9ec5f7b633c54c0fdfa221a9e03550b67a9c83217de12e96c/coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc", size = 219234, upload-time = "2025-11-10T00:12:21.251Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/634ec396e45aded1772dccf6c236e3e7c9604bc47b816e928f32ce7987d1/coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c", size = 216746, upload-time = "2025-11-10T00:12:23.089Z" }, + { url = "https://files.pythonhosted.org/packages/28/76/1079547f9d46f9c7c7d0dad35b6873c98bc5aa721eeabceafabd722cd5e7/coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203", size = 217077, upload-time = "2025-11-10T00:12:24.863Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/6ad80d6ae0d7cb743b9a98df8bb88b1ff3dc54491508a4a97549c2b83400/coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240", size = 248122, upload-time = "2025-11-10T00:12:26.553Z" }, + { url = "https://files.pythonhosted.org/packages/20/1d/784b87270784b0b88e4beec9d028e8d58f73ae248032579c63ad2ac6f69a/coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83", size = 250638, upload-time = "2025-11-10T00:12:28.555Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/b6dd31e23e004e9de84d1a8672cd3d73e50f5dae65dbd0f03fa2cdde6100/coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902", size = 251972, upload-time = "2025-11-10T00:12:30.246Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ef/f9c64d76faac56b82daa036b34d4fe9ab55eb37f22062e68e9470583e688/coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428", size = 248147, upload-time = "2025-11-10T00:12:32.195Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/5b666f90a8f8053bd264a1ce693d2edef2368e518afe70680070fca13ecd/coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75", size = 249995, upload-time = "2025-11-10T00:12:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/eb/7b/871e991ffb5d067f8e67ffb635dabba65b231d6e0eb724a4a558f4a702a5/coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704", size = 247948, upload-time = "2025-11-10T00:12:36.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/ce454f0af9609431b06dbe5485fc9d1c35ddc387e32ae8e374f49005748b/coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b", size = 247770, upload-time = "2025-11-10T00:12:38.167Z" }, + { url = "https://files.pythonhosted.org/packages/61/8f/79002cb58a61dfbd2085de7d0a46311ef2476823e7938db80284cedd2428/coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131", size = 249431, upload-time = "2025-11-10T00:12:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/58/cc/d06685dae97468ed22999440f2f2f5060940ab0e7952a7295f236d98cce7/coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a", size = 219508, upload-time = "2025-11-10T00:12:42.231Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ed/770cd07706a3598c545f62d75adf2e5bd3791bffccdcf708ec383ad42559/coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86", size = 220325, upload-time = "2025-11-10T00:12:44.065Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ac/6a1c507899b6fb1b9a56069954365f655956bcc648e150ce64c2b0ecbed8/coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e", size = 218899, upload-time = "2025-11-10T00:12:46.18Z" }, + { url = "https://files.pythonhosted.org/packages/9a/58/142cd838d960cd740654d094f7b0300d7b81534bb7304437d2439fb685fb/coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df", size = 217471, upload-time = "2025-11-10T00:12:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/bc/2c/2f44d39eb33e41ab3aba80571daad32e0f67076afcf27cb443f9e5b5a3ee/coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001", size = 217742, upload-time = "2025-11-10T00:12:50.182Z" }, + { url = "https://files.pythonhosted.org/packages/32/76/8ebc66c3c699f4de3174a43424c34c086323cd93c4930ab0f835731c443a/coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de", size = 259120, upload-time = "2025-11-10T00:12:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/78a3302b9595f331b86e4f12dfbd9252c8e93d97b8631500888f9a3a2af7/coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926", size = 261229, upload-time = "2025-11-10T00:12:54.667Z" }, + { url = "https://files.pythonhosted.org/packages/07/59/1a9c0844dadef2a6efac07316d9781e6c5a3f3ea7e5e701411e99d619bfd/coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd", size = 263642, upload-time = "2025-11-10T00:12:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/37/86/66c15d190a8e82eee777793cabde730640f555db3c020a179625a2ad5320/coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac", size = 258193, upload-time = "2025-11-10T00:12:58.687Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c7/4a4aeb25cb6f83c3ec4763e5f7cc78da1c6d4ef9e22128562204b7f39390/coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46", size = 261107, upload-time = "2025-11-10T00:13:00.502Z" }, + { url = "https://files.pythonhosted.org/packages/ed/91/b986b5035f23cf0272446298967ecdd2c3c0105ee31f66f7e6b6948fd7f8/coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64", size = 258717, upload-time = "2025-11-10T00:13:02.747Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/6c084997f5a04d050c513545d3344bfa17bd3b67f143f388b5757d762b0b/coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f", size = 257541, upload-time = "2025-11-10T00:13:04.689Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c5/38e642917e406930cb67941210a366ccffa767365c8f8d9ec0f465a8b218/coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820", size = 259872, upload-time = "2025-11-10T00:13:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/b7/67/5e812979d20c167f81dbf9374048e0193ebe64c59a3d93d7d947b07865fa/coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237", size = 220289, upload-time = "2025-11-10T00:13:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/24/3a/b72573802672b680703e0df071faadfab7dcd4d659aaaffc4626bc8bbde8/coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9", size = 221398, upload-time = "2025-11-10T00:13:10.734Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4e/649628f28d38bad81e4e8eb3f78759d20ac173e3c456ac629123815feb40/coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd", size = 219435, upload-time = "2025-11-10T00:13:12.712Z" }, + { url = "https://files.pythonhosted.org/packages/19/8f/92bdd27b067204b99f396a1414d6342122f3e2663459baf787108a6b8b84/coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe", size = 208478, upload-time = "2025-11-10T00:13:14.908Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, +] + [[package]] name = "dirty-equals" version = "0.9.0" @@ -903,6 +1133,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024, upload-time = "2024-08-22T08:03:15.536Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.11.3", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "pytest-timeout" version = "2.4.0" @@ -1019,6 +1264,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "respx" }, @@ -1050,6 +1296,7 @@ dev = [ { name = "pyright", specifier = "==1.1.399" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-timeout" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "respx" }, From 2a76b3f644e784aa70a77e3ed1ab8fcd86296000 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 11 Nov 2025 15:55:23 -0800 Subject: [PATCH 15/56] unit test refactoring --- .coverage | Bin 53248 -> 53248 bytes src/runloop_api_client/sdk/_sync.py | 27 +- tests/sdk/async_devbox/__init__.py | 1 + tests/sdk/async_devbox/conftest.py | 6 + tests/sdk/async_devbox/test_core.py | 290 +++++++ tests/sdk/async_devbox/test_edge_cases.py | 26 + tests/sdk/async_devbox/test_interfaces.py | 215 ++++++ tests/sdk/async_devbox/test_streaming.py | 189 +++++ tests/sdk/conftest.py | 186 ++++- tests/sdk/devbox/__init__.py | 1 + tests/sdk/devbox/conftest.py | 6 + tests/sdk/devbox/test_core.py | 296 ++++++++ tests/sdk/devbox/test_edge_cases.py | 146 ++++ tests/sdk/devbox/test_interfaces.py | 331 ++++++++ tests/sdk/devbox/test_streaming.py | 171 +++++ tests/sdk/test_async_blueprint.py | 8 +- tests/sdk/test_async_clients.py | 68 +- tests/sdk/test_async_devbox.py | 689 ----------------- tests/sdk/test_async_execution.py | 76 +- tests/sdk/test_async_execution_result.py | 49 +- tests/sdk/test_async_snapshot.py | 10 +- tests/sdk/test_async_storage_object.py | 39 +- tests/sdk/test_blueprint.py | 8 +- tests/sdk/test_clients.py | 67 +- tests/sdk/test_devbox.py | 878 ---------------------- tests/sdk/test_execution.py | 58 +- tests/sdk/test_execution_result.py | 49 +- tests/sdk/test_snapshot.py | 10 +- tests/sdk/test_storage_object.py | 48 +- 29 files changed, 2093 insertions(+), 1855 deletions(-) create mode 100644 tests/sdk/async_devbox/__init__.py create mode 100644 tests/sdk/async_devbox/conftest.py create mode 100644 tests/sdk/async_devbox/test_core.py create mode 100644 tests/sdk/async_devbox/test_edge_cases.py create mode 100644 tests/sdk/async_devbox/test_interfaces.py create mode 100644 tests/sdk/async_devbox/test_streaming.py create mode 100644 tests/sdk/devbox/__init__.py create mode 100644 tests/sdk/devbox/conftest.py create mode 100644 tests/sdk/devbox/test_core.py create mode 100644 tests/sdk/devbox/test_edge_cases.py create mode 100644 tests/sdk/devbox/test_interfaces.py create mode 100644 tests/sdk/devbox/test_streaming.py delete mode 100644 tests/sdk/test_async_devbox.py delete mode 100644 tests/sdk/test_devbox.py diff --git a/.coverage b/.coverage index 43b330a8224224b2acd578427dd6aa9f1d8df9d3..f7c8e08d10b5f418a23f7e9f5016df5d7f765b04 100644 GIT binary patch delta 571 zcmZozz}&Eac>_~}+H(d0UJq9OZ~U8iFYs>VoyGfwFPPVZ&z4V)uYpg8Uz+b9-*dic zn*{|Fc$osMCY$vJ)JvMOFmjr(N7cLCh`s0k@2~$p`=9@w{9ph7Kcl1?NQw`el&A_& zN{1gRb@J6|>f zAEy-WEW!FackkZjt8SG2$a;}K=+Nb15nsP0OA{%2loD{i{EFzpWVrx zm65ZNg(>QP`sx4w|NUS8cHjN9|G)Md@v6l0 z2d3|@|5tgNfq@~Avr%sH#9nU}21ZAv$@2Y_~}+8YJ|-Y!P|Z~O^-2YI{rmhlDf-sUsrljUdQ)%COCHEXZEFHB=YO)!?-TYCs5z zSb@xD-Icz_Hl1bP?i-V%Vm|(2l+>9#r&lsgR0}An!x2^Qb|dzlKM4N&|Leaz0|SE| zr-cB}&f@9p6M%LyFg#&y_`~2}$FQGONDin+jUS{ZM$Q2UMU6p*fCZ2ZaWtE}wcpXz zQInOCvyq7@>i_1a|Lgz%5C8N3FA{M*wdVh(r~Ch#M*pe*U$0yLf78*tpZEWZe&gP| zA^pMW`}Y6$j(Kg;NHwIHonqZ1W4@_~HZT>x<)c^p Blueprint: blueprint = self._client.blueprints.create_and_await_build_complete( name=name, base_blueprint_id=base_blueprint_id, + base_blueprint_name=base_blueprint_name, + build_args=build_args, code_mounts=code_mounts, dockerfile=dockerfile, file_mounts=file_mounts, launch_parameters=launch_parameters, + metadata=metadata, + secrets=secrets, services=services, system_setup_commands=system_setup_commands, polling_config=polling_config, diff --git a/tests/sdk/async_devbox/__init__.py b/tests/sdk/async_devbox/__init__.py new file mode 100644 index 000000000..2e3c52583 --- /dev/null +++ b/tests/sdk/async_devbox/__init__.py @@ -0,0 +1 @@ +"""Tests for async Devbox functionality.""" diff --git a/tests/sdk/async_devbox/conftest.py b/tests/sdk/async_devbox/conftest.py new file mode 100644 index 000000000..84c7a1df8 --- /dev/null +++ b/tests/sdk/async_devbox/conftest.py @@ -0,0 +1,6 @@ +"""Shared fixtures and utilities for async Devbox tests. + +This module contains fixtures and helpers specific to async devbox testing +that are shared across multiple test modules in this directory. +""" +# Currently minimal - add shared helpers if patterns emerge diff --git a/tests/sdk/async_devbox/test_core.py b/tests/sdk/async_devbox/test_core.py new file mode 100644 index 000000000..46c47f8ee --- /dev/null +++ b/tests/sdk/async_devbox/test_core.py @@ -0,0 +1,290 @@ +"""Tests for core AsyncDevbox functionality. + +Tests the primary AsyncDevbox class including initialization, async CRUD +operations, snapshot creation, blueprint launching, and async execution methods. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from tests.sdk.conftest import MockDevboxView +from runloop_api_client.sdk import AsyncDevbox +from runloop_api_client._types import NotGiven +from runloop_api_client.lib.polling import PollingConfig +from runloop_api_client.sdk.async_devbox import ( + _AsyncFileInterface, + _AsyncCommandInterface, + _AsyncNetworkInterface, +) + + +class TestAsyncDevbox: + """Tests for AsyncDevbox class.""" + + def test_init(self, mock_async_client: AsyncMock) -> None: + """Test AsyncDevbox initialization.""" + devbox = AsyncDevbox(mock_async_client, "dev_123") + assert devbox.id == "dev_123" + + def test_repr(self, mock_async_client: AsyncMock) -> None: + """Test AsyncDevbox string representation.""" + devbox = AsyncDevbox(mock_async_client, "dev_123") + assert repr(devbox) == "" + + @pytest.mark.asyncio + async def test_context_manager_enter_exit(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: + """Test context manager behavior with successful shutdown.""" + mock_async_client.devboxes.shutdown = AsyncMock(return_value=devbox_view) + + async with AsyncDevbox(mock_async_client, "dev_123") as devbox: + assert devbox.id == "dev_123" + + call_kwargs = mock_async_client.devboxes.shutdown.call_args[1] + assert isinstance(call_kwargs["timeout"], NotGiven) + + @pytest.mark.asyncio + async def test_context_manager_exception_handling(self, mock_async_client: AsyncMock) -> None: + """Test context manager handles exceptions during shutdown.""" + mock_async_client.devboxes.shutdown = AsyncMock(side_effect=RuntimeError("Shutdown failed")) + + with pytest.raises(ValueError, match="Test error"): + async with AsyncDevbox(mock_async_client, "dev_123"): + raise ValueError("Test error") + + # Shutdown should be called even when body raises exception + mock_async_client.devboxes.shutdown.assert_called_once() + + @pytest.mark.asyncio + async def test_get_info(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: + """Test get_info method.""" + mock_async_client.devboxes.retrieve = AsyncMock(return_value=devbox_view) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.get_info( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == devbox_view + mock_async_client.devboxes.retrieve.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + @pytest.mark.asyncio + async def test_await_running(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: + """Test await_running method.""" + mock_async_client.devboxes.await_running = AsyncMock(return_value=devbox_view) + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.await_running(polling_config=polling_config) + + assert result == devbox_view + mock_async_client.devboxes.await_running.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + @pytest.mark.asyncio + async def test_await_suspended(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: + """Test await_suspended method.""" + mock_async_client.devboxes.await_suspended = AsyncMock(return_value=devbox_view) + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.await_suspended(polling_config=polling_config) + + assert result == devbox_view + mock_async_client.devboxes.await_suspended.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + @pytest.mark.asyncio + async def test_shutdown(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: + """Test shutdown method.""" + mock_async_client.devboxes.shutdown = AsyncMock(return_value=devbox_view) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.shutdown( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == devbox_view + mock_async_client.devboxes.shutdown.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + @pytest.mark.asyncio + async def test_suspend(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: + """Test suspend method.""" + mock_async_client.devboxes.suspend = AsyncMock(return_value=None) + mock_async_client.devboxes.await_suspended = AsyncMock(return_value=devbox_view) + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.suspend( + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == devbox_view + mock_async_client.devboxes.suspend.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + mock_async_client.devboxes.await_suspended.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + @pytest.mark.asyncio + async def test_resume(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: + """Test resume method.""" + mock_async_client.devboxes.resume = AsyncMock(return_value=None) + mock_async_client.devboxes.await_running = AsyncMock(return_value=devbox_view) + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.resume( + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == devbox_view + mock_async_client.devboxes.resume.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + mock_async_client.devboxes.await_running.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + @pytest.mark.asyncio + async def test_keep_alive(self, mock_async_client: AsyncMock) -> None: + """Test keep_alive method.""" + mock_async_client.devboxes.keep_alive = AsyncMock(return_value=object()) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.keep_alive( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result is not None # Verify return value is propagated + mock_async_client.devboxes.keep_alive.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + @pytest.mark.asyncio + async def test_snapshot_disk(self, mock_async_client: AsyncMock) -> None: + """Test snapshot_disk waits for completion.""" + snapshot_data = SimpleNamespace(id="snap_123") + snapshot_status = SimpleNamespace(status="completed") + + mock_async_client.devboxes.snapshot_disk_async = AsyncMock(return_value=snapshot_data) + mock_async_client.devboxes.disk_snapshots.await_completed = AsyncMock(return_value=snapshot_status) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + polling_config = PollingConfig(timeout_seconds=60.0) + snapshot = await devbox.snapshot_disk( + name="test-snapshot", + metadata={"key": "value"}, + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + ) + + assert snapshot.id == "snap_123" + mock_async_client.devboxes.snapshot_disk_async.assert_called_once() + mock_async_client.devboxes.disk_snapshots.await_completed.assert_called_once() + + @pytest.mark.asyncio + async def test_snapshot_disk_async(self, mock_async_client: AsyncMock) -> None: + """Test snapshot_disk_async returns immediately.""" + snapshot_data = SimpleNamespace(id="snap_123") + mock_async_client.devboxes.snapshot_disk_async = AsyncMock(return_value=snapshot_data) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + snapshot = await devbox.snapshot_disk_async( + name="test-snapshot", + metadata={"key": "value"}, + extra_headers={"X-Custom": "value"}, + ) + + assert snapshot.id == "snap_123" + mock_async_client.devboxes.snapshot_disk_async.assert_called_once() + + @pytest.mark.asyncio + async def test_close(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: + """Test close method calls shutdown.""" + mock_async_client.devboxes.shutdown = AsyncMock(return_value=devbox_view) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + await devbox.close() + + mock_async_client.devboxes.shutdown.assert_called_once() + + def test_cmd_property(self, mock_async_client: AsyncMock) -> None: + """Test cmd property returns AsyncCommandInterface.""" + devbox = AsyncDevbox(mock_async_client, "dev_123") + cmd = devbox.cmd + assert isinstance(cmd, _AsyncCommandInterface) + assert cmd._devbox is devbox + + def test_file_property(self, mock_async_client: AsyncMock) -> None: + """Test file property returns AsyncFileInterface.""" + devbox = AsyncDevbox(mock_async_client, "dev_123") + file_interface = devbox.file + assert isinstance(file_interface, _AsyncFileInterface) + assert file_interface._devbox is devbox + + def test_net_property(self, mock_async_client: AsyncMock) -> None: + """Test net property returns AsyncNetworkInterface.""" + devbox = AsyncDevbox(mock_async_client, "dev_123") + net = devbox.net + assert isinstance(net, _AsyncNetworkInterface) + assert net._devbox is devbox diff --git a/tests/sdk/async_devbox/test_edge_cases.py b/tests/sdk/async_devbox/test_edge_cases.py new file mode 100644 index 000000000..fa5b89c7a --- /dev/null +++ b/tests/sdk/async_devbox/test_edge_cases.py @@ -0,0 +1,26 @@ +"""Tests for AsyncDevbox error handling. + +Tests async error scenarios including network errors. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import httpx +import pytest + +from runloop_api_client.sdk import AsyncDevbox + + +class TestAsyncDevboxErrorHandling: + """Tests for AsyncDevbox error handling scenarios.""" + + @pytest.mark.asyncio + async def test_async_network_error(self, mock_async_client: AsyncMock) -> None: + """Test handling of network errors in async.""" + mock_async_client.devboxes.retrieve = AsyncMock(side_effect=httpx.NetworkError("Connection failed")) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + with pytest.raises(httpx.NetworkError): + await devbox.get_info() diff --git a/tests/sdk/async_devbox/test_interfaces.py b/tests/sdk/async_devbox/test_interfaces.py new file mode 100644 index 000000000..7ba857a3e --- /dev/null +++ b/tests/sdk/async_devbox/test_interfaces.py @@ -0,0 +1,215 @@ +"""Tests for AsyncDevbox interface classes. + +Tests the async command, file, and network interface helper classes. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from pathlib import Path +from unittest.mock import AsyncMock + +import httpx +import pytest + +from tests.sdk.conftest import MockExecutionView +from runloop_api_client.sdk import AsyncDevbox +from runloop_api_client._types import NotGiven + + +class TestAsyncCommandInterface: + """Tests for _AsyncCommandInterface.""" + + @pytest.mark.asyncio + async def test_exec_without_callbacks( + self, mock_async_client: AsyncMock, execution_view: MockExecutionView + ) -> None: + """Test exec without streaming callbacks.""" + mock_async_client.devboxes.execute_and_await_completion = AsyncMock(return_value=execution_view) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.cmd.exec("echo hello") + + assert result.exit_code == 0 + assert await result.stdout() == "output" + call_kwargs = mock_async_client.devboxes.execute_and_await_completion.call_args[1] + assert call_kwargs["command"] == "echo hello" + assert isinstance(call_kwargs["shell_name"], NotGiven) or call_kwargs["shell_name"] is None + assert isinstance(call_kwargs["timeout"], NotGiven) + + @pytest.mark.asyncio + async def test_exec_with_stdout_callback(self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock) -> None: + """Test exec with stdout callback.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + execution_completed = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="", + ) + + mock_async_client.devboxes.execute_async = AsyncMock(return_value=execution_async) + mock_async_client.devboxes.executions.await_completed = AsyncMock(return_value=execution_completed) + mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) + + stdout_calls: list[str] = [] + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.cmd.exec("echo hello", stdout=stdout_calls.append) + + assert result.exit_code == 0 + mock_async_client.devboxes.execute_async.assert_called_once() + + @pytest.mark.asyncio + async def test_exec_async_returns_execution( + self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock + ) -> None: + """Test exec_async returns AsyncExecution object.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + + mock_async_client.devboxes.execute_async = AsyncMock(return_value=execution_async) + mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + execution = await devbox.cmd.exec_async("long-running command") + + assert execution.execution_id == "exec_123" + assert execution.devbox_id == "dev_123" + mock_async_client.devboxes.execute_async.assert_called_once() + + +class TestAsyncFileInterface: + """Tests for _AsyncFileInterface.""" + + @pytest.mark.asyncio + async def test_read(self, mock_async_client: AsyncMock) -> None: + """Test file read.""" + mock_async_client.devboxes.read_file_contents = AsyncMock(return_value="file content") + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.file.read("/path/to/file") + + assert result == "file content" + mock_async_client.devboxes.read_file_contents.assert_called_once() + + @pytest.mark.asyncio + async def test_write_string(self, mock_async_client: AsyncMock) -> None: + """Test file write with string.""" + execution_detail = SimpleNamespace() + mock_async_client.devboxes.write_file_contents = AsyncMock(return_value=execution_detail) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.file.write("/path/to/file", "content") + + assert result == execution_detail + mock_async_client.devboxes.write_file_contents.assert_called_once() + + @pytest.mark.asyncio + async def test_write_bytes(self, mock_async_client: AsyncMock) -> None: + """Test file write with bytes.""" + execution_detail = SimpleNamespace() + mock_async_client.devboxes.write_file_contents = AsyncMock(return_value=execution_detail) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.file.write("/path/to/file", b"content") + + assert result == execution_detail + mock_async_client.devboxes.write_file_contents.assert_called_once() + + @pytest.mark.asyncio + async def test_download(self, mock_async_client: AsyncMock) -> None: + """Test file download.""" + mock_response = AsyncMock(spec=httpx.Response) + mock_response.read = AsyncMock(return_value=b"file content") + mock_async_client.devboxes.download_file = AsyncMock(return_value=mock_response) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.file.download("/path/to/file") + + assert result == b"file content" + mock_async_client.devboxes.download_file.assert_called_once() + + @pytest.mark.asyncio + async def test_upload(self, mock_async_client: AsyncMock, tmp_path: Path) -> None: + """Test file upload.""" + execution_detail = SimpleNamespace() + mock_async_client.devboxes.upload_file = AsyncMock(return_value=execution_detail) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + # Create a temporary file for upload + temp_file = tmp_path / "test_file.txt" + temp_file.write_text("test content") + + result = await devbox.file.upload("/remote/path", temp_file) + + assert result == execution_detail + mock_async_client.devboxes.upload_file.assert_called_once() + + +class TestAsyncNetworkInterface: + """Tests for _AsyncNetworkInterface.""" + + @pytest.mark.asyncio + async def test_create_ssh_key(self, mock_async_client: AsyncMock) -> None: + """Test create SSH key.""" + ssh_key_response = SimpleNamespace(public_key="ssh-rsa ...") + mock_async_client.devboxes.create_ssh_key = AsyncMock(return_value=ssh_key_response) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.net.create_ssh_key( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == ssh_key_response + mock_async_client.devboxes.create_ssh_key.assert_called_once() + + @pytest.mark.asyncio + async def test_create_tunnel(self, mock_async_client: AsyncMock) -> None: + """Test create tunnel.""" + tunnel_view = SimpleNamespace(tunnel_id="tunnel_123") + mock_async_client.devboxes.create_tunnel = AsyncMock(return_value=tunnel_view) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.net.create_tunnel( + port=8080, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == tunnel_view + mock_async_client.devboxes.create_tunnel.assert_called_once() + + @pytest.mark.asyncio + async def test_remove_tunnel(self, mock_async_client: AsyncMock) -> None: + """Test remove tunnel.""" + mock_async_client.devboxes.remove_tunnel = AsyncMock(return_value=object()) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.net.remove_tunnel( + port=8080, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result is not None # Verify return value is propagated + mock_async_client.devboxes.remove_tunnel.assert_called_once() diff --git a/tests/sdk/async_devbox/test_streaming.py b/tests/sdk/async_devbox/test_streaming.py new file mode 100644 index 000000000..ea62ced8c --- /dev/null +++ b/tests/sdk/async_devbox/test_streaming.py @@ -0,0 +1,189 @@ +"""Tests for AsyncDevbox streaming functionality. + +Tests async streaming setup, task management, stream workers, and the +async streaming group management. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import Any, AsyncIterator +from unittest.mock import Mock, AsyncMock + +import pytest + +from tests.sdk.conftest import TASK_COMPLETION_SHORT +from runloop_api_client.sdk import AsyncDevbox +from runloop_api_client._streaming import AsyncStream +from runloop_api_client.sdk.async_execution import _AsyncStreamingGroup +from runloop_api_client.types.devboxes.execution_update_chunk import ExecutionUpdateChunk + + +class TestAsyncDevboxStreaming: + """Tests for AsyncDevbox streaming methods.""" + + def test_start_streaming_no_callbacks(self, mock_async_client: AsyncMock) -> None: + """Test _start_streaming returns None when no callbacks.""" + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = devbox._start_streaming("exec_123", stdout=None, stderr=None, output=None) + assert result is None + + @pytest.mark.asyncio + async def test_start_streaming_stdout_only( + self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock, async_task_cleanup: list[asyncio.Task[Any]] + ) -> None: + """Test _start_streaming with stdout callback only.""" + + # Create a proper async iterator + async def async_iter(): + yield SimpleNamespace(output="line 1") + yield SimpleNamespace(output="line 2") + + mock_async_stream.__aiter__ = Mock(return_value=async_iter()) + mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) + mock_async_stream.__aexit__ = AsyncMock(return_value=None) + + mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + stdout_calls: list[str] = [] + result = devbox._start_streaming("exec_123", stdout=stdout_calls.append, stderr=None, output=None) + + assert result is not None + assert isinstance(result, _AsyncStreamingGroup) + assert len(result._tasks) == 1 + # Register tasks for automatic cleanup + async_task_cleanup.extend(result._tasks) + # Give the task a moment to start + await asyncio.sleep(TASK_COMPLETION_SHORT * 5) + mock_async_client.devboxes.executions.stream_stdout_updates.assert_called_once() + + @pytest.mark.asyncio + async def test_start_streaming_stderr_only( + self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock, async_task_cleanup: list[asyncio.Task[Any]] + ) -> None: + """Test _start_streaming with stderr callback only.""" + + # Create a proper async iterator + async def async_iter(): + yield SimpleNamespace(output="line 1") + yield SimpleNamespace(output="line 2") + + mock_async_stream.__aiter__ = Mock(return_value=async_iter()) + mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) + mock_async_stream.__aexit__ = AsyncMock(return_value=None) + + mock_async_client.devboxes.executions.stream_stderr_updates = AsyncMock(return_value=mock_async_stream) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + stderr_calls: list[str] = [] + result = devbox._start_streaming("exec_123", stdout=None, stderr=stderr_calls.append, output=None) + + assert result is not None + assert isinstance(result, _AsyncStreamingGroup) + assert len(result._tasks) == 1 + # Register tasks for automatic cleanup + async_task_cleanup.extend(result._tasks) + # Give the task a moment to start + await asyncio.sleep(TASK_COMPLETION_SHORT * 5) + mock_async_client.devboxes.executions.stream_stderr_updates.assert_called_once() + + @pytest.mark.asyncio + async def test_start_streaming_output_only( + self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock, async_task_cleanup: list[asyncio.Task[Any]] + ) -> None: + """Test _start_streaming with output callback only.""" + + # Create a proper async iterator + async def async_iter(): + yield SimpleNamespace(output="line 1") + yield SimpleNamespace(output="line 2") + + mock_async_stream.__aiter__ = Mock(return_value=async_iter()) + mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) + mock_async_stream.__aexit__ = AsyncMock(return_value=None) + + mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) + mock_async_client.devboxes.executions.stream_stderr_updates = AsyncMock(return_value=mock_async_stream) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + output_calls: list[str] = [] + result = devbox._start_streaming("exec_123", stdout=None, stderr=None, output=output_calls.append) + + assert result is not None + assert isinstance(result, _AsyncStreamingGroup) + assert len(result._tasks) == 2 # Both stdout and stderr streams + # Register tasks for automatic cleanup + async_task_cleanup.extend(result._tasks) + # Give tasks a moment to start + TASK_START_DELAY = 0.1 + await asyncio.sleep(TASK_START_DELAY) + + @pytest.mark.asyncio + async def test_stream_worker(self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock) -> None: + """Test _stream_worker processes chunks.""" + chunks = [ + SimpleNamespace(output="line 1"), + SimpleNamespace(output="line 2"), + ] + + async def async_iter() -> AsyncIterator[SimpleNamespace]: + for chunk in chunks: + yield chunk + + mock_async_stream.__aiter__ = Mock(return_value=async_iter()) + mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) + mock_async_stream.__aexit__ = AsyncMock(return_value=None) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + calls: list[str] = [] + + async def stream_factory() -> AsyncStream[ExecutionUpdateChunk]: + return mock_async_stream + + await devbox._stream_worker( + name="test", + stream_factory=stream_factory, + callbacks=[calls.append], + ) + + # Note: In a real scenario, calls would be populated, but with mocks + # we're mainly testing that the method doesn't raise + + @pytest.mark.asyncio + async def test_stream_worker_cancelled( + self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock, async_task_cleanup: list[asyncio.Task[Any]] + ) -> None: + """Test _stream_worker handles cancellation.""" + LONG_SLEEP = 1.0 + + async def async_iter() -> AsyncIterator[SimpleNamespace]: + await asyncio.sleep(LONG_SLEEP) # Long-running + yield SimpleNamespace(output="line") + + mock_async_stream.__aiter__ = Mock(return_value=async_iter()) + mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) + mock_async_stream.__aexit__ = AsyncMock(return_value=None) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + calls: list[str] = [] + + async def stream_factory() -> AsyncStream[ExecutionUpdateChunk]: + return mock_async_stream + + task = asyncio.create_task( + devbox._stream_worker( + name="test", + stream_factory=stream_factory, + callbacks=[calls.append], + ) + ) + # Register task for cleanup in case test fails before cancellation + async_task_cleanup.append(task) + + await asyncio.sleep(TASK_COMPLETION_SHORT) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task diff --git a/tests/sdk/conftest.py b/tests/sdk/conftest.py index d2644f97f..50bcf196d 100644 --- a/tests/sdk/conftest.py +++ b/tests/sdk/conftest.py @@ -2,8 +2,10 @@ from __future__ import annotations -from types import SimpleNamespace +import asyncio +import threading from typing import Any +from dataclasses import dataclass from unittest.mock import Mock, AsyncMock import httpx @@ -11,6 +13,76 @@ from runloop_api_client import Runloop, AsyncRunloop +# Test ID constants +TEST_IDS = { + "devbox": "dev_123", + "execution": "exec_123", + "snapshot": "snap_123", + "blueprint": "bp_123", + "object": "obj_123", +} + +# Test URL constants +TEST_URLS = { + "upload": "https://upload.example.com/obj_123", + "download": "https://download.example.com/obj_123", +} + +# Timing constants for thread/task synchronization tests +THREAD_STARTUP_DELAY = 0.1 # Time to allow threads/tasks to start +TASK_COMPLETION_SHORT = 0.02 # Brief async operation +TASK_COMPLETION_LONG = 1.0 # Long-running operation for cancellation tests +NUM_CONCURRENT_THREADS = 5 # Number of threads for concurrency tests + + +# Mock data structures using dataclasses for type safety +@dataclass +class MockDevboxView: + """Mock DevboxView for testing.""" + + id: str = "dev_123" + status: str = "running" + name: str = "test-devbox" + + +@dataclass +class MockExecutionView: + """Mock DevboxAsyncExecutionDetailView for testing.""" + + execution_id: str = "exec_123" + devbox_id: str = "dev_123" + status: str = "completed" + exit_status: int = 0 + stdout: str = "output" + stderr: str = "" + + +@dataclass +class MockSnapshotView: + """Mock DevboxSnapshotView for testing.""" + + id: str = "snap_123" + status: str = "completed" + name: str = "test-snapshot" + + +@dataclass +class MockBlueprintView: + """Mock BlueprintView for testing.""" + + id: str = "bp_123" + status: str = "built" + name: str = "test-blueprint" + + +@dataclass +class MockObjectView: + """Mock ObjectView for testing.""" + + id: str = "obj_123" + upload_url: str = "https://upload.example.com/obj_123" + name: str = "test-object" + def create_mock_httpx_client(methods: dict[str, Any] | None = None) -> AsyncMock: """ @@ -22,6 +94,9 @@ def create_mock_httpx_client(methods: dict[str, Any] | None = None) -> AsyncMock Returns: Configured AsyncMock for httpx.AsyncClient + + Note: We don't use spec here because we need to manually set context manager + methods which are not allowed with spec. """ mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) @@ -64,56 +139,33 @@ def mock_async_client() -> AsyncMock: @pytest.fixture -def devbox_view() -> SimpleNamespace: +def devbox_view() -> MockDevboxView: """Create a mock DevboxView.""" - return SimpleNamespace( - id="dev_123", - status="running", - name="test-devbox", - ) + return MockDevboxView() @pytest.fixture -def execution_view() -> SimpleNamespace: +def execution_view() -> MockExecutionView: """Create a mock DevboxAsyncExecutionDetailView.""" - return SimpleNamespace( - execution_id="exec_123", - devbox_id="dev_123", - status="completed", - exit_status=0, - stdout="output", - stderr="", - ) + return MockExecutionView() @pytest.fixture -def snapshot_view() -> SimpleNamespace: +def snapshot_view() -> MockSnapshotView: """Create a mock DevboxSnapshotView.""" - return SimpleNamespace( - id="snap_123", - status="completed", - name="test-snapshot", - ) + return MockSnapshotView() @pytest.fixture -def blueprint_view() -> SimpleNamespace: +def blueprint_view() -> MockBlueprintView: """Create a mock BlueprintView.""" - return SimpleNamespace( - id="bp_123", - status="built", - name="test-blueprint", - ) + return MockBlueprintView() @pytest.fixture -def object_view() -> SimpleNamespace: +def object_view() -> MockObjectView: """Create a mock ObjectView.""" - return SimpleNamespace( - id="obj_123", - upload_url="https://upload.example.com/obj_123", - name="test-object", - ) + return MockObjectView() @pytest.fixture @@ -130,7 +182,11 @@ def mock_httpx_response() -> Mock: @pytest.fixture def mock_stream() -> Mock: - """Create a mock Stream for testing.""" + """Create a mock Stream for testing. + + Note: We don't use spec here because we need to manually set context manager + and iterator methods which are not allowed with spec. + """ stream = Mock() stream.__iter__ = Mock(return_value=iter([])) stream.__enter__ = Mock(return_value=stream) @@ -141,7 +197,11 @@ def mock_stream() -> Mock: @pytest.fixture def mock_async_stream() -> AsyncMock: - """Create a mock AsyncStream for testing.""" + """Create a mock AsyncStream for testing. + + Note: We don't use spec here because we need to manually set context manager + and async iterator methods which are not allowed with spec. + """ async def async_iter(): # Empty async iterator @@ -154,3 +214,57 @@ async def async_iter(): stream.__aexit__ = AsyncMock(return_value=None) stream.close = AsyncMock() return stream + + +@pytest.fixture +async def async_task_cleanup(): + """ + Fixture to ensure async tasks are properly cleaned up after tests. + + Usage: + async def test_something(async_task_cleanup): + task = asyncio.create_task(some_coroutine()) + async_task_cleanup.append(task) + # Task will be automatically cancelled and awaited on teardown + + Yields: + List to append tasks to for automatic cleanup + """ + tasks: list[asyncio.Task[Any]] = [] + yield tasks + # Cleanup: cancel all tasks and wait for them to finish + for task in tasks: + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +@pytest.fixture +def thread_cleanup(): + """ + Fixture to ensure threads are properly cleaned up after tests. + + Usage: + def test_something(thread_cleanup): + threads, stop_events = thread_cleanup + stop_event = threading.Event() + thread = threading.Thread(target=worker, args=(stop_event,)) + thread.start() + threads.append(thread) + stop_events.append(stop_event) + # Thread will be automatically stopped and joined on teardown + + Yields: + Tuple of (threads list, stop_events list) for automatic cleanup + """ + threads: list[threading.Thread] = [] + stop_events: list[threading.Event] = [] + yield threads, stop_events + # Cleanup: signal all threads to stop and wait for them + for event in stop_events: + event.set() + for thread in threads: + thread.join(timeout=2.0) diff --git a/tests/sdk/devbox/__init__.py b/tests/sdk/devbox/__init__.py new file mode 100644 index 000000000..c48f00504 --- /dev/null +++ b/tests/sdk/devbox/__init__.py @@ -0,0 +1 @@ +"""Tests for sync Devbox functionality.""" diff --git a/tests/sdk/devbox/conftest.py b/tests/sdk/devbox/conftest.py new file mode 100644 index 000000000..4349ca79c --- /dev/null +++ b/tests/sdk/devbox/conftest.py @@ -0,0 +1,6 @@ +"""Shared fixtures and utilities for sync Devbox tests. + +This module contains fixtures and helpers specific to sync devbox testing +that are shared across multiple test modules in this directory. +""" +# Currently minimal - add shared helpers if patterns emerge diff --git a/tests/sdk/devbox/test_core.py b/tests/sdk/devbox/test_core.py new file mode 100644 index 000000000..86254f834 --- /dev/null +++ b/tests/sdk/devbox/test_core.py @@ -0,0 +1,296 @@ +"""Tests for core Devbox functionality. + +Tests the primary Devbox class including initialization, CRUD operations, +snapshot creation, blueprint launching, and execution methods. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from tests.sdk.conftest import ( + MockDevboxView, +) +from runloop_api_client.sdk import Devbox +from runloop_api_client._types import NotGiven, omit +from runloop_api_client.sdk.devbox import ( + _FileInterface, + _CommandInterface, + _NetworkInterface, +) +from runloop_api_client.lib.polling import PollingConfig + + +class TestDevbox: + """Tests for Devbox class.""" + + def test_init(self, mock_client: Mock) -> None: + """Test Devbox initialization.""" + devbox = Devbox(mock_client, "dev_123") + assert devbox.id == "dev_123" + + def test_repr(self, mock_client: Mock) -> None: + """Test Devbox string representation.""" + devbox = Devbox(mock_client, "dev_123") + assert repr(devbox) == "" + + def test_context_manager_enter_exit(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: + """Test context manager behavior with successful shutdown.""" + mock_client.devboxes.shutdown.return_value = devbox_view + + with Devbox(mock_client, "dev_123") as devbox: + assert devbox.id == "dev_123" + + call_kwargs = mock_client.devboxes.shutdown.call_args[1] + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_context_manager_exception_handling(self, mock_client: Mock) -> None: + """Test context manager handles exceptions during shutdown.""" + mock_client.devboxes.shutdown.side_effect = RuntimeError("Shutdown failed") + + with pytest.raises(ValueError, match="Test error"): + with Devbox(mock_client, "dev_123"): + raise ValueError("Test error") + + # Shutdown should be called even when body raises exception + mock_client.devboxes.shutdown.assert_called_once() + + def test_get_info(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: + """Test get_info method.""" + mock_client.devboxes.retrieve.return_value = devbox_view + + devbox = Devbox(mock_client, "dev_123") + result = devbox.get_info( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == devbox_view + mock_client.devboxes.retrieve.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + def test_await_running(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: + """Test await_running method.""" + mock_client.devboxes.await_running.return_value = devbox_view + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = Devbox(mock_client, "dev_123") + result = devbox.await_running(polling_config=polling_config) + + assert result == devbox_view + mock_client.devboxes.await_running.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + def test_await_suspended(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: + """Test await_suspended method.""" + mock_client.devboxes.await_suspended.return_value = devbox_view + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = Devbox(mock_client, "dev_123") + result = devbox.await_suspended(polling_config=polling_config) + + assert result == devbox_view + mock_client.devboxes.await_suspended.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + def test_shutdown(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: + """Test shutdown method.""" + mock_client.devboxes.shutdown.return_value = devbox_view + + devbox = Devbox(mock_client, "dev_123") + result = devbox.shutdown( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == devbox_view + mock_client.devboxes.shutdown.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + def test_suspend(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: + """Test suspend method.""" + mock_client.devboxes.suspend.return_value = None + mock_client.devboxes.await_suspended.return_value = devbox_view + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = Devbox(mock_client, "dev_123") + result = devbox.suspend( + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == devbox_view + mock_client.devboxes.suspend.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + mock_client.devboxes.await_suspended.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + def test_resume(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: + """Test resume method.""" + mock_client.devboxes.resume.return_value = None + mock_client.devboxes.await_running.return_value = devbox_view + polling_config = PollingConfig(timeout_seconds=60.0) + + devbox = Devbox(mock_client, "dev_123") + result = devbox.resume( + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == devbox_view + mock_client.devboxes.resume.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + mock_client.devboxes.await_running.assert_called_once_with( + "dev_123", + polling_config=polling_config, + ) + + def test_keep_alive(self, mock_client: Mock) -> None: + """Test keep_alive method.""" + mock_client.devboxes.keep_alive.return_value = object() + + devbox = Devbox(mock_client, "dev_123") + result = devbox.keep_alive( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result is not None # Verify return value is propagated + mock_client.devboxes.keep_alive.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + def test_snapshot_disk(self, mock_client: Mock) -> None: + """Test snapshot_disk waits for completion.""" + snapshot_data = SimpleNamespace(id="snap_123") + snapshot_status = SimpleNamespace(status="completed") + + mock_client.devboxes.snapshot_disk_async.return_value = snapshot_data + mock_client.devboxes.disk_snapshots.await_completed.return_value = snapshot_status + + devbox = Devbox(mock_client, "dev_123") + polling_config = PollingConfig(timeout_seconds=60.0) + snapshot = devbox.snapshot_disk( + name="test-snapshot", + metadata={"key": "value"}, + polling_config=polling_config, + extra_headers={"X-Custom": "value"}, + ) + + assert snapshot.id == "snap_123" + call_kwargs = mock_client.devboxes.snapshot_disk_async.call_args[1] + assert call_kwargs["commit_message"] is omit or call_kwargs["commit_message"] is None + assert call_kwargs["metadata"] == {"key": "value"} + assert call_kwargs["name"] == "test-snapshot" + assert call_kwargs["extra_headers"] == {"X-Custom": "value"} + assert isinstance(call_kwargs["timeout"], NotGiven) + call_kwargs2 = mock_client.devboxes.disk_snapshots.await_completed.call_args[1] + assert call_kwargs2["polling_config"] == polling_config + assert isinstance(call_kwargs2["timeout"], NotGiven) + + def test_snapshot_disk_async(self, mock_client: Mock) -> None: + """Test snapshot_disk_async returns immediately.""" + snapshot_data = SimpleNamespace(id="snap_123") + mock_client.devboxes.snapshot_disk_async.return_value = snapshot_data + + devbox = Devbox(mock_client, "dev_123") + snapshot = devbox.snapshot_disk_async( + name="test-snapshot", + metadata={"key": "value"}, + extra_headers={"X-Custom": "value"}, + ) + + assert snapshot.id == "snap_123" + call_kwargs = mock_client.devboxes.snapshot_disk_async.call_args[1] + assert call_kwargs["commit_message"] is omit or call_kwargs["commit_message"] is None + assert call_kwargs["metadata"] == {"key": "value"} + assert call_kwargs["name"] == "test-snapshot" + assert call_kwargs["extra_headers"] == {"X-Custom": "value"} + assert isinstance(call_kwargs["timeout"], NotGiven) + # Verify async method does not wait for completion + if hasattr(mock_client.devboxes.disk_snapshots, "await_completed"): + assert not mock_client.devboxes.disk_snapshots.await_completed.called + + def test_close(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: + """Test close method calls shutdown.""" + mock_client.devboxes.shutdown.return_value = devbox_view + + devbox = Devbox(mock_client, "dev_123") + devbox.close() + + call_kwargs = mock_client.devboxes.shutdown.call_args[1] + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_cmd_property(self, mock_client: Mock) -> None: + """Test cmd property returns CommandInterface.""" + devbox = Devbox(mock_client, "dev_123") + cmd = devbox.cmd + assert isinstance(cmd, _CommandInterface) + assert cmd._devbox is devbox + + def test_file_property(self, mock_client: Mock) -> None: + """Test file property returns FileInterface.""" + devbox = Devbox(mock_client, "dev_123") + file_interface = devbox.file + assert isinstance(file_interface, _FileInterface) + assert file_interface._devbox is devbox + + def test_net_property(self, mock_client: Mock) -> None: + """Test net property returns NetworkInterface.""" + devbox = Devbox(mock_client, "dev_123") + net = devbox.net + assert isinstance(net, _NetworkInterface) + assert net._devbox is devbox diff --git a/tests/sdk/devbox/test_edge_cases.py b/tests/sdk/devbox/test_edge_cases.py new file mode 100644 index 000000000..0f54a30d8 --- /dev/null +++ b/tests/sdk/devbox/test_edge_cases.py @@ -0,0 +1,146 @@ +"""Tests for Devbox error handling, edge cases, and Python-specific behavior. + +Tests error scenarios, edge cases, and Python-specific features that don't +fit into other categories. +""" + +from __future__ import annotations + +import threading +from types import SimpleNamespace +from pathlib import Path +from unittest.mock import Mock, patch + +import httpx +import pytest + +from tests.sdk.conftest import ( + NUM_CONCURRENT_THREADS, + MockDevboxView, + create_mock_httpx_response, +) +from runloop_api_client.sdk import Devbox, StorageObject +from runloop_api_client.types import DevboxView +from runloop_api_client._exceptions import APIStatusError + + +class TestDevboxErrorHandling: + """Tests for Devbox error handling scenarios.""" + + def test_network_error(self, mock_client: Mock) -> None: + """Test handling of network errors.""" + mock_client.devboxes.retrieve.side_effect = httpx.NetworkError("Connection failed") + + devbox = Devbox(mock_client, "dev_123") + with pytest.raises(httpx.NetworkError): + devbox.get_info() + + @pytest.mark.parametrize( + "status_code,message", + [ + (404, "Not Found"), + (500, "Internal Server Error"), + (503, "Service Unavailable"), + ], + ) + def test_api_error(self, mock_client: Mock, status_code: int, message: str) -> None: + """Test handling of API errors with various status codes.""" + response = create_mock_httpx_response(status_code=status_code, headers={}, text=message) + error = APIStatusError(message=message, response=response, body=None) + + mock_client.devboxes.retrieve.side_effect = error + + devbox = Devbox(mock_client, "dev_123") + with pytest.raises(APIStatusError): + devbox.get_info() + + def test_timeout_error(self, mock_client: Mock) -> None: + """Test handling of timeout errors.""" + mock_client.devboxes.retrieve.side_effect = httpx.TimeoutException("Request timed out") + + devbox = Devbox(mock_client, "dev_123") + with pytest.raises(httpx.TimeoutException): + devbox.get_info(timeout=1.0) + + +class TestDevboxEdgeCases: + """Tests for Devbox edge cases.""" + + def test_empty_responses(self, mock_client: Mock) -> None: + """Test handling of empty responses.""" + empty_view = SimpleNamespace(id="dev_123", status="", name="") + mock_client.devboxes.retrieve.return_value = empty_view + + devbox = Devbox(mock_client, "dev_123") + result = devbox.get_info() + assert result == empty_view + + def test_none_values(self, mock_client: Mock) -> None: + """Test handling of None values.""" + view_with_none = SimpleNamespace(id="dev_123", status=None, name=None) + mock_client.devboxes.retrieve.return_value = view_with_none + + devbox = Devbox(mock_client, "dev_123") + result = devbox.get_info() + assert result.status is None + assert result.name is None + + def test_concurrent_operations( + self, mock_client: Mock, thread_cleanup: tuple[list[threading.Thread], list[threading.Event]] + ) -> None: + """Test concurrent operations.""" + mock_client.devboxes.retrieve.return_value = SimpleNamespace(id="dev_123", status="running") + + devbox = Devbox(mock_client, "dev_123") + results: list[DevboxView] = [] + + def get_info() -> None: + results.append(devbox.get_info()) + + threads = [threading.Thread(target=get_info) for _ in range(NUM_CONCURRENT_THREADS)] + # Register threads for automatic cleanup + cleanup_threads, _ = thread_cleanup + cleanup_threads.extend(threads) + + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert len(results) == NUM_CONCURRENT_THREADS + + +class TestDevboxPythonSpecific: + """Tests for Python-specific Devbox behavior.""" + + def test_context_manager_vs_manual_cleanup(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: + """Test context manager provides automatic cleanup.""" + mock_client.devboxes.shutdown.return_value = devbox_view + + # Context manager approach (Pythonic) + with Devbox(mock_client, "dev_123"): + pass + + mock_client.devboxes.shutdown.assert_called_once() + + # Manual cleanup (TypeScript-like) + devbox = Devbox(mock_client, "dev_123") + devbox.shutdown() + assert mock_client.devboxes.shutdown.call_count == 2 + + def test_path_handling(self, mock_client: Mock, tmp_path: Path) -> None: + """Test Path handling (Python-specific).""" + object_view = SimpleNamespace(id="obj_123", upload_url="https://upload.example.com") + mock_client.objects.create.return_value = object_view + + temp_file = tmp_path / "test_file.txt" + temp_file.write_text("test") + + with patch("httpx.put") as mock_put: + mock_response = create_mock_httpx_response() + mock_put.return_value = mock_response + + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + obj.upload_content(temp_file) # Path object works + + mock_put.assert_called_once() diff --git a/tests/sdk/devbox/test_interfaces.py b/tests/sdk/devbox/test_interfaces.py new file mode 100644 index 000000000..d6f36aeb7 --- /dev/null +++ b/tests/sdk/devbox/test_interfaces.py @@ -0,0 +1,331 @@ +"""Tests for Devbox interface classes. + +Tests the command, file, and network interface helper classes that provide +structured access to devbox operations. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from pathlib import Path +from unittest.mock import Mock + +import httpx + +from tests.sdk.conftest import MockExecutionView +from runloop_api_client.sdk import Devbox +from runloop_api_client._types import NotGiven + + +class TestCommandInterface: + """Tests for _CommandInterface.""" + + def test_exec_without_callbacks(self, mock_client: Mock, execution_view: MockExecutionView) -> None: + """Test exec without streaming callbacks.""" + mock_client.devboxes.execute_and_await_completion.return_value = execution_view + + devbox = Devbox(mock_client, "dev_123") + result = devbox.cmd.exec("echo hello") + + assert result.exit_code == 0 + assert result.stdout() == "output" + call_kwargs = mock_client.devboxes.execute_and_await_completion.call_args[1] + assert call_kwargs["command"] == "echo hello" + assert isinstance(call_kwargs["shell_name"], NotGiven) or call_kwargs["shell_name"] is None + assert call_kwargs["polling_config"] is None + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_exec_with_stdout_callback(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test exec with stdout callback.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + execution_completed = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="", + ) + + mock_client.devboxes.execute_async.return_value = execution_async + mock_client.devboxes.executions.await_completed.return_value = execution_completed + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + + stdout_calls: list[str] = [] + + devbox = Devbox(mock_client, "dev_123") + result = devbox.cmd.exec("echo hello", stdout=stdout_calls.append) + + assert result.exit_code == 0 + mock_client.devboxes.execute_async.assert_called_once() + mock_client.devboxes.executions.await_completed.assert_called_once() + + def test_exec_with_stderr_callback(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test exec with stderr callback.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + execution_completed = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="", + stderr="error", + ) + + mock_client.devboxes.execute_async.return_value = execution_async + mock_client.devboxes.executions.await_completed.return_value = execution_completed + mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream + + stderr_calls: list[str] = [] + + devbox = Devbox(mock_client, "dev_123") + result = devbox.cmd.exec("echo hello", stderr=stderr_calls.append) + + assert result.exit_code == 0 + mock_client.devboxes.execute_async.assert_called_once() + + def test_exec_with_output_callback(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test exec with output callback.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + execution_completed = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="", + ) + + mock_client.devboxes.execute_async.return_value = execution_async + mock_client.devboxes.executions.await_completed.return_value = execution_completed + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream + + output_calls: list[str] = [] + + devbox = Devbox(mock_client, "dev_123") + result = devbox.cmd.exec("echo hello", output=output_calls.append) + + assert result.exit_code == 0 + mock_client.devboxes.execute_async.assert_called_once() + + def test_exec_with_all_callbacks(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test exec with all callbacks.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + execution_completed = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="error", + ) + + mock_client.devboxes.execute_async.return_value = execution_async + mock_client.devboxes.executions.await_completed.return_value = execution_completed + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream + + stdout_calls: list[str] = [] + stderr_calls: list[str] = [] + output_calls: list[str] = [] + + devbox = Devbox(mock_client, "dev_123") + result = devbox.cmd.exec( + "echo hello", + stdout=stdout_calls.append, + stderr=stderr_calls.append, + output=output_calls.append, + ) + + assert result.exit_code == 0 + mock_client.devboxes.execute_async.assert_called_once() + + def test_exec_async_returns_execution(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test exec_async returns Execution object.""" + execution_async = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="running", + ) + + mock_client.devboxes.execute_async.return_value = execution_async + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + + devbox = Devbox(mock_client, "dev_123") + execution = devbox.cmd.exec_async("long-running command") + + assert execution.execution_id == "exec_123" + assert execution.devbox_id == "dev_123" + mock_client.devboxes.execute_async.assert_called_once() + + +class TestFileInterface: + """Tests for _FileInterface.""" + + def test_read(self, mock_client: Mock) -> None: + """Test file read.""" + mock_client.devboxes.read_file_contents.return_value = "file content" + + devbox = Devbox(mock_client, "dev_123") + result = devbox.file.read("/path/to/file") + + assert result == "file content" + call_kwargs = mock_client.devboxes.read_file_contents.call_args[1] + assert call_kwargs["file_path"] == "/path/to/file" + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_write_string(self, mock_client: Mock) -> None: + """Test file write with string.""" + execution_detail = SimpleNamespace() + mock_client.devboxes.write_file_contents.return_value = execution_detail + + devbox = Devbox(mock_client, "dev_123") + result = devbox.file.write("/path/to/file", "content") + + assert result == execution_detail + call_kwargs = mock_client.devboxes.write_file_contents.call_args[1] + assert call_kwargs["file_path"] == "/path/to/file" + assert call_kwargs["contents"] == "content" + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_write_bytes(self, mock_client: Mock) -> None: + """Test file write with bytes.""" + execution_detail = SimpleNamespace() + mock_client.devboxes.write_file_contents.return_value = execution_detail + + devbox = Devbox(mock_client, "dev_123") + result = devbox.file.write("/path/to/file", b"content") + + assert result == execution_detail + call_kwargs = mock_client.devboxes.write_file_contents.call_args[1] + assert call_kwargs["file_path"] == "/path/to/file" + assert call_kwargs["contents"] == "content" + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_download(self, mock_client: Mock) -> None: + """Test file download.""" + mock_response = Mock(spec=httpx.Response) + mock_response.read.return_value = b"file content" + mock_client.devboxes.download_file.return_value = mock_response + + devbox = Devbox(mock_client, "dev_123") + result = devbox.file.download("/path/to/file") + + assert result == b"file content" + call_kwargs = mock_client.devboxes.download_file.call_args[1] + assert call_kwargs["path"] == "/path/to/file" + assert isinstance(call_kwargs["timeout"], NotGiven) + + def test_upload(self, mock_client: Mock, tmp_path: Path) -> None: + """Test file upload.""" + execution_detail = SimpleNamespace() + mock_client.devboxes.upload_file.return_value = execution_detail + + devbox = Devbox(mock_client, "dev_123") + # Create a temporary file for upload + temp_file = tmp_path / "test_file.txt" + temp_file.write_text("test content") + + result = devbox.file.upload("/remote/path", temp_file) + + assert result == execution_detail + call_kwargs = mock_client.devboxes.upload_file.call_args[1] + assert call_kwargs["path"] == "/remote/path" + assert call_kwargs["file"] is not None # File object from temp_path + assert isinstance(call_kwargs["timeout"], NotGiven) + + +class TestNetworkInterface: + """Tests for _NetworkInterface.""" + + def test_create_ssh_key(self, mock_client: Mock) -> None: + """Test create SSH key.""" + ssh_key_response = SimpleNamespace(public_key="ssh-rsa ...") + mock_client.devboxes.create_ssh_key.return_value = ssh_key_response + + devbox = Devbox(mock_client, "dev_123") + result = devbox.net.create_ssh_key( + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == ssh_key_response + mock_client.devboxes.create_ssh_key.assert_called_once_with( + "dev_123", + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + def test_create_tunnel(self, mock_client: Mock) -> None: + """Test create tunnel.""" + tunnel_view = SimpleNamespace(port=8080) + mock_client.devboxes.create_tunnel.return_value = tunnel_view + + devbox = Devbox(mock_client, "dev_123") + result = devbox.net.create_tunnel( + port=8080, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result == tunnel_view + mock_client.devboxes.create_tunnel.assert_called_once_with( + "dev_123", + port=8080, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + def test_remove_tunnel(self, mock_client: Mock) -> None: + """Test remove tunnel.""" + mock_client.devboxes.remove_tunnel.return_value = object() + + devbox = Devbox(mock_client, "dev_123") + result = devbox.net.remove_tunnel( + port=8080, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) + + assert result is not None # Verify return value is propagated + mock_client.devboxes.remove_tunnel.assert_called_once_with( + "dev_123", + port=8080, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + idempotency_key="key-123", + ) diff --git a/tests/sdk/devbox/test_streaming.py b/tests/sdk/devbox/test_streaming.py new file mode 100644 index 000000000..4f8ef75ff --- /dev/null +++ b/tests/sdk/devbox/test_streaming.py @@ -0,0 +1,171 @@ +"""Tests for Devbox streaming functionality. + +Tests streaming setup, thread spawning, concurrent operations, and the +streaming group management. +""" + +from __future__ import annotations + +import time +import threading +from types import SimpleNamespace +from unittest.mock import Mock + +from tests.sdk.conftest import THREAD_STARTUP_DELAY +from runloop_api_client.sdk import Devbox +from runloop_api_client._streaming import Stream +from runloop_api_client.sdk.execution import _StreamingGroup +from runloop_api_client.types.devboxes.execution_update_chunk import ExecutionUpdateChunk + +# Legacy alias for backward compatibility +SHORT_SLEEP = THREAD_STARTUP_DELAY + + +class TestDevboxStreaming: + """Tests for Devbox streaming methods.""" + + def test_start_streaming_no_callbacks(self, mock_client: Mock) -> None: + """Test _start_streaming returns None when no callbacks.""" + devbox = Devbox(mock_client, "dev_123") + result = devbox._start_streaming("exec_123", stdout=None, stderr=None, output=None) + assert result is None + + def test_start_streaming_stdout_only(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test _start_streaming with stdout callback only.""" + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + + devbox = Devbox(mock_client, "dev_123") + stdout_calls: list[str] = [] + result = devbox._start_streaming("exec_123", stdout=stdout_calls.append, stderr=None, output=None) + + assert result is not None + assert isinstance(result, _StreamingGroup) + assert len(result._threads) == 1 + mock_client.devboxes.executions.stream_stdout_updates.assert_called_once() + + def test_start_streaming_stderr_only(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test _start_streaming with stderr callback only.""" + mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream + + devbox = Devbox(mock_client, "dev_123") + stderr_calls: list[str] = [] + result = devbox._start_streaming("exec_123", stdout=None, stderr=stderr_calls.append, output=None) + + assert result is not None + assert isinstance(result, _StreamingGroup) + assert len(result._threads) == 1 + mock_client.devboxes.executions.stream_stderr_updates.assert_called_once() + + def test_start_streaming_output_only(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test _start_streaming with output callback only.""" + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream + + devbox = Devbox(mock_client, "dev_123") + output_calls: list[str] = [] + result = devbox._start_streaming("exec_123", stdout=None, stderr=None, output=output_calls.append) + + assert result is not None + assert isinstance(result, _StreamingGroup) + assert len(result._threads) == 2 # Both stdout and stderr streams + + def test_start_streaming_all_callbacks(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test _start_streaming with all callbacks.""" + mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream + mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream + + devbox = Devbox(mock_client, "dev_123") + stdout_calls: list[str] = [] + stderr_calls: list[str] = [] + output_calls: list[str] = [] + result = devbox._start_streaming( + "exec_123", + stdout=stdout_calls.append, + stderr=stderr_calls.append, + output=output_calls.append, + ) + + assert result is not None + assert isinstance(result, _StreamingGroup) + assert len(result._threads) == 2 # Both stdout and stderr streams + + def test_spawn_stream_thread( + self, mock_client: Mock, mock_stream: Mock, thread_cleanup: tuple[list[threading.Thread], list[threading.Event]] + ) -> None: + """Test _spawn_stream_thread creates and starts thread.""" + mock_stream.__iter__ = Mock( + return_value=iter( + [ + SimpleNamespace(output="line 1"), + SimpleNamespace(output="line 2"), + ] + ) + ) + mock_stream.__enter__ = Mock(return_value=mock_stream) + mock_stream.__exit__ = Mock(return_value=None) + + devbox = Devbox(mock_client, "dev_123") + stop_event = threading.Event() + calls: list[str] = [] + + def stream_factory() -> Stream[ExecutionUpdateChunk]: + return mock_stream + + thread = devbox._spawn_stream_thread( + name="test", + stream_factory=stream_factory, + callbacks=[calls.append], + stop_event=stop_event, + ) + + # Register thread and stop event for automatic cleanup + threads, stop_events = thread_cleanup + threads.append(thread) + stop_events.append(stop_event) + + assert isinstance(thread, threading.Thread) + # Give thread time to start + time.sleep(SHORT_SLEEP) + # Thread may have already finished if stream is short + if thread.is_alive(): + stop_event.set() + thread.join(timeout=1.0) + assert not thread.is_alive() + + def test_spawn_stream_thread_stop_event( + self, mock_client: Mock, mock_stream: Mock, thread_cleanup: tuple[list[threading.Thread], list[threading.Event]] + ) -> None: + """Test _spawn_stream_thread respects stop event.""" + mock_stream.__iter__ = Mock( + return_value=iter( + [ + SimpleNamespace(output="line 1"), + SimpleNamespace(output="line 2"), + ] + ) + ) + mock_stream.__enter__ = Mock(return_value=mock_stream) + mock_stream.__exit__ = Mock(return_value=None) + + devbox = Devbox(mock_client, "dev_123") + stop_event = threading.Event() + calls: list[str] = [] + + def stream_factory() -> Stream[ExecutionUpdateChunk]: + return mock_stream + + thread = devbox._spawn_stream_thread( + name="test", + stream_factory=stream_factory, + callbacks=[calls.append], + stop_event=stop_event, + ) + + # Register thread and stop event for automatic cleanup + threads, stop_events = thread_cleanup + threads.append(thread) + stop_events.append(stop_event) + + stop_event.set() + thread.join(timeout=1.0) + assert not thread.is_alive() diff --git a/tests/sdk/test_async_blueprint.py b/tests/sdk/test_async_blueprint.py index b549d8e3b..8f638c18f 100644 --- a/tests/sdk/test_async_blueprint.py +++ b/tests/sdk/test_async_blueprint.py @@ -7,6 +7,7 @@ import pytest +from tests.sdk.conftest import MockDevboxView, MockBlueprintView from runloop_api_client.sdk import AsyncBlueprint @@ -24,7 +25,7 @@ def test_repr(self, mock_async_client: AsyncMock) -> None: assert repr(blueprint) == "" @pytest.mark.asyncio - async def test_get_info(self, mock_async_client: AsyncMock, blueprint_view: SimpleNamespace) -> None: + async def test_get_info(self, mock_async_client: AsyncMock, blueprint_view: MockBlueprintView) -> None: """Test get_info method.""" mock_async_client.blueprints.retrieve = AsyncMock(return_value=blueprint_view) @@ -59,7 +60,6 @@ async def test_logs(self, mock_async_client: AsyncMock) -> None: @pytest.mark.asyncio async def test_delete(self, mock_async_client: AsyncMock) -> None: """Test delete method.""" - # Return value not used - testing side effect only mock_async_client.blueprints.delete = AsyncMock(return_value=object()) blueprint = AsyncBlueprint(mock_async_client, "bp_123") @@ -70,11 +70,11 @@ async def test_delete(self, mock_async_client: AsyncMock) -> None: timeout=30.0, ) - assert result is not None + assert result is not None # Verify return value is propagated mock_async_client.blueprints.delete.assert_called_once() @pytest.mark.asyncio - async def test_create_devbox(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + async def test_create_devbox(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: """Test create_devbox method.""" mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) diff --git a/tests/sdk/test_async_clients.py b/tests/sdk/test_async_clients.py index 951056f46..1fce85b4e 100644 --- a/tests/sdk/test_async_clients.py +++ b/tests/sdk/test_async_clients.py @@ -2,14 +2,20 @@ from __future__ import annotations -import tempfile from types import SimpleNamespace from pathlib import Path from unittest.mock import AsyncMock, patch import pytest -from tests.sdk.conftest import create_mock_httpx_client, create_mock_httpx_response +from tests.sdk.conftest import ( + MockDevboxView, + MockObjectView, + MockSnapshotView, + MockBlueprintView, + create_mock_httpx_client, + create_mock_httpx_response, +) from runloop_api_client.sdk import AsyncDevbox, AsyncSnapshot, AsyncBlueprint, AsyncStorageObject from runloop_api_client.sdk._async import ( AsyncRunloopSDK, @@ -25,7 +31,7 @@ class TestAsyncDevboxClient: """Tests for AsyncDevboxClient class.""" @pytest.mark.asyncio - async def test_create(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + async def test_create(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: """Test create method.""" mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) @@ -41,7 +47,7 @@ async def test_create(self, mock_async_client: AsyncMock, devbox_view: SimpleNam mock_async_client.devboxes.create_and_await_running.assert_called_once() @pytest.mark.asyncio - async def test_create_from_blueprint_id(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + async def test_create_from_blueprint_id(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: """Test create_from_blueprint_id method.""" mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) @@ -56,7 +62,7 @@ async def test_create_from_blueprint_id(self, mock_async_client: AsyncMock, devb assert call_kwargs["blueprint_id"] == "bp_123" @pytest.mark.asyncio - async def test_create_from_blueprint_name(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + async def test_create_from_blueprint_name(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: """Test create_from_blueprint_name method.""" mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) @@ -71,7 +77,7 @@ async def test_create_from_blueprint_name(self, mock_async_client: AsyncMock, de assert call_kwargs["blueprint_name"] == "my-blueprint" @pytest.mark.asyncio - async def test_create_from_snapshot(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + async def test_create_from_snapshot(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: """Test create_from_snapshot method.""" mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) @@ -97,7 +103,7 @@ def test_from_id(self, mock_async_client: AsyncMock) -> None: assert not mock_async_client.devboxes.await_running.called @pytest.mark.asyncio - async def test_list(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + async def test_list(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: """Test list method.""" page = SimpleNamespace(devboxes=[devbox_view]) mock_async_client.devboxes.list = AsyncMock(return_value=page) @@ -119,7 +125,7 @@ class TestAsyncSnapshotClient: """Tests for AsyncSnapshotClient class.""" @pytest.mark.asyncio - async def test_list(self, mock_async_client: AsyncMock, snapshot_view: SimpleNamespace) -> None: + async def test_list(self, mock_async_client: AsyncMock, snapshot_view: MockSnapshotView) -> None: """Test list method.""" page = SimpleNamespace(disk_snapshots=[snapshot_view]) mock_async_client.devboxes.disk_snapshots.list = AsyncMock(return_value=page) @@ -149,7 +155,7 @@ class TestAsyncBlueprintClient: """Tests for AsyncBlueprintClient class.""" @pytest.mark.asyncio - async def test_create(self, mock_async_client: AsyncMock, blueprint_view: SimpleNamespace) -> None: + async def test_create(self, mock_async_client: AsyncMock, blueprint_view: MockBlueprintView) -> None: """Test create method.""" mock_async_client.blueprints.create_and_await_build_complete = AsyncMock(return_value=blueprint_view) @@ -172,7 +178,7 @@ def test_from_id(self, mock_async_client: AsyncMock) -> None: assert blueprint.id == "bp_123" @pytest.mark.asyncio - async def test_list(self, mock_async_client: AsyncMock, blueprint_view: SimpleNamespace) -> None: + async def test_list(self, mock_async_client: AsyncMock, blueprint_view: MockBlueprintView) -> None: """Test list method.""" page = SimpleNamespace(blueprints=[blueprint_view]) mock_async_client.blueprints.list = AsyncMock(return_value=page) @@ -194,7 +200,7 @@ class TestAsyncStorageObjectClient: """Tests for AsyncStorageObjectClient class.""" @pytest.mark.asyncio - async def test_create(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + async def test_create(self, mock_async_client: AsyncMock, object_view: MockObjectView) -> None: """Test create method.""" mock_async_client.objects.create = AsyncMock(return_value=object_view) @@ -208,7 +214,7 @@ async def test_create(self, mock_async_client: AsyncMock, object_view: SimpleNam @pytest.mark.asyncio async def test_create_auto_detect_content_type( - self, mock_async_client: AsyncMock, object_view: SimpleNamespace + self, mock_async_client: AsyncMock, object_view: MockObjectView ) -> None: """Test create auto-detects content type.""" mock_async_client.objects.create = AsyncMock(return_value=object_view) @@ -230,7 +236,7 @@ def test_from_id(self, mock_async_client: AsyncMock) -> None: assert obj.upload_url is None @pytest.mark.asyncio - async def test_list(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + async def test_list(self, mock_async_client: AsyncMock, object_view: MockObjectView) -> None: """Test list method.""" page = SimpleNamespace(objects=[object_view]) mock_async_client.objects.list = AsyncMock(return_value=page) @@ -251,33 +257,31 @@ async def test_list(self, mock_async_client: AsyncMock, object_view: SimpleNames mock_async_client.objects.list.assert_called_once() @pytest.mark.asyncio - async def test_upload_from_file(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + async def test_upload_from_file( + self, mock_async_client: AsyncMock, object_view: MockObjectView, tmp_path: Path + ) -> None: """Test upload_from_file method.""" mock_async_client.objects.create = AsyncMock(return_value=object_view) mock_async_client.objects.complete = AsyncMock(return_value=object_view) - with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: - f.write("test content") - temp_path = Path(f.name) + temp_file = tmp_path / "test_file.txt" + temp_file.write_text("test content") - try: - with patch("httpx.AsyncClient") as mock_client_class: - mock_response = create_mock_httpx_response() - mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) - mock_client_class.return_value = mock_http_client + with patch("httpx.AsyncClient") as mock_client_class: + mock_response = create_mock_httpx_response() + mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) + mock_client_class.return_value = mock_http_client - client = AsyncStorageObjectClient(mock_async_client) - obj = await client.upload_from_file(temp_path, name="test.txt") + client = AsyncStorageObjectClient(mock_async_client) + obj = await client.upload_from_file(temp_file, name="test.txt") - assert isinstance(obj, AsyncStorageObject) - assert obj.id == "obj_123" - mock_async_client.objects.create.assert_called_once() - mock_async_client.objects.complete.assert_called_once() - finally: - temp_path.unlink() + assert isinstance(obj, AsyncStorageObject) + assert obj.id == "obj_123" + mock_async_client.objects.create.assert_called_once() + mock_async_client.objects.complete.assert_called_once() @pytest.mark.asyncio - async def test_upload_from_text(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + async def test_upload_from_text(self, mock_async_client: AsyncMock, object_view: MockObjectView) -> None: """Test upload_from_text method.""" mock_async_client.objects.create = AsyncMock(return_value=object_view) mock_async_client.objects.complete = AsyncMock(return_value=object_view) @@ -298,7 +302,7 @@ async def test_upload_from_text(self, mock_async_client: AsyncMock, object_view: mock_async_client.objects.complete.assert_called_once() @pytest.mark.asyncio - async def test_upload_from_bytes(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + async def test_upload_from_bytes(self, mock_async_client: AsyncMock, object_view: MockObjectView) -> None: """Test upload_from_bytes method.""" mock_async_client.objects.create = AsyncMock(return_value=object_view) mock_async_client.objects.complete = AsyncMock(return_value=object_view) diff --git a/tests/sdk/test_async_devbox.py b/tests/sdk/test_async_devbox.py deleted file mode 100644 index d7dbd7ccd..000000000 --- a/tests/sdk/test_async_devbox.py +++ /dev/null @@ -1,689 +0,0 @@ -"""Comprehensive tests for async Devbox class.""" - -from __future__ import annotations - -import asyncio -import tempfile -from types import SimpleNamespace -from typing import AsyncIterator -from pathlib import Path -from unittest.mock import Mock, AsyncMock - -import httpx -import pytest - -from runloop_api_client.sdk import AsyncDevbox -from runloop_api_client._types import NotGiven -from runloop_api_client._streaming import AsyncStream -from runloop_api_client.lib.polling import PollingConfig -from runloop_api_client.sdk.async_devbox import ( - _AsyncFileInterface, - _AsyncCommandInterface, - _AsyncNetworkInterface, -) -from runloop_api_client.sdk.async_execution import _AsyncStreamingGroup - - -class TestAsyncDevbox: - """Tests for AsyncDevbox class.""" - - def test_init(self, mock_async_client: AsyncMock) -> None: - """Test AsyncDevbox initialization.""" - devbox = AsyncDevbox(mock_async_client, "dev_123") - assert devbox.id == "dev_123" - - def test_repr(self, mock_async_client: AsyncMock) -> None: - """Test AsyncDevbox string representation.""" - devbox = AsyncDevbox(mock_async_client, "dev_123") - assert repr(devbox) == "" - - @pytest.mark.asyncio - async def test_context_manager_enter_exit(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: - """Test context manager behavior with successful shutdown.""" - mock_async_client.devboxes.shutdown = AsyncMock(return_value=devbox_view) - - async with AsyncDevbox(mock_async_client, "dev_123") as devbox: - assert devbox.id == "dev_123" - - call_kwargs = mock_async_client.devboxes.shutdown.call_args[1] - assert isinstance(call_kwargs["timeout"], NotGiven) - - @pytest.mark.asyncio - async def test_context_manager_exception_handling(self, mock_async_client: AsyncMock) -> None: - """Test context manager handles exceptions during shutdown.""" - mock_async_client.devboxes.shutdown = AsyncMock(side_effect=RuntimeError("Shutdown failed")) - - with pytest.raises(ValueError, match="Test error"): - async with AsyncDevbox(mock_async_client, "dev_123"): - raise ValueError("Test error") - - # Shutdown should be called even when body raises exception - mock_async_client.devboxes.shutdown.assert_called_once() - - @pytest.mark.asyncio - async def test_get_info(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: - """Test get_info method.""" - mock_async_client.devboxes.retrieve = AsyncMock(return_value=devbox_view) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.get_info( - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - ) - - assert result == devbox_view - mock_async_client.devboxes.retrieve.assert_called_once_with( - "dev_123", - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - ) - - @pytest.mark.asyncio - async def test_await_running(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: - """Test await_running method.""" - mock_async_client.devboxes.await_running = AsyncMock(return_value=devbox_view) - polling_config = PollingConfig(timeout_seconds=60.0) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.await_running(polling_config=polling_config) - - assert result == devbox_view - mock_async_client.devboxes.await_running.assert_called_once_with( - "dev_123", - polling_config=polling_config, - ) - - @pytest.mark.asyncio - async def test_await_suspended(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: - """Test await_suspended method.""" - mock_async_client.devboxes.await_suspended = AsyncMock(return_value=devbox_view) - polling_config = PollingConfig(timeout_seconds=60.0) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.await_suspended(polling_config=polling_config) - - assert result == devbox_view - mock_async_client.devboxes.await_suspended.assert_called_once_with( - "dev_123", - polling_config=polling_config, - ) - - @pytest.mark.asyncio - async def test_shutdown(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: - """Test shutdown method.""" - mock_async_client.devboxes.shutdown = AsyncMock(return_value=devbox_view) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.shutdown( - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result == devbox_view - mock_async_client.devboxes.shutdown.assert_called_once_with( - "dev_123", - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - @pytest.mark.asyncio - async def test_suspend(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: - """Test suspend method.""" - mock_async_client.devboxes.suspend = AsyncMock(return_value=None) - mock_async_client.devboxes.await_suspended = AsyncMock(return_value=devbox_view) - polling_config = PollingConfig(timeout_seconds=60.0) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.suspend( - polling_config=polling_config, - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result == devbox_view - mock_async_client.devboxes.suspend.assert_called_once_with( - "dev_123", - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - mock_async_client.devboxes.await_suspended.assert_called_once_with( - "dev_123", - polling_config=polling_config, - ) - - @pytest.mark.asyncio - async def test_resume(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: - """Test resume method.""" - mock_async_client.devboxes.resume = AsyncMock(return_value=None) - mock_async_client.devboxes.await_running = AsyncMock(return_value=devbox_view) - polling_config = PollingConfig(timeout_seconds=60.0) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.resume( - polling_config=polling_config, - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result == devbox_view - mock_async_client.devboxes.resume.assert_called_once_with( - "dev_123", - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - mock_async_client.devboxes.await_running.assert_called_once_with( - "dev_123", - polling_config=polling_config, - ) - - @pytest.mark.asyncio - async def test_keep_alive(self, mock_async_client: AsyncMock) -> None: - """Test keep_alive method.""" - # Return value not used - testing parameter passing only - mock_async_client.devboxes.keep_alive = AsyncMock(return_value=object()) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.keep_alive( - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result is not None - mock_async_client.devboxes.keep_alive.assert_called_once_with( - "dev_123", - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - @pytest.mark.asyncio - async def test_snapshot_disk(self, mock_async_client: AsyncMock) -> None: - """Test snapshot_disk waits for completion.""" - snapshot_data = SimpleNamespace(id="snap_123") - snapshot_status = SimpleNamespace(status="completed") - - mock_async_client.devboxes.snapshot_disk_async = AsyncMock(return_value=snapshot_data) - mock_async_client.devboxes.disk_snapshots.await_completed = AsyncMock(return_value=snapshot_status) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - polling_config = PollingConfig(timeout_seconds=60.0) - snapshot = await devbox.snapshot_disk( - name="test-snapshot", - metadata={"key": "value"}, - polling_config=polling_config, - extra_headers={"X-Custom": "value"}, - ) - - assert snapshot.id == "snap_123" - mock_async_client.devboxes.snapshot_disk_async.assert_called_once() - mock_async_client.devboxes.disk_snapshots.await_completed.assert_called_once() - - @pytest.mark.asyncio - async def test_snapshot_disk_async(self, mock_async_client: AsyncMock) -> None: - """Test snapshot_disk_async returns immediately.""" - snapshot_data = SimpleNamespace(id="snap_123") - mock_async_client.devboxes.snapshot_disk_async = AsyncMock(return_value=snapshot_data) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - snapshot = await devbox.snapshot_disk_async( - name="test-snapshot", - metadata={"key": "value"}, - extra_headers={"X-Custom": "value"}, - ) - - assert snapshot.id == "snap_123" - mock_async_client.devboxes.snapshot_disk_async.assert_called_once() - - @pytest.mark.asyncio - async def test_close(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: - """Test close method calls shutdown.""" - mock_async_client.devboxes.shutdown = AsyncMock(return_value=devbox_view) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - await devbox.close() - - mock_async_client.devboxes.shutdown.assert_called_once() - - def test_cmd_property(self, mock_async_client: AsyncMock) -> None: - """Test cmd property returns AsyncCommandInterface.""" - devbox = AsyncDevbox(mock_async_client, "dev_123") - cmd = devbox.cmd - assert isinstance(cmd, _AsyncCommandInterface) - assert cmd._devbox is devbox - - def test_file_property(self, mock_async_client: AsyncMock) -> None: - """Test file property returns AsyncFileInterface.""" - devbox = AsyncDevbox(mock_async_client, "dev_123") - file_interface = devbox.file - assert isinstance(file_interface, _AsyncFileInterface) - assert file_interface._devbox is devbox - - def test_net_property(self, mock_async_client: AsyncMock) -> None: - """Test net property returns AsyncNetworkInterface.""" - devbox = AsyncDevbox(mock_async_client, "dev_123") - net = devbox.net - assert isinstance(net, _AsyncNetworkInterface) - assert net._devbox is devbox - - -class TestAsyncCommandInterface: - """Tests for _AsyncCommandInterface.""" - - @pytest.mark.asyncio - async def test_exec_without_callbacks(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: - """Test exec without streaming callbacks.""" - mock_async_client.devboxes.execute_and_await_completion = AsyncMock(return_value=execution_view) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.cmd.exec("echo hello") - - assert result.exit_code == 0 - assert await result.stdout() == "output" - call_kwargs = mock_async_client.devboxes.execute_and_await_completion.call_args[1] - assert call_kwargs["command"] == "echo hello" - assert isinstance(call_kwargs["shell_name"], NotGiven) or call_kwargs["shell_name"] is None - assert isinstance(call_kwargs["timeout"], NotGiven) - - @pytest.mark.asyncio - async def test_exec_with_stdout_callback(self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock) -> None: - """Test exec with stdout callback.""" - execution_async = SimpleNamespace( - execution_id="exec_123", - devbox_id="dev_123", - status="running", - ) - execution_completed = SimpleNamespace( - execution_id="exec_123", - devbox_id="dev_123", - status="completed", - exit_status=0, - stdout="output", - stderr="", - ) - - mock_async_client.devboxes.execute_async = AsyncMock(return_value=execution_async) - mock_async_client.devboxes.executions.await_completed = AsyncMock(return_value=execution_completed) - mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) - - stdout_calls: list[str] = [] - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.cmd.exec("echo hello", stdout=stdout_calls.append) - - assert result.exit_code == 0 - mock_async_client.devboxes.execute_async.assert_called_once() - - @pytest.mark.asyncio - async def test_exec_async_returns_execution( - self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock - ) -> None: - """Test exec_async returns AsyncExecution object.""" - execution_async = SimpleNamespace( - execution_id="exec_123", - devbox_id="dev_123", - status="running", - ) - - mock_async_client.devboxes.execute_async = AsyncMock(return_value=execution_async) - mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - execution = await devbox.cmd.exec_async("long-running command") - - assert execution.execution_id == "exec_123" - assert execution.devbox_id == "dev_123" - mock_async_client.devboxes.execute_async.assert_called_once() - - -class TestAsyncFileInterface: - """Tests for _AsyncFileInterface.""" - - @pytest.mark.asyncio - async def test_read(self, mock_async_client: AsyncMock) -> None: - """Test file read.""" - mock_async_client.devboxes.read_file_contents = AsyncMock(return_value="file content") - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.file.read("/path/to/file") - - assert result == "file content" - mock_async_client.devboxes.read_file_contents.assert_called_once() - - @pytest.mark.asyncio - async def test_write_string(self, mock_async_client: AsyncMock) -> None: - """Test file write with string.""" - execution_detail = SimpleNamespace() - mock_async_client.devboxes.write_file_contents = AsyncMock(return_value=execution_detail) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.file.write("/path/to/file", "content") - - assert result == execution_detail - mock_async_client.devboxes.write_file_contents.assert_called_once() - - @pytest.mark.asyncio - async def test_write_bytes(self, mock_async_client: AsyncMock) -> None: - """Test file write with bytes.""" - execution_detail = SimpleNamespace() - mock_async_client.devboxes.write_file_contents = AsyncMock(return_value=execution_detail) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.file.write("/path/to/file", b"content") - - assert result == execution_detail - mock_async_client.devboxes.write_file_contents.assert_called_once() - - @pytest.mark.asyncio - async def test_download(self, mock_async_client: AsyncMock) -> None: - """Test file download.""" - mock_response = AsyncMock() - mock_response.read = AsyncMock(return_value=b"file content") - mock_async_client.devboxes.download_file = AsyncMock(return_value=mock_response) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.file.download("/path/to/file") - - assert result == b"file content" - mock_async_client.devboxes.download_file.assert_called_once() - - @pytest.mark.asyncio - async def test_upload(self, mock_async_client: AsyncMock) -> None: - """Test file upload.""" - execution_detail = SimpleNamespace() - mock_async_client.devboxes.upload_file = AsyncMock(return_value=execution_detail) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - # Create a temporary file for upload - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - f.write("test content") - temp_path = Path(f.name) - - try: - result = await devbox.file.upload("/remote/path", temp_path) - finally: - temp_path.unlink() - - assert result == execution_detail - mock_async_client.devboxes.upload_file.assert_called_once() - - -class TestAsyncNetworkInterface: - """Tests for _AsyncNetworkInterface.""" - - @pytest.mark.asyncio - async def test_create_ssh_key(self, mock_async_client: AsyncMock) -> None: - """Test create SSH key.""" - ssh_key_response = SimpleNamespace(public_key="ssh-rsa ...") - mock_async_client.devboxes.create_ssh_key = AsyncMock(return_value=ssh_key_response) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.net.create_ssh_key( - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result == ssh_key_response - mock_async_client.devboxes.create_ssh_key.assert_called_once() - - @pytest.mark.asyncio - async def test_create_tunnel(self, mock_async_client: AsyncMock) -> None: - """Test create tunnel.""" - tunnel_view = SimpleNamespace(tunnel_id="tunnel_123") - mock_async_client.devboxes.create_tunnel = AsyncMock(return_value=tunnel_view) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.net.create_tunnel( - port=8080, - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result == tunnel_view - mock_async_client.devboxes.create_tunnel.assert_called_once() - - @pytest.mark.asyncio - async def test_remove_tunnel(self, mock_async_client: AsyncMock) -> None: - """Test remove tunnel.""" - # Return value not used - testing parameter passing only - mock_async_client.devboxes.remove_tunnel = AsyncMock(return_value=object()) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.net.remove_tunnel( - port=8080, - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result is not None - mock_async_client.devboxes.remove_tunnel.assert_called_once() - - -class TestAsyncDevboxStreaming: - """Tests for AsyncDevbox streaming methods.""" - - def test_start_streaming_no_callbacks(self, mock_async_client: AsyncMock) -> None: - """Test _start_streaming returns None when no callbacks.""" - devbox = AsyncDevbox(mock_async_client, "dev_123") - result = devbox._start_streaming("exec_123", stdout=None, stderr=None, output=None) - assert result is None - - @pytest.mark.asyncio - async def test_start_streaming_stdout_only( - self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock - ) -> None: - """Test _start_streaming with stdout callback only.""" - - # Create a proper async iterator - async def async_iter(): - yield SimpleNamespace(output="line 1") - yield SimpleNamespace(output="line 2") - - mock_async_stream.__aiter__ = Mock(return_value=async_iter()) - mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) - mock_async_stream.__aexit__ = AsyncMock(return_value=None) - - mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - stdout_calls: list[str] = [] - result = devbox._start_streaming("exec_123", stdout=stdout_calls.append, stderr=None, output=None) - - assert result is not None - assert isinstance(result, _AsyncStreamingGroup) - assert len(result._tasks) == 1 - # Give the task a moment to start - TASK_START_DELAY = 0.1 - await asyncio.sleep(TASK_START_DELAY) - mock_async_client.devboxes.executions.stream_stdout_updates.assert_called_once() - # Clean up tasks - for task in result._tasks: - task.cancel() - try: - await task - except (Exception, asyncio.CancelledError): - pass - - @pytest.mark.asyncio - async def test_start_streaming_stderr_only( - self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock - ) -> None: - """Test _start_streaming with stderr callback only.""" - - # Create a proper async iterator - async def async_iter(): - yield SimpleNamespace(output="line 1") - yield SimpleNamespace(output="line 2") - - mock_async_stream.__aiter__ = Mock(return_value=async_iter()) - mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) - mock_async_stream.__aexit__ = AsyncMock(return_value=None) - - mock_async_client.devboxes.executions.stream_stderr_updates = AsyncMock(return_value=mock_async_stream) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - stderr_calls: list[str] = [] - result = devbox._start_streaming("exec_123", stdout=None, stderr=stderr_calls.append, output=None) - - assert result is not None - assert isinstance(result, _AsyncStreamingGroup) - assert len(result._tasks) == 1 - # Give the task a moment to start - TASK_START_DELAY = 0.1 - await asyncio.sleep(TASK_START_DELAY) - mock_async_client.devboxes.executions.stream_stderr_updates.assert_called_once() - # Clean up tasks - for task in result._tasks: - task.cancel() - try: - await task - except (Exception, asyncio.CancelledError): - pass - - @pytest.mark.asyncio - async def test_start_streaming_output_only( - self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock - ) -> None: - """Test _start_streaming with output callback only.""" - - # Create a proper async iterator - async def async_iter(): - yield SimpleNamespace(output="line 1") - yield SimpleNamespace(output="line 2") - - mock_async_stream.__aiter__ = Mock(return_value=async_iter()) - mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) - mock_async_stream.__aexit__ = AsyncMock(return_value=None) - - mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) - mock_async_client.devboxes.executions.stream_stderr_updates = AsyncMock(return_value=mock_async_stream) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - output_calls: list[str] = [] - result = devbox._start_streaming("exec_123", stdout=None, stderr=None, output=output_calls.append) - - assert result is not None - assert isinstance(result, _AsyncStreamingGroup) - assert len(result._tasks) == 2 # Both stdout and stderr streams - # Give tasks a moment to start - TASK_START_DELAY = 0.1 - await asyncio.sleep(TASK_START_DELAY) - # Clean up tasks - for task in result._tasks: - task.cancel() - try: - await task - except (Exception, asyncio.CancelledError): - pass - - @pytest.mark.asyncio - async def test_stream_worker(self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock) -> None: - """Test _stream_worker processes chunks.""" - chunks = [ - SimpleNamespace(output="line 1"), - SimpleNamespace(output="line 2"), - ] - - async def async_iter() -> AsyncIterator: - for chunk in chunks: - yield chunk - - mock_async_stream.__aiter__ = Mock(return_value=async_iter()) - mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) - mock_async_stream.__aexit__ = AsyncMock(return_value=None) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - calls: list[str] = [] - - async def stream_factory() -> AsyncStream: - return mock_async_stream - - await devbox._stream_worker( - name="test", - stream_factory=stream_factory, - callbacks=[calls.append], - ) - - # Note: In a real scenario, calls would be populated, but with mocks - # we're mainly testing that the method doesn't raise - - @pytest.mark.asyncio - async def test_stream_worker_cancelled(self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock) -> None: - """Test _stream_worker handles cancellation.""" - LONG_SLEEP = 1.0 - - async def async_iter() -> AsyncIterator: - await asyncio.sleep(LONG_SLEEP) # Long-running - yield SimpleNamespace(output="line") - - mock_async_stream.__aiter__ = Mock(return_value=async_iter()) - mock_async_stream.__aenter__ = AsyncMock(return_value=mock_async_stream) - mock_async_stream.__aexit__ = AsyncMock(return_value=None) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - calls: list[str] = [] - - async def stream_factory() -> AsyncStream: - return mock_async_stream - - task = asyncio.create_task( - devbox._stream_worker( - name="test", - stream_factory=stream_factory, - callbacks=[calls.append], - ) - ) - - await asyncio.sleep(0.01) - task.cancel() - - with pytest.raises(asyncio.CancelledError): - await task - - -class TestAsyncDevboxErrorHandling: - """Tests for AsyncDevbox error handling scenarios.""" - - @pytest.mark.asyncio - async def test_async_network_error(self, mock_async_client: AsyncMock) -> None: - """Test handling of network errors in async.""" - mock_async_client.devboxes.retrieve = AsyncMock(side_effect=httpx.NetworkError("Connection failed")) - - devbox = AsyncDevbox(mock_async_client, "dev_123") - with pytest.raises(httpx.NetworkError): - await devbox.get_info() diff --git a/tests/sdk/test_async_execution.py b/tests/sdk/test_async_execution.py index 77cb74464..9453ebf60 100644 --- a/tests/sdk/test_async_execution.py +++ b/tests/sdk/test_async_execution.py @@ -4,15 +4,21 @@ import asyncio from types import SimpleNamespace +from typing import Any from unittest.mock import AsyncMock import pytest +from tests.sdk.conftest import ( + TASK_COMPLETION_LONG, + TASK_COMPLETION_SHORT, + MockExecutionView, +) from runloop_api_client.sdk.async_execution import AsyncExecution, _AsyncStreamingGroup -# Test constants -SHORT_SLEEP = 0.01 # Brief pause for task/thread startup -LONG_SLEEP = 1.0 # Simulates long-running operation for cancellation tests +# Legacy aliases for backward compatibility +SHORT_SLEEP = TASK_COMPLETION_SHORT +LONG_SLEEP = TASK_COMPLETION_LONG class TestAsyncStreamingGroup: @@ -82,16 +88,19 @@ async def task2() -> None: class TestAsyncExecution: """Tests for AsyncExecution class.""" - def test_init(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + def test_init(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: """Test AsyncExecution initialization.""" - execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert execution.execution_id == "exec_123" assert execution.devbox_id == "dev_123" assert execution._latest == execution_view @pytest.mark.asyncio async def test_init_with_streaming_group( - self, mock_async_client: AsyncMock, execution_view: SimpleNamespace + self, + mock_async_client: AsyncMock, + execution_view: MockExecutionView, + async_task_cleanup: list[asyncio.Task[Any]], ) -> None: """Test AsyncExecution initialization with streaming group.""" @@ -99,30 +108,25 @@ async def task() -> None: await asyncio.sleep(SHORT_SLEEP) tasks = [asyncio.create_task(task())] + # Register tasks for automatic cleanup + async_task_cleanup.extend(tasks) streaming_group = _AsyncStreamingGroup(tasks) - execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) + execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) # type: ignore[arg-type] assert execution._streaming_group is streaming_group - # Clean up tasks - for task in tasks: - task.cancel() - try: - await task - except (Exception, asyncio.CancelledError): - pass - - def test_properties(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + + def test_properties(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: """Test AsyncExecution properties.""" - execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert execution.execution_id == "exec_123" assert execution.devbox_id == "dev_123" @pytest.mark.asyncio async def test_result_already_completed( - self, mock_async_client: AsyncMock, execution_view: SimpleNamespace + self, mock_async_client: AsyncMock, execution_view: MockExecutionView ) -> None: """Test result when execution is already completed.""" - execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] result = await execution.result() assert result.exit_code == 0 @@ -150,7 +154,7 @@ async def test_result_needs_polling(self, mock_async_client: AsyncMock) -> None: mock_async_client.devboxes.executions.await_completed = AsyncMock(return_value=completed_execution) - execution = AsyncExecution(mock_async_client, "dev_123", running_execution) + execution = AsyncExecution(mock_async_client, "dev_123", running_execution) # type: ignore[arg-type] result = await execution.result() assert result.exit_code == 0 @@ -182,14 +186,14 @@ async def task() -> None: tasks = [asyncio.create_task(task())] streaming_group = _AsyncStreamingGroup(tasks) - execution = AsyncExecution(mock_async_client, "dev_123", running_execution, streaming_group) + execution = AsyncExecution(mock_async_client, "dev_123", running_execution, streaming_group) # type: ignore[arg-type] result = await execution.result() assert result.exit_code == 0 assert execution._streaming_group is None # Should be cleaned up @pytest.mark.asyncio - async def test_get_state(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + async def test_get_state(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: """Test get_state method.""" updated_execution = SimpleNamespace( execution_id="exec_123", @@ -198,7 +202,7 @@ async def test_get_state(self, mock_async_client: AsyncMock, execution_view: Sim ) mock_async_client.devboxes.executions.retrieve = AsyncMock(return_value=updated_execution) - execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] result = await execution.get_state() assert result == updated_execution @@ -206,11 +210,11 @@ async def test_get_state(self, mock_async_client: AsyncMock, execution_view: Sim mock_async_client.devboxes.executions.retrieve.assert_called_once() @pytest.mark.asyncio - async def test_kill(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + async def test_kill(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: """Test kill method.""" mock_async_client.devboxes.executions.kill = AsyncMock(return_value=None) - execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] await execution.kill() mock_async_client.devboxes.executions.kill.assert_called_once_with( @@ -220,11 +224,13 @@ async def test_kill(self, mock_async_client: AsyncMock, execution_view: SimpleNa ) @pytest.mark.asyncio - async def test_kill_with_process_group(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + async def test_kill_with_process_group( + self, mock_async_client: AsyncMock, execution_view: MockExecutionView + ) -> None: """Test kill with kill_process_group.""" mock_async_client.devboxes.executions.kill = AsyncMock(return_value=None) - execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] await execution.kill(kill_process_group=True) mock_async_client.devboxes.executions.kill.assert_called_once_with( @@ -235,7 +241,7 @@ async def test_kill_with_process_group(self, mock_async_client: AsyncMock, execu @pytest.mark.asyncio async def test_kill_with_streaming_cleanup( - self, mock_async_client: AsyncMock, execution_view: SimpleNamespace + self, mock_async_client: AsyncMock, execution_view: MockExecutionView ) -> None: """Test kill cleans up streaming.""" mock_async_client.devboxes.executions.kill = AsyncMock(return_value=None) @@ -246,7 +252,7 @@ async def task() -> None: tasks = [asyncio.create_task(task())] streaming_group = _AsyncStreamingGroup(tasks) - execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) + execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) # type: ignore[arg-type] await execution.kill() assert execution._streaming_group is None # Should be cleaned up @@ -254,15 +260,15 @@ async def task() -> None: @pytest.mark.asyncio async def test_settle_streaming_no_group( - self, mock_async_client: AsyncMock, execution_view: SimpleNamespace + self, mock_async_client: AsyncMock, execution_view: MockExecutionView ) -> None: """Test _settle_streaming when no streaming group.""" - execution = AsyncExecution(mock_async_client, "dev_123", execution_view) + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] await execution._settle_streaming(cancel=True) # Should not raise @pytest.mark.asyncio async def test_settle_streaming_with_group_cancel( - self, mock_async_client: AsyncMock, execution_view: SimpleNamespace + self, mock_async_client: AsyncMock, execution_view: MockExecutionView ) -> None: """Test _settle_streaming with streaming group and cancel.""" @@ -272,7 +278,7 @@ async def task() -> None: tasks = [asyncio.create_task(task())] streaming_group = _AsyncStreamingGroup(tasks) - execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) + execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) # type: ignore[arg-type] await execution._settle_streaming(cancel=True) assert execution._streaming_group is None @@ -280,7 +286,7 @@ async def task() -> None: @pytest.mark.asyncio async def test_settle_streaming_with_group_wait( - self, mock_async_client: AsyncMock, execution_view: SimpleNamespace + self, mock_async_client: AsyncMock, execution_view: MockExecutionView ) -> None: """Test _settle_streaming with streaming group and wait.""" @@ -290,7 +296,7 @@ async def task() -> None: tasks = [asyncio.create_task(task())] streaming_group = _AsyncStreamingGroup(tasks) - execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) + execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) # type: ignore[arg-type] await execution._settle_streaming(cancel=False) assert execution._streaming_group is None diff --git a/tests/sdk/test_async_execution_result.py b/tests/sdk/test_async_execution_result.py index 0f8991fa2..0cdc5256f 100644 --- a/tests/sdk/test_async_execution_result.py +++ b/tests/sdk/test_async_execution_result.py @@ -7,32 +7,33 @@ import pytest +from tests.sdk.conftest import MockExecutionView from runloop_api_client.sdk.async_execution_result import AsyncExecutionResult class TestAsyncExecutionResult: """Tests for AsyncExecutionResult class.""" - def test_init(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + def test_init(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: """Test AsyncExecutionResult initialization.""" - result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] # Verify via public API assert result.devbox_id == "dev_123" assert result.execution_id == "exec_123" - def test_devbox_id_property(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + def test_devbox_id_property(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: """Test devbox_id property.""" - result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.devbox_id == "dev_123" - def test_execution_id_property(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + def test_execution_id_property(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: """Test execution_id property.""" - result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.execution_id == "exec_123" - def test_exit_code_property(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + def test_exit_code_property(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: """Test exit_code property.""" - result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.exit_code == 0 def test_exit_code_none(self, mock_async_client: AsyncMock) -> None: @@ -45,12 +46,12 @@ def test_exit_code_none(self, mock_async_client: AsyncMock) -> None: stdout="", stderr="", ) - result = AsyncExecutionResult(mock_async_client, "dev_123", execution) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] assert result.exit_code is None - def test_success_property(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + def test_success_property(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: """Test success property.""" - result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.success is True def test_success_false(self, mock_async_client: AsyncMock) -> None: @@ -63,12 +64,12 @@ def test_success_false(self, mock_async_client: AsyncMock) -> None: stdout="", stderr="error", ) - result = AsyncExecutionResult(mock_async_client, "dev_123", execution) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] assert result.success is False - def test_failed_property(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + def test_failed_property(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: """Test failed property when exit code is zero.""" - result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.failed is False def test_failed_true(self, mock_async_client: AsyncMock) -> None: @@ -81,7 +82,7 @@ def test_failed_true(self, mock_async_client: AsyncMock) -> None: stdout="", stderr="error", ) - result = AsyncExecutionResult(mock_async_client, "dev_123", execution) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] assert result.failed is True def test_failed_none(self, mock_async_client: AsyncMock) -> None: @@ -94,13 +95,13 @@ def test_failed_none(self, mock_async_client: AsyncMock) -> None: stdout="", stderr="", ) - result = AsyncExecutionResult(mock_async_client, "dev_123", execution) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] assert result.failed is False @pytest.mark.asyncio - async def test_stdout(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + async def test_stdout(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: """Test stdout method.""" - result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert await result.stdout() == "output" @pytest.mark.asyncio @@ -114,7 +115,7 @@ async def test_stdout_empty(self, mock_async_client: AsyncMock) -> None: stdout=None, stderr="", ) - result = AsyncExecutionResult(mock_async_client, "dev_123", execution) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] assert await result.stdout() == "" @pytest.mark.asyncio @@ -128,16 +129,16 @@ async def test_stderr(self, mock_async_client: AsyncMock) -> None: stdout="", stderr="error message", ) - result = AsyncExecutionResult(mock_async_client, "dev_123", execution) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] assert await result.stderr() == "error message" @pytest.mark.asyncio - async def test_stderr_empty(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + async def test_stderr_empty(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: """Test stderr method when stderr is None.""" - result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert await result.stderr() == "" - def test_raw_property(self, mock_async_client: AsyncMock, execution_view: SimpleNamespace) -> None: + def test_raw_property(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: """Test raw property.""" - result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.raw == execution_view diff --git a/tests/sdk/test_async_snapshot.py b/tests/sdk/test_async_snapshot.py index cdb3c0e30..7bca2ad95 100644 --- a/tests/sdk/test_async_snapshot.py +++ b/tests/sdk/test_async_snapshot.py @@ -7,6 +7,7 @@ import pytest +from tests.sdk.conftest import MockDevboxView, MockSnapshotView from runloop_api_client.sdk import AsyncSnapshot from runloop_api_client.lib.polling import PollingConfig @@ -25,7 +26,7 @@ def test_repr(self, mock_async_client: AsyncMock) -> None: assert repr(snapshot) == "" @pytest.mark.asyncio - async def test_get_info(self, mock_async_client: AsyncMock, snapshot_view: SimpleNamespace) -> None: + async def test_get_info(self, mock_async_client: AsyncMock, snapshot_view: MockSnapshotView) -> None: """Test get_info method.""" mock_async_client.devboxes.disk_snapshots.query_status = AsyncMock(return_value=snapshot_view) @@ -64,7 +65,6 @@ async def test_update(self, mock_async_client: AsyncMock) -> None: @pytest.mark.asyncio async def test_delete(self, mock_async_client: AsyncMock) -> None: """Test delete method.""" - # Return value not used - testing side effect only mock_async_client.devboxes.disk_snapshots.delete = AsyncMock(return_value=object()) snapshot = AsyncSnapshot(mock_async_client, "snap_123") @@ -76,11 +76,11 @@ async def test_delete(self, mock_async_client: AsyncMock) -> None: idempotency_key="key-123", ) - assert result is not None + assert result is not None # Verify return value is propagated mock_async_client.devboxes.disk_snapshots.delete.assert_called_once() @pytest.mark.asyncio - async def test_await_completed(self, mock_async_client: AsyncMock, snapshot_view: SimpleNamespace) -> None: + async def test_await_completed(self, mock_async_client: AsyncMock, snapshot_view: MockSnapshotView) -> None: """Test await_completed method.""" mock_async_client.devboxes.disk_snapshots.await_completed = AsyncMock(return_value=snapshot_view) polling_config = PollingConfig(timeout_seconds=60.0) @@ -98,7 +98,7 @@ async def test_await_completed(self, mock_async_client: AsyncMock, snapshot_view mock_async_client.devboxes.disk_snapshots.await_completed.assert_called_once() @pytest.mark.asyncio - async def test_create_devbox(self, mock_async_client: AsyncMock, devbox_view: SimpleNamespace) -> None: + async def test_create_devbox(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: """Test create_devbox method.""" mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) diff --git a/tests/sdk/test_async_storage_object.py b/tests/sdk/test_async_storage_object.py index e7219ab18..8c7164a94 100644 --- a/tests/sdk/test_async_storage_object.py +++ b/tests/sdk/test_async_storage_object.py @@ -2,14 +2,13 @@ from __future__ import annotations -import tempfile from types import SimpleNamespace from pathlib import Path from unittest.mock import Mock, AsyncMock, patch import pytest -from tests.sdk.conftest import create_mock_httpx_client, create_mock_httpx_response +from tests.sdk.conftest import MockObjectView, create_mock_httpx_client, create_mock_httpx_response from runloop_api_client.sdk import AsyncStorageObject @@ -34,7 +33,7 @@ def test_repr(self, mock_async_client: AsyncMock) -> None: assert repr(obj) == "" @pytest.mark.asyncio - async def test_refresh(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + async def test_refresh(self, mock_async_client: AsyncMock, object_view: MockObjectView) -> None: """Test refresh method.""" mock_async_client.objects.retrieve = AsyncMock(return_value=object_view) @@ -172,7 +171,7 @@ async def test_download_as_text_custom_encoding( mock_http_client.get.assert_called_once() @pytest.mark.asyncio - async def test_delete(self, mock_async_client: AsyncMock, object_view: SimpleNamespace) -> None: + async def test_delete(self, mock_async_client: AsyncMock, object_view: MockObjectView) -> None: """Test delete method.""" mock_async_client.objects.delete = AsyncMock(return_value=object_view) @@ -224,28 +223,26 @@ async def test_upload_content_bytes(self, mock_client_class: Mock, mock_async_cl @pytest.mark.asyncio @patch("httpx.AsyncClient") - async def test_upload_content_path(self, mock_client_class: Mock, mock_async_client: AsyncMock) -> None: + async def test_upload_content_path( + self, mock_client_class: Mock, mock_async_client: AsyncMock, tmp_path: Path + ) -> None: """Test upload_content with Path.""" mock_response = create_mock_httpx_response() mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) mock_client_class.return_value = mock_http_client - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - f.write("test content") - temp_path = Path(f.name) - - try: - obj = AsyncStorageObject(mock_async_client, "obj_123", "https://upload.example.com") - await obj.upload_content(temp_path) - - # Verify put was called - mock_http_client.put.assert_called_once() - call_args = mock_http_client.put.call_args - assert call_args[0][0] == "https://upload.example.com" - assert call_args[1]["content"] == b"test content" - mock_response.raise_for_status.assert_called_once() - finally: - temp_path.unlink() + temp_file = tmp_path / "test_file.txt" + temp_file.write_text("test content") + + obj = AsyncStorageObject(mock_async_client, "obj_123", "https://upload.example.com") + await obj.upload_content(temp_file) + + # Verify put was called + mock_http_client.put.assert_called_once() + call_args = mock_http_client.put.call_args + assert call_args[0][0] == "https://upload.example.com" + assert call_args[1]["content"] == b"test content" + mock_response.raise_for_status.assert_called_once() @pytest.mark.asyncio async def test_upload_content_no_url(self, mock_async_client: AsyncMock) -> None: diff --git a/tests/sdk/test_blueprint.py b/tests/sdk/test_blueprint.py index 5dcdbc2b4..2c6bc6580 100644 --- a/tests/sdk/test_blueprint.py +++ b/tests/sdk/test_blueprint.py @@ -5,6 +5,7 @@ from types import SimpleNamespace from unittest.mock import Mock +from tests.sdk.conftest import MockDevboxView, MockBlueprintView from runloop_api_client.sdk import Blueprint @@ -21,7 +22,7 @@ def test_repr(self, mock_client: Mock) -> None: blueprint = Blueprint(mock_client, "bp_123") assert repr(blueprint) == "" - def test_get_info(self, mock_client: Mock, blueprint_view: SimpleNamespace) -> None: + def test_get_info(self, mock_client: Mock, blueprint_view: MockBlueprintView) -> None: """Test get_info method.""" mock_client.blueprints.retrieve.return_value = blueprint_view @@ -66,7 +67,6 @@ def test_logs(self, mock_client: Mock) -> None: def test_delete(self, mock_client: Mock) -> None: """Test delete method.""" - # Return value not used - testing side effect only mock_client.blueprints.delete.return_value = object() blueprint = Blueprint(mock_client, "bp_123") @@ -77,7 +77,7 @@ def test_delete(self, mock_client: Mock) -> None: timeout=30.0, ) - assert result is not None + assert result is not None # Verify return value is propagated mock_client.blueprints.delete.assert_called_once_with( "bp_123", extra_headers={"X-Custom": "value"}, @@ -86,7 +86,7 @@ def test_delete(self, mock_client: Mock) -> None: timeout=30.0, ) - def test_create_devbox(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + def test_create_devbox(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: """Test create_devbox method.""" mock_client.devboxes.create_and_await_running.return_value = devbox_view diff --git a/tests/sdk/test_clients.py b/tests/sdk/test_clients.py index 08c8b511c..84a0bdf85 100644 --- a/tests/sdk/test_clients.py +++ b/tests/sdk/test_clients.py @@ -2,12 +2,17 @@ from __future__ import annotations -import tempfile from types import SimpleNamespace from pathlib import Path from unittest.mock import Mock, patch -from tests.sdk.conftest import create_mock_httpx_response +from tests.sdk.conftest import ( + MockDevboxView, + MockObjectView, + MockSnapshotView, + MockBlueprintView, + create_mock_httpx_response, +) from runloop_api_client.sdk import Devbox, Snapshot, Blueprint, StorageObject from runloop_api_client.sdk._sync import ( RunloopSDK, @@ -22,7 +27,7 @@ class TestDevboxClient: """Tests for DevboxClient class.""" - def test_create(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + def test_create(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: """Test create method.""" mock_client.devboxes.create_and_await_running.return_value = devbox_view @@ -37,7 +42,7 @@ def test_create(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: assert devbox.id == "dev_123" mock_client.devboxes.create_and_await_running.assert_called_once() - def test_create_from_blueprint_id(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + def test_create_from_blueprint_id(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: """Test create_from_blueprint_id method.""" mock_client.devboxes.create_and_await_running.return_value = devbox_view @@ -53,7 +58,7 @@ def test_create_from_blueprint_id(self, mock_client: Mock, devbox_view: SimpleNa call_kwargs = mock_client.devboxes.create_and_await_running.call_args[1] assert call_kwargs["blueprint_id"] == "bp_123" - def test_create_from_blueprint_name(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + def test_create_from_blueprint_name(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: """Test create_from_blueprint_name method.""" mock_client.devboxes.create_and_await_running.return_value = devbox_view @@ -67,7 +72,7 @@ def test_create_from_blueprint_name(self, mock_client: Mock, devbox_view: Simple call_kwargs = mock_client.devboxes.create_and_await_running.call_args[1] assert call_kwargs["blueprint_name"] == "my-blueprint" - def test_create_from_snapshot(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + def test_create_from_snapshot(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: """Test create_from_snapshot method.""" mock_client.devboxes.create_and_await_running.return_value = devbox_view @@ -81,7 +86,7 @@ def test_create_from_snapshot(self, mock_client: Mock, devbox_view: SimpleNamesp call_kwargs = mock_client.devboxes.create_and_await_running.call_args[1] assert call_kwargs["snapshot_id"] == "snap_123" - def test_from_id(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + def test_from_id(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: """Test from_id method waits for running.""" mock_client.devboxes.await_running.return_value = devbox_view @@ -92,7 +97,7 @@ def test_from_id(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: assert devbox.id == "dev_123" mock_client.devboxes.await_running.assert_called_once_with("dev_123") - def test_list(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + def test_list(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: """Test list method.""" page = SimpleNamespace(devboxes=[devbox_view]) mock_client.devboxes.list.return_value = page @@ -113,7 +118,7 @@ def test_list(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: class TestSnapshotClient: """Tests for SnapshotClient class.""" - def test_list(self, mock_client: Mock, snapshot_view: SimpleNamespace) -> None: + def test_list(self, mock_client: Mock, snapshot_view: MockSnapshotView) -> None: """Test list method.""" page = SimpleNamespace(disk_snapshots=[snapshot_view]) mock_client.devboxes.disk_snapshots.list.return_value = page @@ -142,7 +147,7 @@ def test_from_id(self, mock_client: Mock) -> None: class TestBlueprintClient: """Tests for BlueprintClient class.""" - def test_create(self, mock_client: Mock, blueprint_view: SimpleNamespace) -> None: + def test_create(self, mock_client: Mock, blueprint_view: MockBlueprintView) -> None: """Test create method.""" mock_client.blueprints.create_and_await_build_complete.return_value = blueprint_view @@ -164,7 +169,7 @@ def test_from_id(self, mock_client: Mock) -> None: assert isinstance(blueprint, Blueprint) assert blueprint.id == "bp_123" - def test_list(self, mock_client: Mock, blueprint_view: SimpleNamespace) -> None: + def test_list(self, mock_client: Mock, blueprint_view: MockBlueprintView) -> None: """Test list method.""" page = SimpleNamespace(blueprints=[blueprint_view]) mock_client.blueprints.list.return_value = page @@ -185,7 +190,7 @@ def test_list(self, mock_client: Mock, blueprint_view: SimpleNamespace) -> None: class TestStorageObjectClient: """Tests for StorageObjectClient class.""" - def test_create(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + def test_create(self, mock_client: Mock, object_view: MockObjectView) -> None: """Test create method.""" mock_client.objects.create.return_value = object_view @@ -197,7 +202,7 @@ def test_create(self, mock_client: Mock, object_view: SimpleNamespace) -> None: assert obj.upload_url == "https://upload.example.com/obj_123" mock_client.objects.create.assert_called_once() - def test_create_auto_detect_content_type(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + def test_create_auto_detect_content_type(self, mock_client: Mock, object_view: MockObjectView) -> None: """Test create auto-detects content type.""" mock_client.objects.create.return_value = object_view @@ -218,7 +223,7 @@ def test_from_id(self, mock_client: Mock) -> None: assert obj.id == "obj_123" assert obj.upload_url is None - def test_list(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + def test_list(self, mock_client: Mock, object_view: MockObjectView) -> None: """Test list method.""" page = SimpleNamespace(objects=[object_view]) mock_client.objects.list.return_value = page @@ -238,31 +243,27 @@ def test_list(self, mock_client: Mock, object_view: SimpleNamespace) -> None: assert objects[0].id == "obj_123" mock_client.objects.list.assert_called_once() - def test_upload_from_file(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + def test_upload_from_file(self, mock_client: Mock, object_view: MockObjectView, tmp_path: Path) -> None: """Test upload_from_file method.""" mock_client.objects.create.return_value = object_view - with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: - f.write("test content") - temp_path = Path(f.name) + temp_file = tmp_path / "test_file.txt" + temp_file.write_text("test content") - try: - with patch("httpx.put") as mock_put: - mock_response = create_mock_httpx_response() - mock_put.return_value = mock_response + with patch("httpx.put") as mock_put: + mock_response = create_mock_httpx_response() + mock_put.return_value = mock_response - client = StorageObjectClient(mock_client) - obj = client.upload_from_file(temp_path, name="test.txt") + client = StorageObjectClient(mock_client) + obj = client.upload_from_file(temp_file, name="test.txt") - assert isinstance(obj, StorageObject) - assert obj.id == "obj_123" - mock_client.objects.create.assert_called_once() - mock_client.objects.complete.assert_called_once() - mock_put.assert_called_once() - finally: - temp_path.unlink() + assert isinstance(obj, StorageObject) + assert obj.id == "obj_123" + mock_client.objects.create.assert_called_once() + mock_client.objects.complete.assert_called_once() + mock_put.assert_called_once() - def test_upload_from_text(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + def test_upload_from_text(self, mock_client: Mock, object_view: MockObjectView) -> None: """Test upload_from_text method.""" mock_client.objects.create.return_value = object_view @@ -281,7 +282,7 @@ def test_upload_from_text(self, mock_client: Mock, object_view: SimpleNamespace) assert call_kwargs["metadata"] == {"key": "value"} mock_client.objects.complete.assert_called_once() - def test_upload_from_bytes(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + def test_upload_from_bytes(self, mock_client: Mock, object_view: MockObjectView) -> None: """Test upload_from_bytes method.""" mock_client.objects.create.return_value = object_view diff --git a/tests/sdk/test_devbox.py b/tests/sdk/test_devbox.py deleted file mode 100644 index c960f5172..000000000 --- a/tests/sdk/test_devbox.py +++ /dev/null @@ -1,878 +0,0 @@ -"""Comprehensive tests for sync Devbox class.""" - -from __future__ import annotations - -import time -import tempfile -import threading -from types import SimpleNamespace -from pathlib import Path -from unittest.mock import Mock, patch - -import httpx -import pytest - -from tests.sdk.conftest import create_mock_httpx_response -from runloop_api_client.sdk import Devbox, StorageObject -from runloop_api_client._types import NotGiven, omit -from runloop_api_client._streaming import Stream -from runloop_api_client.sdk.devbox import ( - _FileInterface, - _CommandInterface, - _NetworkInterface, -) -from runloop_api_client._exceptions import APIStatusError -from runloop_api_client.lib.polling import PollingConfig -from runloop_api_client.sdk.execution import _StreamingGroup - -# Test constants -SHORT_SLEEP = 0.1 # Brief pause for thread operations -NUM_CONCURRENT_THREADS = 5 # Number of threads for concurrent operation tests - - -class TestDevbox: - """Tests for Devbox class.""" - - def test_init(self, mock_client: Mock) -> None: - """Test Devbox initialization.""" - devbox = Devbox(mock_client, "dev_123") - assert devbox.id == "dev_123" - - def test_repr(self, mock_client: Mock) -> None: - """Test Devbox string representation.""" - devbox = Devbox(mock_client, "dev_123") - assert repr(devbox) == "" - - def test_context_manager_enter_exit(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: - """Test context manager behavior with successful shutdown.""" - mock_client.devboxes.shutdown.return_value = devbox_view - - with Devbox(mock_client, "dev_123") as devbox: - assert devbox.id == "dev_123" - - call_kwargs = mock_client.devboxes.shutdown.call_args[1] - assert isinstance(call_kwargs["timeout"], NotGiven) - - def test_context_manager_exception_handling(self, mock_client: Mock) -> None: - """Test context manager handles exceptions during shutdown.""" - mock_client.devboxes.shutdown.side_effect = RuntimeError("Shutdown failed") - - with pytest.raises(ValueError, match="Test error"): - with Devbox(mock_client, "dev_123") as devbox: - raise ValueError("Test error") - - # Shutdown should be called even when body raises exception - mock_client.devboxes.shutdown.assert_called_once() - - def test_get_info(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: - """Test get_info method.""" - mock_client.devboxes.retrieve.return_value = devbox_view - - devbox = Devbox(mock_client, "dev_123") - result = devbox.get_info( - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - ) - - assert result == devbox_view - mock_client.devboxes.retrieve.assert_called_once_with( - "dev_123", - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - ) - - def test_await_running(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: - """Test await_running method.""" - mock_client.devboxes.await_running.return_value = devbox_view - polling_config = PollingConfig(timeout_seconds=60.0) - - devbox = Devbox(mock_client, "dev_123") - result = devbox.await_running(polling_config=polling_config) - - assert result == devbox_view - mock_client.devboxes.await_running.assert_called_once_with( - "dev_123", - polling_config=polling_config, - ) - - def test_await_suspended(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: - """Test await_suspended method.""" - mock_client.devboxes.await_suspended.return_value = devbox_view - polling_config = PollingConfig(timeout_seconds=60.0) - - devbox = Devbox(mock_client, "dev_123") - result = devbox.await_suspended(polling_config=polling_config) - - assert result == devbox_view - mock_client.devboxes.await_suspended.assert_called_once_with( - "dev_123", - polling_config=polling_config, - ) - - def test_shutdown(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: - """Test shutdown method.""" - mock_client.devboxes.shutdown.return_value = devbox_view - - devbox = Devbox(mock_client, "dev_123") - result = devbox.shutdown( - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result == devbox_view - mock_client.devboxes.shutdown.assert_called_once_with( - "dev_123", - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - def test_suspend(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: - """Test suspend method.""" - mock_client.devboxes.suspend.return_value = None - mock_client.devboxes.await_suspended.return_value = devbox_view - polling_config = PollingConfig(timeout_seconds=60.0) - - devbox = Devbox(mock_client, "dev_123") - result = devbox.suspend( - polling_config=polling_config, - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result == devbox_view - mock_client.devboxes.suspend.assert_called_once_with( - "dev_123", - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - mock_client.devboxes.await_suspended.assert_called_once_with( - "dev_123", - polling_config=polling_config, - ) - - def test_resume(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: - """Test resume method.""" - mock_client.devboxes.resume.return_value = None - mock_client.devboxes.await_running.return_value = devbox_view - polling_config = PollingConfig(timeout_seconds=60.0) - - devbox = Devbox(mock_client, "dev_123") - result = devbox.resume( - polling_config=polling_config, - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result == devbox_view - mock_client.devboxes.resume.assert_called_once_with( - "dev_123", - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - mock_client.devboxes.await_running.assert_called_once_with( - "dev_123", - polling_config=polling_config, - ) - - def test_keep_alive(self, mock_client: Mock) -> None: - """Test keep_alive method.""" - # Return value not used - testing parameter passing only - mock_client.devboxes.keep_alive.return_value = object() - - devbox = Devbox(mock_client, "dev_123") - result = devbox.keep_alive( - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result is not None - mock_client.devboxes.keep_alive.assert_called_once_with( - "dev_123", - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - def test_snapshot_disk(self, mock_client: Mock) -> None: - """Test snapshot_disk waits for completion.""" - snapshot_data = SimpleNamespace(id="snap_123") - snapshot_status = SimpleNamespace(status="completed") - - mock_client.devboxes.snapshot_disk_async.return_value = snapshot_data - mock_client.devboxes.disk_snapshots.await_completed.return_value = snapshot_status - - devbox = Devbox(mock_client, "dev_123") - polling_config = PollingConfig(timeout_seconds=60.0) - snapshot = devbox.snapshot_disk( - name="test-snapshot", - metadata={"key": "value"}, - polling_config=polling_config, - extra_headers={"X-Custom": "value"}, - ) - - assert snapshot.id == "snap_123" - call_kwargs = mock_client.devboxes.snapshot_disk_async.call_args[1] - assert call_kwargs["commit_message"] is omit or call_kwargs["commit_message"] is None - assert call_kwargs["metadata"] == {"key": "value"} - assert call_kwargs["name"] == "test-snapshot" - assert call_kwargs["extra_headers"] == {"X-Custom": "value"} - assert isinstance(call_kwargs["timeout"], NotGiven) - call_kwargs2 = mock_client.devboxes.disk_snapshots.await_completed.call_args[1] - assert call_kwargs2["polling_config"] == polling_config - assert isinstance(call_kwargs2["timeout"], NotGiven) - - def test_snapshot_disk_async(self, mock_client: Mock) -> None: - """Test snapshot_disk_async returns immediately.""" - snapshot_data = SimpleNamespace(id="snap_123") - mock_client.devboxes.snapshot_disk_async.return_value = snapshot_data - - devbox = Devbox(mock_client, "dev_123") - snapshot = devbox.snapshot_disk_async( - name="test-snapshot", - metadata={"key": "value"}, - extra_headers={"X-Custom": "value"}, - ) - - assert snapshot.id == "snap_123" - call_kwargs = mock_client.devboxes.snapshot_disk_async.call_args[1] - assert call_kwargs["commit_message"] is omit or call_kwargs["commit_message"] is None - assert call_kwargs["metadata"] == {"key": "value"} - assert call_kwargs["name"] == "test-snapshot" - assert call_kwargs["extra_headers"] == {"X-Custom": "value"} - assert isinstance(call_kwargs["timeout"], NotGiven) - # Verify async method does not wait for completion - if hasattr(mock_client.devboxes.disk_snapshots, "await_completed"): - assert not mock_client.devboxes.disk_snapshots.await_completed.called - - def test_close(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: - """Test close method calls shutdown.""" - mock_client.devboxes.shutdown.return_value = devbox_view - - devbox = Devbox(mock_client, "dev_123") - devbox.close() - - call_kwargs = mock_client.devboxes.shutdown.call_args[1] - assert isinstance(call_kwargs["timeout"], NotGiven) - - def test_cmd_property(self, mock_client: Mock) -> None: - """Test cmd property returns CommandInterface.""" - devbox = Devbox(mock_client, "dev_123") - cmd = devbox.cmd - assert isinstance(cmd, _CommandInterface) - assert cmd._devbox is devbox - - def test_file_property(self, mock_client: Mock) -> None: - """Test file property returns FileInterface.""" - devbox = Devbox(mock_client, "dev_123") - file_interface = devbox.file - assert isinstance(file_interface, _FileInterface) - assert file_interface._devbox is devbox - - def test_net_property(self, mock_client: Mock) -> None: - """Test net property returns NetworkInterface.""" - devbox = Devbox(mock_client, "dev_123") - net = devbox.net - assert isinstance(net, _NetworkInterface) - assert net._devbox is devbox - - -class TestCommandInterface: - """Tests for _CommandInterface.""" - - def test_exec_without_callbacks(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: - """Test exec without streaming callbacks.""" - mock_client.devboxes.execute_and_await_completion.return_value = execution_view - - devbox = Devbox(mock_client, "dev_123") - result = devbox.cmd.exec("echo hello") - - assert result.exit_code == 0 - assert result.stdout() == "output" - call_kwargs = mock_client.devboxes.execute_and_await_completion.call_args[1] - assert call_kwargs["command"] == "echo hello" - assert isinstance(call_kwargs["shell_name"], NotGiven) or call_kwargs["shell_name"] is None - assert call_kwargs["polling_config"] is None - assert isinstance(call_kwargs["timeout"], NotGiven) - - def test_exec_with_stdout_callback(self, mock_client: Mock, mock_stream: Mock) -> None: - """Test exec with stdout callback.""" - execution_async = SimpleNamespace( - execution_id="exec_123", - devbox_id="dev_123", - status="running", - ) - execution_completed = SimpleNamespace( - execution_id="exec_123", - devbox_id="dev_123", - status="completed", - exit_status=0, - stdout="output", - stderr="", - ) - - mock_client.devboxes.execute_async.return_value = execution_async - mock_client.devboxes.executions.await_completed.return_value = execution_completed - mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream - - stdout_calls: list[str] = [] - - devbox = Devbox(mock_client, "dev_123") - result = devbox.cmd.exec("echo hello", stdout=stdout_calls.append) - - assert result.exit_code == 0 - mock_client.devboxes.execute_async.assert_called_once() - mock_client.devboxes.executions.await_completed.assert_called_once() - - def test_exec_with_stderr_callback(self, mock_client: Mock, mock_stream: Mock) -> None: - """Test exec with stderr callback.""" - execution_async = SimpleNamespace( - execution_id="exec_123", - devbox_id="dev_123", - status="running", - ) - execution_completed = SimpleNamespace( - execution_id="exec_123", - devbox_id="dev_123", - status="completed", - exit_status=0, - stdout="", - stderr="error", - ) - - mock_client.devboxes.execute_async.return_value = execution_async - mock_client.devboxes.executions.await_completed.return_value = execution_completed - mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream - - stderr_calls: list[str] = [] - - devbox = Devbox(mock_client, "dev_123") - result = devbox.cmd.exec("echo hello", stderr=stderr_calls.append) - - assert result.exit_code == 0 - mock_client.devboxes.execute_async.assert_called_once() - - def test_exec_with_output_callback(self, mock_client: Mock, mock_stream: Mock) -> None: - """Test exec with output callback.""" - execution_async = SimpleNamespace( - execution_id="exec_123", - devbox_id="dev_123", - status="running", - ) - execution_completed = SimpleNamespace( - execution_id="exec_123", - devbox_id="dev_123", - status="completed", - exit_status=0, - stdout="output", - stderr="", - ) - - mock_client.devboxes.execute_async.return_value = execution_async - mock_client.devboxes.executions.await_completed.return_value = execution_completed - mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream - mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream - - output_calls: list[str] = [] - - devbox = Devbox(mock_client, "dev_123") - result = devbox.cmd.exec("echo hello", output=output_calls.append) - - assert result.exit_code == 0 - mock_client.devboxes.execute_async.assert_called_once() - - def test_exec_with_all_callbacks(self, mock_client: Mock, mock_stream: Mock) -> None: - """Test exec with all callbacks.""" - execution_async = SimpleNamespace( - execution_id="exec_123", - devbox_id="dev_123", - status="running", - ) - execution_completed = SimpleNamespace( - execution_id="exec_123", - devbox_id="dev_123", - status="completed", - exit_status=0, - stdout="output", - stderr="error", - ) - - mock_client.devboxes.execute_async.return_value = execution_async - mock_client.devboxes.executions.await_completed.return_value = execution_completed - mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream - mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream - - stdout_calls: list[str] = [] - stderr_calls: list[str] = [] - output_calls: list[str] = [] - - devbox = Devbox(mock_client, "dev_123") - result = devbox.cmd.exec( - "echo hello", - stdout=stdout_calls.append, - stderr=stderr_calls.append, - output=output_calls.append, - ) - - assert result.exit_code == 0 - mock_client.devboxes.execute_async.assert_called_once() - - def test_exec_async_returns_execution(self, mock_client: Mock, mock_stream: Mock) -> None: - """Test exec_async returns Execution object.""" - execution_async = SimpleNamespace( - execution_id="exec_123", - devbox_id="dev_123", - status="running", - ) - - mock_client.devboxes.execute_async.return_value = execution_async - mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream - - devbox = Devbox(mock_client, "dev_123") - execution = devbox.cmd.exec_async("long-running command") - - assert execution.execution_id == "exec_123" - assert execution.devbox_id == "dev_123" - mock_client.devboxes.execute_async.assert_called_once() - - -class TestFileInterface: - """Tests for _FileInterface.""" - - def test_read(self, mock_client: Mock) -> None: - """Test file read.""" - mock_client.devboxes.read_file_contents.return_value = "file content" - - devbox = Devbox(mock_client, "dev_123") - result = devbox.file.read("/path/to/file") - - assert result == "file content" - call_kwargs = mock_client.devboxes.read_file_contents.call_args[1] - assert call_kwargs["file_path"] == "/path/to/file" - assert isinstance(call_kwargs["timeout"], NotGiven) - - def test_write_string(self, mock_client: Mock) -> None: - """Test file write with string.""" - execution_detail = SimpleNamespace() - mock_client.devboxes.write_file_contents.return_value = execution_detail - - devbox = Devbox(mock_client, "dev_123") - result = devbox.file.write("/path/to/file", "content") - - assert result == execution_detail - call_kwargs = mock_client.devboxes.write_file_contents.call_args[1] - assert call_kwargs["file_path"] == "/path/to/file" - assert call_kwargs["contents"] == "content" - assert isinstance(call_kwargs["timeout"], NotGiven) - - def test_write_bytes(self, mock_client: Mock) -> None: - """Test file write with bytes.""" - execution_detail = SimpleNamespace() - mock_client.devboxes.write_file_contents.return_value = execution_detail - - devbox = Devbox(mock_client, "dev_123") - result = devbox.file.write("/path/to/file", b"content") - - assert result == execution_detail - call_kwargs = mock_client.devboxes.write_file_contents.call_args[1] - assert call_kwargs["file_path"] == "/path/to/file" - assert call_kwargs["contents"] == "content" - assert isinstance(call_kwargs["timeout"], NotGiven) - - def test_download(self, mock_client: Mock) -> None: - """Test file download.""" - mock_response = Mock() - mock_response.read.return_value = b"file content" - mock_client.devboxes.download_file.return_value = mock_response - - devbox = Devbox(mock_client, "dev_123") - result = devbox.file.download("/path/to/file") - - assert result == b"file content" - call_kwargs = mock_client.devboxes.download_file.call_args[1] - assert call_kwargs["path"] == "/path/to/file" - assert isinstance(call_kwargs["timeout"], NotGiven) - - def test_upload(self, mock_client: Mock) -> None: - """Test file upload.""" - execution_detail = SimpleNamespace() - mock_client.devboxes.upload_file.return_value = execution_detail - - devbox = Devbox(mock_client, "dev_123") - # Create a temporary file for upload - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - f.write("test content") - temp_path = Path(f.name) - - try: - result = devbox.file.upload("/remote/path", temp_path) - finally: - temp_path.unlink() - - assert result == execution_detail - call_kwargs = mock_client.devboxes.upload_file.call_args[1] - assert call_kwargs["path"] == "/remote/path" - assert call_kwargs["file"] is not None # File object from temp_path - assert isinstance(call_kwargs["timeout"], NotGiven) - - -class TestNetworkInterface: - """Tests for _NetworkInterface.""" - - def test_create_ssh_key(self, mock_client: Mock) -> None: - """Test create SSH key.""" - ssh_key_response = SimpleNamespace(public_key="ssh-rsa ...") - mock_client.devboxes.create_ssh_key.return_value = ssh_key_response - - devbox = Devbox(mock_client, "dev_123") - result = devbox.net.create_ssh_key( - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result == ssh_key_response - mock_client.devboxes.create_ssh_key.assert_called_once_with( - "dev_123", - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - def test_create_tunnel(self, mock_client: Mock) -> None: - """Test create tunnel.""" - tunnel_view = SimpleNamespace(port=8080) - mock_client.devboxes.create_tunnel.return_value = tunnel_view - - devbox = Devbox(mock_client, "dev_123") - result = devbox.net.create_tunnel( - port=8080, - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result == tunnel_view - mock_client.devboxes.create_tunnel.assert_called_once_with( - "dev_123", - port=8080, - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - def test_remove_tunnel(self, mock_client: Mock) -> None: - """Test remove tunnel.""" - # Return value not used - testing parameter passing only - mock_client.devboxes.remove_tunnel.return_value = object() - - devbox = Devbox(mock_client, "dev_123") - result = devbox.net.remove_tunnel( - port=8080, - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - assert result is not None - mock_client.devboxes.remove_tunnel.assert_called_once_with( - "dev_123", - port=8080, - extra_headers={"X-Custom": "value"}, - extra_query={"param": "value"}, - extra_body={"key": "value"}, - timeout=30.0, - idempotency_key="key-123", - ) - - -class TestDevboxStreaming: - """Tests for Devbox streaming methods.""" - - def test_start_streaming_no_callbacks(self, mock_client: Mock) -> None: - """Test _start_streaming returns None when no callbacks.""" - devbox = Devbox(mock_client, "dev_123") - result = devbox._start_streaming("exec_123", stdout=None, stderr=None, output=None) - assert result is None - - def test_start_streaming_stdout_only(self, mock_client: Mock, mock_stream: Mock) -> None: - """Test _start_streaming with stdout callback only.""" - mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream - - devbox = Devbox(mock_client, "dev_123") - stdout_calls: list[str] = [] - result = devbox._start_streaming("exec_123", stdout=stdout_calls.append, stderr=None, output=None) - - assert result is not None - assert isinstance(result, _StreamingGroup) - assert len(result._threads) == 1 - mock_client.devboxes.executions.stream_stdout_updates.assert_called_once() - - def test_start_streaming_stderr_only(self, mock_client: Mock, mock_stream: Mock) -> None: - """Test _start_streaming with stderr callback only.""" - mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream - - devbox = Devbox(mock_client, "dev_123") - stderr_calls: list[str] = [] - result = devbox._start_streaming("exec_123", stdout=None, stderr=stderr_calls.append, output=None) - - assert result is not None - assert isinstance(result, _StreamingGroup) - assert len(result._threads) == 1 - mock_client.devboxes.executions.stream_stderr_updates.assert_called_once() - - def test_start_streaming_output_only(self, mock_client: Mock, mock_stream: Mock) -> None: - """Test _start_streaming with output callback only.""" - mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream - mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream - - devbox = Devbox(mock_client, "dev_123") - output_calls: list[str] = [] - result = devbox._start_streaming("exec_123", stdout=None, stderr=None, output=output_calls.append) - - assert result is not None - assert isinstance(result, _StreamingGroup) - assert len(result._threads) == 2 # Both stdout and stderr streams - - def test_start_streaming_all_callbacks(self, mock_client: Mock, mock_stream: Mock) -> None: - """Test _start_streaming with all callbacks.""" - mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream - mock_client.devboxes.executions.stream_stderr_updates.return_value = mock_stream - - devbox = Devbox(mock_client, "dev_123") - stdout_calls: list[str] = [] - stderr_calls: list[str] = [] - output_calls: list[str] = [] - result = devbox._start_streaming( - "exec_123", - stdout=stdout_calls.append, - stderr=stderr_calls.append, - output=output_calls.append, - ) - - assert result is not None - assert isinstance(result, _StreamingGroup) - assert len(result._threads) == 2 # Both stdout and stderr streams - - def test_spawn_stream_thread(self, mock_client: Mock, mock_stream: Mock) -> None: - """Test _spawn_stream_thread creates and starts thread.""" - mock_stream.__iter__ = Mock( - return_value=iter( - [ - SimpleNamespace(output="line 1"), - SimpleNamespace(output="line 2"), - ] - ) - ) - mock_stream.__enter__ = Mock(return_value=mock_stream) - mock_stream.__exit__ = Mock(return_value=None) - - devbox = Devbox(mock_client, "dev_123") - stop_event = threading.Event() - calls: list[str] = [] - - def stream_factory() -> Stream: - return mock_stream - - thread = devbox._spawn_stream_thread( - name="test", - stream_factory=stream_factory, - callbacks=[calls.append], - stop_event=stop_event, - ) - - assert isinstance(thread, threading.Thread) - # Give thread time to start - time.sleep(SHORT_SLEEP) - # Thread may have already finished if stream is short - if thread.is_alive(): - stop_event.set() - thread.join(timeout=1.0) - assert not thread.is_alive() - - def test_spawn_stream_thread_stop_event(self, mock_client: Mock, mock_stream: Mock) -> None: - """Test _spawn_stream_thread respects stop event.""" - mock_stream.__iter__ = Mock( - return_value=iter( - [ - SimpleNamespace(output="line 1"), - SimpleNamespace(output="line 2"), - ] - ) - ) - mock_stream.__enter__ = Mock(return_value=mock_stream) - mock_stream.__exit__ = Mock(return_value=None) - - devbox = Devbox(mock_client, "dev_123") - stop_event = threading.Event() - calls: list[str] = [] - - def stream_factory() -> Stream: - return mock_stream - - thread = devbox._spawn_stream_thread( - name="test", - stream_factory=stream_factory, - callbacks=[calls.append], - stop_event=stop_event, - ) - - stop_event.set() - thread.join(timeout=1.0) - assert not thread.is_alive() - - -class TestDevboxErrorHandling: - """Tests for Devbox error handling scenarios.""" - - def test_network_error(self, mock_client: Mock) -> None: - """Test handling of network errors.""" - mock_client.devboxes.retrieve.side_effect = httpx.NetworkError("Connection failed") - - devbox = Devbox(mock_client, "dev_123") - with pytest.raises(httpx.NetworkError): - devbox.get_info() - - @pytest.mark.parametrize( - "status_code,message", - [ - (404, "Not Found"), - (500, "Internal Server Error"), - (503, "Service Unavailable"), - ], - ) - def test_api_error(self, mock_client: Mock, status_code: int, message: str) -> None: - """Test handling of API errors with various status codes.""" - response = create_mock_httpx_response(status_code=status_code, headers={}, text=message) - error = APIStatusError(message=message, response=response, body=None) - - mock_client.devboxes.retrieve.side_effect = error - - devbox = Devbox(mock_client, "dev_123") - with pytest.raises(APIStatusError): - devbox.get_info() - - def test_timeout_error(self, mock_client: Mock) -> None: - """Test handling of timeout errors.""" - mock_client.devboxes.retrieve.side_effect = httpx.TimeoutException("Request timed out") - - devbox = Devbox(mock_client, "dev_123") - with pytest.raises(httpx.TimeoutException): - devbox.get_info(timeout=1.0) - - -class TestDevboxEdgeCases: - """Tests for Devbox edge cases.""" - - def test_empty_responses(self, mock_client: Mock) -> None: - """Test handling of empty responses.""" - empty_view = SimpleNamespace(id="dev_123", status="", name="") - mock_client.devboxes.retrieve.return_value = empty_view - - devbox = Devbox(mock_client, "dev_123") - result = devbox.get_info() - assert result == empty_view - - def test_none_values(self, mock_client: Mock) -> None: - """Test handling of None values.""" - view_with_none = SimpleNamespace(id="dev_123", status=None, name=None) - mock_client.devboxes.retrieve.return_value = view_with_none - - devbox = Devbox(mock_client, "dev_123") - result = devbox.get_info() - assert result.status is None - assert result.name is None - - def test_concurrent_operations(self, mock_client: Mock) -> None: - """Test concurrent operations.""" - mock_client.devboxes.retrieve.return_value = SimpleNamespace(id="dev_123", status="running") - - devbox = Devbox(mock_client, "dev_123") - results = [] - - def get_info() -> None: - results.append(devbox.get_info()) - - threads = [threading.Thread(target=get_info) for _ in range(NUM_CONCURRENT_THREADS)] - for thread in threads: - thread.start() - for thread in threads: - thread.join() - - assert len(results) == NUM_CONCURRENT_THREADS - - -class TestDevboxPythonSpecific: - """Tests for Python-specific Devbox behavior.""" - - def test_context_manager_vs_manual_cleanup(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: - """Test context manager provides automatic cleanup.""" - mock_client.devboxes.shutdown.return_value = devbox_view - - # Context manager approach (Pythonic) - with Devbox(mock_client, "dev_123"): - pass - - mock_client.devboxes.shutdown.assert_called_once() - - # Manual cleanup (TypeScript-like) - devbox = Devbox(mock_client, "dev_123") - devbox.shutdown() - assert mock_client.devboxes.shutdown.call_count == 2 - - def test_path_handling(self, mock_client: Mock) -> None: - """Test Path handling (Python-specific).""" - object_view = SimpleNamespace(id="obj_123", upload_url="https://upload.example.com") - mock_client.objects.create.return_value = object_view - - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - f.write("test") - temp_path = Path(f.name) - - try: - with patch("httpx.put") as mock_put: - mock_response = create_mock_httpx_response() - mock_put.return_value = mock_response - - obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") - obj.upload_content(temp_path) # Path object works - - mock_put.assert_called_once() - finally: - temp_path.unlink() diff --git a/tests/sdk/test_execution.py b/tests/sdk/test_execution.py index 5dd7624ef..0da625239 100644 --- a/tests/sdk/test_execution.py +++ b/tests/sdk/test_execution.py @@ -7,12 +7,18 @@ from types import SimpleNamespace from unittest.mock import Mock +from tests.sdk.conftest import ( + TASK_COMPLETION_LONG, + THREAD_STARTUP_DELAY, + TASK_COMPLETION_SHORT, + MockExecutionView, +) from runloop_api_client.sdk.execution import Execution, _StreamingGroup -# Test constants -SHORT_SLEEP = 0.1 # Brief pause for thread startup -MEDIUM_SLEEP = 0.2 # Slightly longer pause -LONG_SLEEP = 1.0 # Simulates long-running operation for cancellation tests +# Legacy aliases for backward compatibility during transition +SHORT_SLEEP = THREAD_STARTUP_DELAY +MEDIUM_SLEEP = TASK_COMPLETION_SHORT * 10 # 0.2 +LONG_SLEEP = TASK_COMPLETION_LONG class TestStreamingGroup: @@ -76,31 +82,31 @@ def test_active_multiple_threads(self) -> None: class TestExecution: """Tests for Execution class.""" - def test_init(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_init(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test Execution initialization.""" - execution = Execution(mock_client, "dev_123", execution_view) + execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert execution.execution_id == "exec_123" assert execution.devbox_id == "dev_123" assert execution._latest == execution_view - def test_init_with_streaming_group(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_init_with_streaming_group(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test Execution initialization with streaming group.""" threads = [threading.Thread(target=lambda: None)] stop_event = threading.Event() streaming_group = _StreamingGroup(threads, stop_event) - execution = Execution(mock_client, "dev_123", execution_view, streaming_group) + execution = Execution(mock_client, "dev_123", execution_view, streaming_group) # type: ignore[arg-type] assert execution._streaming_group is streaming_group - def test_properties(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_properties(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test Execution properties.""" - execution = Execution(mock_client, "dev_123", execution_view) + execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert execution.execution_id == "exec_123" assert execution.devbox_id == "dev_123" - def test_result_already_completed(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_result_already_completed(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test result when execution is already completed.""" - execution = Execution(mock_client, "dev_123", execution_view) + execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] result = execution.result() assert result.exit_code == 0 @@ -127,7 +133,7 @@ def test_result_needs_polling(self, mock_client: Mock) -> None: mock_client.devboxes.executions.await_completed.return_value = completed_execution - execution = Execution(mock_client, "dev_123", running_execution) + execution = Execution(mock_client, "dev_123", running_execution) # type: ignore[arg-type] result = execution.result() assert result.exit_code == 0 @@ -161,13 +167,13 @@ def test_result_with_streaming_group(self, mock_client: Mock) -> None: thread.start() streaming_group = _StreamingGroup([thread], stop_event) - execution = Execution(mock_client, "dev_123", running_execution, streaming_group) + execution = Execution(mock_client, "dev_123", running_execution, streaming_group) # type: ignore[arg-type] result = execution.result() assert result.exit_code == 0 assert execution._streaming_group is None # Should be cleaned up - def test_get_state(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_get_state(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test get_state method.""" updated_execution = SimpleNamespace( execution_id="exec_123", @@ -176,7 +182,7 @@ def test_get_state(self, mock_client: Mock, execution_view: SimpleNamespace) -> ) mock_client.devboxes.executions.retrieve.return_value = updated_execution - execution = Execution(mock_client, "dev_123", execution_view) + execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] result = execution.get_state() assert result == updated_execution @@ -186,11 +192,11 @@ def test_get_state(self, mock_client: Mock, execution_view: SimpleNamespace) -> devbox_id="dev_123", ) - def test_kill(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_kill(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test kill method.""" mock_client.devboxes.executions.kill.return_value = None - execution = Execution(mock_client, "dev_123", execution_view) + execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] execution.kill() mock_client.devboxes.executions.kill.assert_called_once_with( @@ -199,11 +205,11 @@ def test_kill(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: kill_process_group=None, ) - def test_kill_with_process_group(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_kill_with_process_group(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test kill with kill_process_group.""" mock_client.devboxes.executions.kill.return_value = None - execution = Execution(mock_client, "dev_123", execution_view) + execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] execution.kill(kill_process_group=True) mock_client.devboxes.executions.kill.assert_called_once_with( @@ -212,7 +218,7 @@ def test_kill_with_process_group(self, mock_client: Mock, execution_view: Simple kill_process_group=True, ) - def test_kill_with_streaming_cleanup(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_kill_with_streaming_cleanup(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test kill cleans up streaming.""" mock_client.devboxes.executions.kill.return_value = None @@ -222,18 +228,18 @@ def test_kill_with_streaming_cleanup(self, mock_client: Mock, execution_view: Si thread.start() streaming_group = _StreamingGroup([thread], stop_event) - execution = Execution(mock_client, "dev_123", execution_view, streaming_group) + execution = Execution(mock_client, "dev_123", execution_view, streaming_group) # type: ignore[arg-type] execution.kill() assert execution._streaming_group is None # Should be cleaned up assert stop_event.is_set() # Should be stopped - def test_stop_streaming_no_group(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_stop_streaming_no_group(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test _stop_streaming when no streaming group.""" - execution = Execution(mock_client, "dev_123", execution_view) + execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] execution._stop_streaming() # Should not raise - def test_stop_streaming_with_group(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_stop_streaming_with_group(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test _stop_streaming with streaming group.""" stop_event = threading.Event() # Thread needs to be started to be joinable @@ -241,7 +247,7 @@ def test_stop_streaming_with_group(self, mock_client: Mock, execution_view: Simp thread.start() streaming_group = _StreamingGroup([thread], stop_event) - execution = Execution(mock_client, "dev_123", execution_view, streaming_group) + execution = Execution(mock_client, "dev_123", execution_view, streaming_group) # type: ignore[arg-type] execution._stop_streaming() assert execution._streaming_group is None diff --git a/tests/sdk/test_execution_result.py b/tests/sdk/test_execution_result.py index 3eda81d23..20f8d5519 100644 --- a/tests/sdk/test_execution_result.py +++ b/tests/sdk/test_execution_result.py @@ -5,32 +5,33 @@ from types import SimpleNamespace from unittest.mock import Mock +from tests.sdk.conftest import MockExecutionView from runloop_api_client.sdk.execution_result import ExecutionResult class TestExecutionResult: """Tests for ExecutionResult class.""" - def test_init(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_init(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test ExecutionResult initialization.""" - result = ExecutionResult(mock_client, "dev_123", execution_view) + result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] # Verify via public API assert result.devbox_id == "dev_123" assert result.execution_id == "exec_123" - def test_devbox_id_property(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_devbox_id_property(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test devbox_id property.""" - result = ExecutionResult(mock_client, "dev_123", execution_view) + result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.devbox_id == "dev_123" - def test_execution_id_property(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_execution_id_property(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test execution_id property.""" - result = ExecutionResult(mock_client, "dev_123", execution_view) + result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.execution_id == "exec_123" - def test_exit_code_property(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_exit_code_property(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test exit_code property.""" - result = ExecutionResult(mock_client, "dev_123", execution_view) + result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.exit_code == 0 def test_exit_code_none(self, mock_client: Mock) -> None: @@ -43,12 +44,12 @@ def test_exit_code_none(self, mock_client: Mock) -> None: stdout="", stderr="", ) - result = ExecutionResult(mock_client, "dev_123", execution) + result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] assert result.exit_code is None - def test_success_property(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_success_property(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test success property.""" - result = ExecutionResult(mock_client, "dev_123", execution_view) + result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.success is True def test_success_false(self, mock_client: Mock) -> None: @@ -61,12 +62,12 @@ def test_success_false(self, mock_client: Mock) -> None: stdout="", stderr="error", ) - result = ExecutionResult(mock_client, "dev_123", execution) + result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] assert result.success is False - def test_failed_property(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_failed_property(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test failed property when exit code is zero.""" - result = ExecutionResult(mock_client, "dev_123", execution_view) + result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.failed is False def test_failed_true(self, mock_client: Mock) -> None: @@ -79,7 +80,7 @@ def test_failed_true(self, mock_client: Mock) -> None: stdout="", stderr="error", ) - result = ExecutionResult(mock_client, "dev_123", execution) + result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] assert result.failed is True def test_failed_none(self, mock_client: Mock) -> None: @@ -92,12 +93,12 @@ def test_failed_none(self, mock_client: Mock) -> None: stdout="", stderr="", ) - result = ExecutionResult(mock_client, "dev_123", execution) + result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] assert result.failed is False - def test_stdout(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_stdout(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test stdout method.""" - result = ExecutionResult(mock_client, "dev_123", execution_view) + result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.stdout() == "output" def test_stdout_empty(self, mock_client: Mock) -> None: @@ -110,7 +111,7 @@ def test_stdout_empty(self, mock_client: Mock) -> None: stdout=None, stderr="", ) - result = ExecutionResult(mock_client, "dev_123", execution) + result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] assert result.stdout() == "" def test_stderr(self, mock_client: Mock) -> None: @@ -123,15 +124,15 @@ def test_stderr(self, mock_client: Mock) -> None: stdout="", stderr="error message", ) - result = ExecutionResult(mock_client, "dev_123", execution) + result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] assert result.stderr() == "error message" - def test_stderr_empty(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_stderr_empty(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test stderr method when stderr is None.""" - result = ExecutionResult(mock_client, "dev_123", execution_view) + result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.stderr() == "" - def test_raw_property(self, mock_client: Mock, execution_view: SimpleNamespace) -> None: + def test_raw_property(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test raw property.""" - result = ExecutionResult(mock_client, "dev_123", execution_view) + result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.raw == execution_view diff --git a/tests/sdk/test_snapshot.py b/tests/sdk/test_snapshot.py index a778f2860..383e812cc 100644 --- a/tests/sdk/test_snapshot.py +++ b/tests/sdk/test_snapshot.py @@ -5,6 +5,7 @@ from types import SimpleNamespace from unittest.mock import Mock +from tests.sdk.conftest import MockDevboxView, MockSnapshotView from runloop_api_client.sdk import Snapshot from runloop_api_client.lib.polling import PollingConfig @@ -22,7 +23,7 @@ def test_repr(self, mock_client: Mock) -> None: snapshot = Snapshot(mock_client, "snap_123") assert repr(snapshot) == "" - def test_get_info(self, mock_client: Mock, snapshot_view: SimpleNamespace) -> None: + def test_get_info(self, mock_client: Mock, snapshot_view: MockSnapshotView) -> None: """Test get_info method.""" mock_client.devboxes.disk_snapshots.query_status.return_value = snapshot_view @@ -75,7 +76,6 @@ def test_update(self, mock_client: Mock) -> None: def test_delete(self, mock_client: Mock) -> None: """Test delete method.""" - # Return value not used - testing side effect only mock_client.devboxes.disk_snapshots.delete.return_value = object() snapshot = Snapshot(mock_client, "snap_123") @@ -87,7 +87,7 @@ def test_delete(self, mock_client: Mock) -> None: idempotency_key="key-123", ) - assert result is not None + assert result is not None # Verify return value is propagated mock_client.devboxes.disk_snapshots.delete.assert_called_once_with( "snap_123", extra_headers={"X-Custom": "value"}, @@ -97,7 +97,7 @@ def test_delete(self, mock_client: Mock) -> None: idempotency_key="key-123", ) - def test_await_completed(self, mock_client: Mock, snapshot_view: SimpleNamespace) -> None: + def test_await_completed(self, mock_client: Mock, snapshot_view: MockSnapshotView) -> None: """Test await_completed method.""" mock_client.devboxes.disk_snapshots.await_completed.return_value = snapshot_view polling_config = PollingConfig(timeout_seconds=60.0) @@ -121,7 +121,7 @@ def test_await_completed(self, mock_client: Mock, snapshot_view: SimpleNamespace timeout=30.0, ) - def test_create_devbox(self, mock_client: Mock, devbox_view: SimpleNamespace) -> None: + def test_create_devbox(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: """Test create_devbox method.""" mock_client.devboxes.create_and_await_running.return_value = devbox_view diff --git a/tests/sdk/test_storage_object.py b/tests/sdk/test_storage_object.py index d096101e2..6720256d5 100644 --- a/tests/sdk/test_storage_object.py +++ b/tests/sdk/test_storage_object.py @@ -2,14 +2,13 @@ from __future__ import annotations -import tempfile from types import SimpleNamespace from pathlib import Path from unittest.mock import Mock, patch import pytest -from tests.sdk.conftest import create_mock_httpx_response +from tests.sdk.conftest import MockObjectView, create_mock_httpx_response from runloop_api_client.sdk import StorageObject from runloop_api_client.sdk._sync import StorageObjectClient @@ -34,7 +33,7 @@ def test_repr(self, mock_client: Mock) -> None: obj = StorageObject(mock_client, "obj_123", None) assert repr(obj) == "" - def test_refresh(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + def test_refresh(self, mock_client: Mock, object_view: MockObjectView) -> None: """Test refresh method.""" mock_client.objects.retrieve.return_value = object_view @@ -182,7 +181,7 @@ def test_download_as_text_custom_encoding(self, mock_get: Mock, mock_client: Moc assert mock_response.encoding == "latin-1" mock_get.assert_called_once() - def test_delete(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + def test_delete(self, mock_client: Mock, object_view: MockObjectView) -> None: """Test delete method.""" mock_client.objects.delete.return_value = object_view @@ -230,26 +229,22 @@ def test_upload_content_bytes(self, mock_put: Mock, mock_client: Mock) -> None: mock_response.raise_for_status.assert_called_once() @patch("httpx.put") - def test_upload_content_path(self, mock_put: Mock, mock_client: Mock) -> None: + def test_upload_content_path(self, mock_put: Mock, mock_client: Mock, tmp_path: Path) -> None: """Test upload_content with Path.""" mock_response = create_mock_httpx_response() mock_put.return_value = mock_response - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - f.write("test content") - temp_path = Path(f.name) + temp_file = tmp_path / "test_file.txt" + temp_file.write_text("test content") - try: - obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") - obj.upload_content(temp_path) + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + obj.upload_content(temp_file) - mock_put.assert_called_once() - call_args = mock_put.call_args - assert call_args[0][0] == "https://upload.example.com" - assert call_args[1]["content"] == b"test content" - mock_response.raise_for_status.assert_called_once() - finally: - temp_path.unlink() + mock_put.assert_called_once() + call_args = mock_put.call_args + assert call_args[0][0] == "https://upload.example.com" + assert call_args[1]["content"] == b"test content" + mock_response.raise_for_status.assert_called_once() def test_upload_content_no_url(self, mock_client: Mock) -> None: """Test upload_content raises error when no upload URL.""" @@ -298,7 +293,7 @@ def test_large_file_upload(self, mock_client: Mock) -> None: class TestStorageObjectPythonSpecific: """Tests for Python-specific StorageObject behavior.""" - def test_content_type_detection(self, mock_client: Mock, object_view: SimpleNamespace) -> None: + def test_content_type_detection(self, mock_client: Mock, object_view: MockObjectView) -> None: """Test content type detection differences.""" mock_client.objects.create.return_value = object_view @@ -314,10 +309,8 @@ def test_content_type_detection(self, mock_client: Mock, object_view: SimpleName call2 = mock_client.objects.create.call_args[1] assert call2["content_type"] == "binary" - def test_upload_data_types(self, mock_client: Mock) -> None: + def test_upload_data_types(self, mock_client: Mock, tmp_path: Path) -> None: """Test Python supports more upload data types.""" - object_view = SimpleNamespace(id="obj_123", upload_url="https://upload.example.com") - with patch("httpx.put") as mock_put: mock_response = create_mock_httpx_response() mock_put.return_value = mock_response @@ -331,13 +324,8 @@ def test_upload_data_types(self, mock_client: Mock) -> None: obj.upload_content(b"bytes content") # Path (Python-specific) - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - f.write("file content") - temp_path = Path(f.name) - - try: - obj.upload_content(temp_path) - finally: - temp_path.unlink() + temp_file = tmp_path / "test_file.txt" + temp_file.write_text("file content") + obj.upload_content(temp_file) assert mock_put.call_count == 3 From 7fe4e6eeb56b66b25c98d6310186ca66eaaf89f0 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 11 Nov 2025 16:12:55 -0800 Subject: [PATCH 16/56] async client accepts new blueprint create_and_await_build_complete parameters --- src/runloop_api_client/sdk/_async.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/runloop_api_client/sdk/_async.py b/src/runloop_api_client/sdk/_async.py index f7c20c52c..476d81bd2 100644 --- a/src/runloop_api_client/sdk/_async.py +++ b/src/runloop_api_client/sdk/_async.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Dict, Literal, Mapping, Iterable, Optional +from typing import Dict, Literal, Mapping, Iterable, Optional from pathlib import Path import httpx @@ -13,6 +13,7 @@ from .async_snapshot import AsyncSnapshot from .async_blueprint import AsyncBlueprint from .async_storage_object import AsyncStorageObject +from ..types.blueprint_create_params import Service from ..types.shared_params.launch_parameters import LaunchParameters from ..types.shared_params.code_mount_parameters import CodeMountParameters @@ -262,27 +263,35 @@ async def create( self, *, name: str, - base_blueprint_id: Optional[str] | NotGiven = NOT_GIVEN, - code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, - dockerfile: Optional[str] | NotGiven = NOT_GIVEN, - file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, - services: Optional[Iterable[Any]] | NotGiven = NOT_GIVEN, - system_setup_commands: Optional[SequenceNotStr[str]] | NotGiven = NOT_GIVEN, + base_blueprint_id: Optional[str] | Omit = omit, + base_blueprint_name: Optional[str] | Omit = omit, + build_args: Optional[Dict[str, str]] | Omit = omit, + code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, + dockerfile: Optional[str] | Omit = omit, + file_mounts: Optional[Dict[str, str]] | Omit = omit, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + secrets: Optional[Dict[str, str]] | Omit = omit, + services: Optional[Iterable[Service]] | Omit = omit, + system_setup_commands: Optional[SequenceNotStr[str]] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + timeout: float | Timeout | None | NotGiven = NOT_GIVEN, idempotency_key: str | None = None, ) -> AsyncBlueprint: blueprint = await self._client.blueprints.create_and_await_build_complete( name=name, base_blueprint_id=base_blueprint_id, + base_blueprint_name=base_blueprint_name, + build_args=build_args, code_mounts=code_mounts, dockerfile=dockerfile, file_mounts=file_mounts, launch_parameters=launch_parameters, + metadata=metadata, + secrets=secrets, services=services, system_setup_commands=system_setup_commands, polling_config=polling_config, From 3cbd3f85ad16052cee4e5e71299afd9fd0203a1b Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Wed, 12 Nov 2025 11:17:59 -0800 Subject: [PATCH 17/56] added file system mounts to devbox creation parameters and cleaned up default parameters (matches base api) --- src/runloop_api_client/sdk/_async.py | 85 ++++++++--------- src/runloop_api_client/sdk/_sync.py | 91 ++++++++++--------- src/runloop_api_client/sdk/async_blueprint.py | 20 ++-- src/runloop_api_client/sdk/async_devbox.py | 28 +++--- src/runloop_api_client/sdk/async_snapshot.py | 20 ++-- src/runloop_api_client/sdk/blueprint.py | 23 +++-- src/runloop_api_client/sdk/devbox.py | 42 ++++----- src/runloop_api_client/sdk/snapshot.py | 23 +++-- 8 files changed, 175 insertions(+), 157 deletions(-) diff --git a/src/runloop_api_client/sdk/_async.py b/src/runloop_api_client/sdk/_async.py index 476d81bd2..e9fd73b2b 100644 --- a/src/runloop_api_client/sdk/_async.py +++ b/src/runloop_api_client/sdk/_async.py @@ -5,7 +5,7 @@ import httpx -from .._types import NOT_GIVEN, Body, Omit, Query, Headers, Timeout, NotGiven, SequenceNotStr, omit, not_given +from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, SequenceNotStr, omit, not_given from .._client import AsyncRunloop from ._helpers import ContentType, detect_content_type from ..lib.polling import PollingConfig @@ -13,6 +13,7 @@ from .async_snapshot import AsyncSnapshot from .async_blueprint import AsyncBlueprint from .async_storage_object import AsyncStorageObject +from ..types.shared_params.mount import Mount from ..types.blueprint_create_params import Service from ..types.shared_params.launch_parameters import LaunchParameters from ..types.shared_params.code_mount_parameters import CodeMountParameters @@ -27,18 +28,19 @@ def __init__(self, client: AsyncRunloop) -> None: async def create( self, *, - blueprint_id: Optional[str] | NotGiven = NOT_GIVEN, - blueprint_name: Optional[str] | NotGiven = NOT_GIVEN, - code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, - entrypoint: Optional[str] | NotGiven = NOT_GIVEN, - environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, - metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - name: Optional[str] | NotGiven = NOT_GIVEN, - repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, - secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - snapshot_id: Optional[str] | NotGiven = NOT_GIVEN, + blueprint_id: Optional[str] | Omit = omit, + blueprint_name: Optional[str] | Omit = omit, + code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, + entrypoint: Optional[str] | Omit = omit, + environment_variables: Optional[Dict[str, str]] | Omit = omit, + file_mounts: Optional[Dict[str, str]] | Omit = omit, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + mounts: Optional[Iterable[Mount]] | Omit = omit, + name: Optional[str] | Omit = omit, + repo_connection_id: Optional[str] | Omit = omit, + secrets: Optional[Dict[str, str]] | Omit = omit, + snapshot_id: Optional[str] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -55,6 +57,7 @@ async def create( file_mounts=file_mounts, launch_parameters=launch_parameters, metadata=metadata, + mounts=mounts, name=name, repo_connection_id=repo_connection_id, secrets=secrets, @@ -72,15 +75,15 @@ async def create_from_blueprint_id( self, blueprint_id: str, *, - code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, - entrypoint: Optional[str] | NotGiven = NOT_GIVEN, - environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, - metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - name: Optional[str] | NotGiven = NOT_GIVEN, - repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, - secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, + entrypoint: Optional[str] | Omit = omit, + environment_variables: Optional[Dict[str, str]] | Omit = omit, + file_mounts: Optional[Dict[str, str]] | Omit = omit, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + name: Optional[str] | Omit = omit, + repo_connection_id: Optional[str] | Omit = omit, + secrets: Optional[Dict[str, str]] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -112,15 +115,15 @@ async def create_from_blueprint_name( self, blueprint_name: str, *, - code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, - entrypoint: Optional[str] | NotGiven = NOT_GIVEN, - environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, - metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - name: Optional[str] | NotGiven = NOT_GIVEN, - repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, - secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, + entrypoint: Optional[str] | Omit = omit, + environment_variables: Optional[Dict[str, str]] | Omit = omit, + file_mounts: Optional[Dict[str, str]] | Omit = omit, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + name: Optional[str] | Omit = omit, + repo_connection_id: Optional[str] | Omit = omit, + secrets: Optional[Dict[str, str]] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -152,15 +155,15 @@ async def create_from_snapshot( self, snapshot_id: str, *, - code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, - entrypoint: Optional[str] | NotGiven = NOT_GIVEN, - environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, - metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - name: Optional[str] | NotGiven = NOT_GIVEN, - repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, - secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, + entrypoint: Optional[str] | Omit = omit, + environment_variables: Optional[Dict[str, str]] | Omit = omit, + file_mounts: Optional[Dict[str, str]] | Omit = omit, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + name: Optional[str] | Omit = omit, + repo_connection_id: Optional[str] | Omit = omit, + secrets: Optional[Dict[str, str]] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -278,7 +281,7 @@ async def create( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> AsyncBlueprint: blueprint = await self._client.blueprints.create_and_await_build_complete( diff --git a/src/runloop_api_client/sdk/_sync.py b/src/runloop_api_client/sdk/_sync.py index 96d6ed056..73912698b 100644 --- a/src/runloop_api_client/sdk/_sync.py +++ b/src/runloop_api_client/sdk/_sync.py @@ -6,13 +6,14 @@ import httpx from .devbox import Devbox -from .._types import NOT_GIVEN, Body, Omit, Query, Headers, Timeout, NotGiven, SequenceNotStr, omit, not_given +from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, SequenceNotStr, omit, not_given from .._client import Runloop from ._helpers import ContentType, detect_content_type from .snapshot import Snapshot from .blueprint import Blueprint from ..lib.polling import PollingConfig from .storage_object import StorageObject +from ..types.shared_params.mount import Mount from ..types.blueprint_create_params import Service from ..types.shared_params.launch_parameters import LaunchParameters from ..types.shared_params.code_mount_parameters import CodeMountParameters @@ -27,18 +28,19 @@ def __init__(self, client: Runloop) -> None: def create( self, *, - blueprint_id: Optional[str] | NotGiven = NOT_GIVEN, - blueprint_name: Optional[str] | NotGiven = NOT_GIVEN, - code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, - entrypoint: Optional[str] | NotGiven = NOT_GIVEN, - environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, - metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - name: Optional[str] | NotGiven = NOT_GIVEN, - repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, - secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - snapshot_id: Optional[str] | NotGiven = NOT_GIVEN, + blueprint_id: Optional[str] | Omit = omit, + blueprint_name: Optional[str] | Omit = omit, + code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, + entrypoint: Optional[str] | Omit = omit, + environment_variables: Optional[Dict[str, str]] | Omit = omit, + file_mounts: Optional[Dict[str, str]] | Omit = omit, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + mounts: Optional[Iterable[Mount]] | Omit = omit, + name: Optional[str] | Omit = omit, + repo_connection_id: Optional[str] | Omit = omit, + secrets: Optional[Dict[str, str]] | Omit = omit, + snapshot_id: Optional[str] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -55,6 +57,7 @@ def create( file_mounts=file_mounts, launch_parameters=launch_parameters, metadata=metadata, + mounts=mounts, name=name, repo_connection_id=repo_connection_id, secrets=secrets, @@ -72,15 +75,16 @@ def create_from_blueprint_id( self, blueprint_id: str, *, - code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, - entrypoint: Optional[str] | NotGiven = NOT_GIVEN, - environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, - metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - name: Optional[str] | NotGiven = NOT_GIVEN, - repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, - secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, + entrypoint: Optional[str] | Omit = omit, + environment_variables: Optional[Dict[str, str]] | Omit = omit, + file_mounts: Optional[Dict[str, str]] | Omit = omit, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + mounts: Optional[Iterable[Mount]] | Omit = omit, + name: Optional[str] | Omit = omit, + repo_connection_id: Optional[str] | Omit = omit, + secrets: Optional[Dict[str, str]] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -96,6 +100,7 @@ def create_from_blueprint_id( file_mounts=file_mounts, launch_parameters=launch_parameters, metadata=metadata, + mounts=mounts, name=name, repo_connection_id=repo_connection_id, secrets=secrets, @@ -112,15 +117,16 @@ def create_from_blueprint_name( self, blueprint_name: str, *, - code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, - entrypoint: Optional[str] | NotGiven = NOT_GIVEN, - environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, - metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - name: Optional[str] | NotGiven = NOT_GIVEN, - repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, - secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, + entrypoint: Optional[str] | Omit = omit, + environment_variables: Optional[Dict[str, str]] | Omit = omit, + file_mounts: Optional[Dict[str, str]] | Omit = omit, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + mounts: Optional[Iterable[Mount]] | Omit = omit, + name: Optional[str] | Omit = omit, + repo_connection_id: Optional[str] | Omit = omit, + secrets: Optional[Dict[str, str]] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -136,6 +142,7 @@ def create_from_blueprint_name( file_mounts=file_mounts, launch_parameters=launch_parameters, metadata=metadata, + mounts=mounts, name=name, repo_connection_id=repo_connection_id, secrets=secrets, @@ -152,15 +159,16 @@ def create_from_snapshot( self, snapshot_id: str, *, - code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, - entrypoint: Optional[str] | NotGiven = NOT_GIVEN, - environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, - metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - name: Optional[str] | NotGiven = NOT_GIVEN, - repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, - secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, + entrypoint: Optional[str] | Omit = omit, + environment_variables: Optional[Dict[str, str]] | Omit = omit, + file_mounts: Optional[Dict[str, str]] | Omit = omit, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + mounts: Optional[Iterable[Mount]] | Omit = omit, + name: Optional[str] | Omit = omit, + repo_connection_id: Optional[str] | Omit = omit, + secrets: Optional[Dict[str, str]] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -176,6 +184,7 @@ def create_from_snapshot( file_mounts=file_mounts, launch_parameters=launch_parameters, metadata=metadata, + mounts=mounts, name=name, repo_connection_id=repo_connection_id, secrets=secrets, @@ -279,7 +288,7 @@ def create( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> Blueprint: blueprint = self._client.blueprints.create_and_await_build_complete( diff --git a/src/runloop_api_client/sdk/async_blueprint.py b/src/runloop_api_client/sdk/async_blueprint.py index 2ea5309d7..ce2f1416e 100644 --- a/src/runloop_api_client/sdk/async_blueprint.py +++ b/src/runloop_api_client/sdk/async_blueprint.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: from .async_devbox import AsyncDevbox from ..types import BlueprintView -from .._types import NOT_GIVEN, Body, Query, Headers, Timeout, NotGiven, not_given +from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given from .._client import AsyncRunloop from ..lib.polling import PollingConfig from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView @@ -86,15 +86,15 @@ async def delete( async def create_devbox( self, *, - code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, - entrypoint: Optional[str] | NotGiven = NOT_GIVEN, - environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, - metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - name: Optional[str] | NotGiven = NOT_GIVEN, - repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, - secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, + entrypoint: Optional[str] | Omit = omit, + environment_variables: Optional[Dict[str, str]] | Omit = omit, + file_mounts: Optional[Dict[str, str]] | Omit = omit, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + name: Optional[str] | Omit = omit, + repo_connection_id: Optional[str] | Omit = omit, + secrets: Optional[Dict[str, str]] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index d889c062f..a9ddefb06 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -245,9 +245,9 @@ def _start_streaming( self, execution_id: str, *, - stdout: LogCallback | None, - stderr: LogCallback | None, - output: LogCallback | None, + stdout: Optional[LogCallback] = None, + stderr: Optional[LogCallback] = None, + output: Optional[LogCallback] = None, ) -> Optional[_AsyncStreamingGroup]: tasks: list[asyncio.Task[None]] = [] @@ -318,10 +318,10 @@ async def exec( self, command: str, *, - shell_name: str | None = None, - stdout: LogCallback | None = None, - stderr: LogCallback | None = None, - output: LogCallback | None = None, + shell_name: Optional[str] | Omit = omit, + stdout: Optional[LogCallback] = None, + stderr: Optional[LogCallback] = None, + output: Optional[LogCallback] = None, polling_config: PollingConfig | None = None, attach_stdin: bool | Omit = omit, extra_headers: Headers | None = None, @@ -337,7 +337,7 @@ async def exec( execution: DevboxAsyncExecutionDetailView = await client.devboxes.execute_async( devbox.id, command=command, - shell_name=shell_name if shell_name is not None else omit, + shell_name=shell_name, attach_stdin=attach_stdin, extra_headers=extra_headers, extra_query=extra_query, @@ -380,7 +380,7 @@ async def command_coro() -> DevboxAsyncExecutionDetailView: final = await client.devboxes.execute_and_await_completion( devbox.id, command=command, - shell_name=shell_name if shell_name is not None else not_given, + shell_name=shell_name, polling_config=polling_config, extra_headers=extra_headers, extra_query=extra_query, @@ -394,10 +394,10 @@ async def exec_async( self, command: str, *, - shell_name: str | None = None, - stdout: LogCallback | None = None, - stderr: LogCallback | None = None, - output: LogCallback | None = None, + shell_name: Optional[str] | Omit = omit, + stdout: Optional[LogCallback] = None, + stderr: Optional[LogCallback] = None, + output: Optional[LogCallback] = None, attach_stdin: bool | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -411,7 +411,7 @@ async def exec_async( execution: DevboxAsyncExecutionDetailView = await client.devboxes.execute_async( devbox.id, command=command, - shell_name=shell_name if shell_name is not None else omit, + shell_name=shell_name, attach_stdin=attach_stdin, extra_headers=extra_headers, extra_query=extra_query, diff --git a/src/runloop_api_client/sdk/async_snapshot.py b/src/runloop_api_client/sdk/async_snapshot.py index 5d4001e3a..43d66445c 100644 --- a/src/runloop_api_client/sdk/async_snapshot.py +++ b/src/runloop_api_client/sdk/async_snapshot.py @@ -5,7 +5,7 @@ if TYPE_CHECKING: from .async_devbox import AsyncDevbox -from .._types import NOT_GIVEN, Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given +from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given from .._client import AsyncRunloop from ..lib.polling import PollingConfig from ..types.devbox_snapshot_view import DevboxSnapshotView @@ -114,15 +114,15 @@ async def await_completed( async def create_devbox( self, *, - code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, - entrypoint: Optional[str] | NotGiven = NOT_GIVEN, - environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, - metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - name: Optional[str] | NotGiven = NOT_GIVEN, - repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, - secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, + entrypoint: Optional[str] | Omit = omit, + environment_variables: Optional[Dict[str, str]] | Omit = omit, + file_mounts: Optional[Dict[str, str]] | Omit = omit, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + name: Optional[str] | Omit = omit, + repo_connection_id: Optional[str] | Omit = omit, + secrets: Optional[Dict[str, str]] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, diff --git a/src/runloop_api_client/sdk/blueprint.py b/src/runloop_api_client/sdk/blueprint.py index c3327c2c6..140c411c5 100644 --- a/src/runloop_api_client/sdk/blueprint.py +++ b/src/runloop_api_client/sdk/blueprint.py @@ -6,9 +6,10 @@ if TYPE_CHECKING: from .devbox import Devbox from ..types import BlueprintView -from .._types import NOT_GIVEN, Body, Query, Headers, Timeout, NotGiven, not_given +from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given from .._client import Runloop from ..lib.polling import PollingConfig +from ..types.shared_params.mount import Mount from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView from ..types.shared_params.launch_parameters import LaunchParameters from ..types.shared_params.code_mount_parameters import CodeMountParameters @@ -86,15 +87,16 @@ def delete( def create_devbox( self, *, - code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, - entrypoint: Optional[str] | NotGiven = NOT_GIVEN, - environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, - metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - name: Optional[str] | NotGiven = NOT_GIVEN, - repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, - secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, + entrypoint: Optional[str] | Omit = omit, + environment_variables: Optional[Dict[str, str]] | Omit = omit, + file_mounts: Optional[Dict[str, str]] | Omit = omit, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + mounts: Optional[Iterable[Mount]] | Omit = omit, + name: Optional[str] | Omit = omit, + repo_connection_id: Optional[str] | Omit = omit, + secrets: Optional[Dict[str, str]] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -113,6 +115,7 @@ def create_devbox( file_mounts=file_mounts, launch_parameters=launch_parameters, metadata=metadata, + mounts=mounts, name=name, repo_connection_id=repo_connection_id, secrets=secrets, diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index b4b96b962..586124405 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -2,7 +2,7 @@ import logging import threading -from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence +from typing import TYPE_CHECKING, Any, Dict, Callable, Optional, Sequence from typing_extensions import override from ..types import ( @@ -153,9 +153,9 @@ def keep_alive( def snapshot_disk( self, *, - commit_message: str | None | Omit = omit, - metadata: dict[str, str] | None | Omit = omit, - name: str | None | Omit = omit, + commit_message: Optional[str] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + name: Optional[str] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -187,9 +187,9 @@ def snapshot_disk( def snapshot_disk_async( self, *, - commit_message: str | None | Omit = omit, - metadata: dict[str, str] | None | Omit = omit, - name: str | None | Omit = omit, + commit_message: Optional[str] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + name: Optional[str] | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, @@ -237,9 +237,9 @@ def _start_streaming( self, execution_id: str, *, - stdout: LogCallback | None, - stderr: LogCallback | None, - output: LogCallback | None, + stdout: Optional[LogCallback] = None, + stderr: Optional[LogCallback] = None, + output: Optional[LogCallback] = None, ) -> Optional[_StreamingGroup]: threads: list[threading.Thread] = [] stop_event = threading.Event() @@ -319,10 +319,10 @@ def exec( self, command: str, *, - shell_name: str | None = None, - stdout: LogCallback | None = None, - stderr: LogCallback | None = None, - output: LogCallback | None = None, + shell_name: Optional[str] | Omit = omit, + stdout: Optional[LogCallback] = None, + stderr: Optional[LogCallback] = None, + output: Optional[LogCallback] = None, polling_config: PollingConfig | None = None, attach_stdin: bool | Omit = omit, extra_headers: Headers | None = None, @@ -338,7 +338,7 @@ def exec( execution: DevboxAsyncExecutionDetailView = client.devboxes.execute_async( devbox.id, command=command, - shell_name=shell_name if shell_name is not None else omit, + shell_name=shell_name, attach_stdin=attach_stdin, extra_headers=extra_headers, extra_query=extra_query, @@ -373,7 +373,7 @@ def exec( final = client.devboxes.execute_and_await_completion( devbox.id, command=command, - shell_name=shell_name if shell_name is not None else not_given, + shell_name=shell_name, polling_config=polling_config, extra_headers=extra_headers, extra_query=extra_query, @@ -387,10 +387,10 @@ def exec_async( self, command: str, *, - shell_name: str | None = None, - stdout: LogCallback | None = None, - stderr: LogCallback | None = None, - output: LogCallback | None = None, + shell_name: Optional[str] | Omit = omit, + stdout: Optional[LogCallback] = None, + stderr: Optional[LogCallback] = None, + output: Optional[LogCallback] = None, attach_stdin: bool | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -404,7 +404,7 @@ def exec_async( execution: DevboxAsyncExecutionDetailView = client.devboxes.execute_async( devbox.id, command=command, - shell_name=shell_name if shell_name is not None else omit, + shell_name=shell_name, attach_stdin=attach_stdin, extra_headers=extra_headers, extra_query=extra_query, diff --git a/src/runloop_api_client/sdk/snapshot.py b/src/runloop_api_client/sdk/snapshot.py index aface6635..b9434ef52 100644 --- a/src/runloop_api_client/sdk/snapshot.py +++ b/src/runloop_api_client/sdk/snapshot.py @@ -5,9 +5,10 @@ if TYPE_CHECKING: from .devbox import Devbox -from .._types import NOT_GIVEN, Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given +from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given from .._client import Runloop from ..lib.polling import PollingConfig +from ..types.shared_params.mount import Mount from ..types.devbox_snapshot_view import DevboxSnapshotView from ..types.shared_params.launch_parameters import LaunchParameters from ..types.shared_params.code_mount_parameters import CodeMountParameters @@ -114,15 +115,16 @@ def await_completed( def create_devbox( self, *, - code_mounts: Optional[Iterable[CodeMountParameters]] | NotGiven = NOT_GIVEN, - entrypoint: Optional[str] | NotGiven = NOT_GIVEN, - environment_variables: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - file_mounts: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - launch_parameters: Optional[LaunchParameters] | NotGiven = NOT_GIVEN, - metadata: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, - name: Optional[str] | NotGiven = NOT_GIVEN, - repo_connection_id: Optional[str] | NotGiven = NOT_GIVEN, - secrets: Optional[Dict[str, str]] | NotGiven = NOT_GIVEN, + code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, + entrypoint: Optional[str] | Omit = omit, + environment_variables: Optional[Dict[str, str]] | Omit = omit, + file_mounts: Optional[Dict[str, str]] | Omit = omit, + launch_parameters: Optional[LaunchParameters] | Omit = omit, + metadata: Optional[Dict[str, str]] | Omit = omit, + mounts: Optional[Iterable[Mount]] | Omit = omit, + name: Optional[str] | Omit = omit, + repo_connection_id: Optional[str] | Omit = omit, + secrets: Optional[Dict[str, str]] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -141,6 +143,7 @@ def create_devbox( file_mounts=file_mounts, launch_parameters=launch_parameters, metadata=metadata, + mounts=mounts, name=name, repo_connection_id=repo_connection_id, secrets=secrets, From 0e7d4399076623978b05b33344406146a28f80cf Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Wed, 12 Nov 2025 17:21:02 -0800 Subject: [PATCH 18/56] fixed snapshot list, cleaned up getattr --- src/runloop_api_client/sdk/_async.py | 8 ++++---- src/runloop_api_client/sdk/_sync.py | 8 ++++---- src/runloop_api_client/sdk/async_devbox.py | 2 +- src/runloop_api_client/sdk/devbox.py | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/runloop_api_client/sdk/_async.py b/src/runloop_api_client/sdk/_async.py index e9fd73b2b..0f8935a74 100644 --- a/src/runloop_api_client/sdk/_async.py +++ b/src/runloop_api_client/sdk/_async.py @@ -217,7 +217,7 @@ async def list( extra_body=extra_body, timeout=timeout, ) - return [AsyncDevbox(self._client, item.id) for item in getattr(page, "devboxes", [])] + return [AsyncDevbox(self._client, item.id) for item in page.devboxes] class AsyncSnapshotClient: @@ -250,7 +250,7 @@ async def list( extra_body=extra_body, timeout=timeout, ) - return [AsyncSnapshot(self._client, item.id) for item in getattr(page, "disk_snapshots", [])] + return [AsyncSnapshot(self._client, item.id) for item in page.snapshots] def from_id(self, snapshot_id: str) -> AsyncSnapshot: return AsyncSnapshot(self._client, snapshot_id) @@ -329,7 +329,7 @@ async def list( extra_body=extra_body, timeout=timeout, ) - return [AsyncBlueprint(self._client, item.id) for item in getattr(page, "blueprints", [])] + return [AsyncBlueprint(self._client, item.id) for item in page.blueprints] class AsyncStorageObjectClient: @@ -378,7 +378,7 @@ async def list( extra_body=extra_body, timeout=timeout, ) - return [AsyncStorageObject(self._client, item.id, upload_url=None) for item in getattr(page, "objects", [])] + return [AsyncStorageObject(self._client, item.id, upload_url=item.upload_url) for item in page.objects] async def upload_from_file( self, diff --git a/src/runloop_api_client/sdk/_sync.py b/src/runloop_api_client/sdk/_sync.py index 73912698b..356d81754 100644 --- a/src/runloop_api_client/sdk/_sync.py +++ b/src/runloop_api_client/sdk/_sync.py @@ -224,7 +224,7 @@ def list( extra_body=extra_body, timeout=timeout, ) - return [Devbox(self._client, item.id) for item in getattr(page, "devboxes", [])] + return [Devbox(self._client, item.id) for item in page.devboxes] class SnapshotClient: @@ -257,7 +257,7 @@ def list( extra_body=extra_body, timeout=timeout, ) - return [Snapshot(self._client, item.id) for item in getattr(page, "disk_snapshots", [])] + return [Snapshot(self._client, item.id) for item in page.snapshots] def from_id(self, snapshot_id: str) -> Snapshot: return Snapshot(self._client, snapshot_id) @@ -336,7 +336,7 @@ def list( extra_body=extra_body, timeout=timeout, ) - return [Blueprint(self._client, item.id) for item in getattr(page, "blueprints", [])] + return [Blueprint(self._client, item.id) for item in page.blueprints] class StorageObjectClient: @@ -385,7 +385,7 @@ def list( extra_body=extra_body, timeout=timeout, ) - return [StorageObject(self._client, item.id, upload_url=None) for item in getattr(page, "objects", [])] + return [StorageObject(self._client, item.id, upload_url=item.upload_url) for item in page.objects] def upload_from_file( self, diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index a9ddefb06..edda16f7d 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -298,7 +298,7 @@ async def _stream_worker( stream = await stream_factory() async with stream: async for chunk in stream: - text = getattr(chunk, "output", "") + text = chunk.output for callback in callbacks: try: callback(text) diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index 586124405..b600979bd 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -293,7 +293,7 @@ def worker() -> None: for chunk in stream: if stop_event.is_set(): break - text = getattr(chunk, "output", "") + text = chunk.output for callback in callbacks: try: callback(text) From 8b01643312bca620e2724c2d182c9e4e949714f0 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Wed, 12 Nov 2025 18:23:48 -0800 Subject: [PATCH 19/56] abandon trying to support other upload file types for devbox file uploads (sticks to base api FileType) --- src/runloop_api_client/sdk/_helpers.py | 31 +--------------------- src/runloop_api_client/sdk/async_devbox.py | 9 +++---- src/runloop_api_client/sdk/devbox.py | 9 +++---- 3 files changed, 9 insertions(+), 40 deletions(-) diff --git a/src/runloop_api_client/sdk/_helpers.py b/src/runloop_api_client/sdk/_helpers.py index a84897be8..1e459f6e4 100644 --- a/src/runloop_api_client/sdk/_helpers.py +++ b/src/runloop_api_client/sdk/_helpers.py @@ -2,14 +2,10 @@ import io import os -from typing import IO, Dict, Union, Literal, Callable, cast +from typing import Dict, Union, Literal, Callable from pathlib import Path -from .._types import FileTypes -from .._utils import file_from_path - LogCallback = Callable[[str], None] -UploadInput = Union[FileTypes, str, os.PathLike[str], Path, bytes, bytearray, io.IOBase] ContentType = Literal["unspecified", "text", "binary", "gzip", "tar", "tgz"] UploadData = Union[str, bytes, bytearray, Path, os.PathLike[str], io.IOBase] @@ -59,28 +55,3 @@ def read_upload_data(data: UploadData) -> bytes: return result.encode("utf-8") return result raise TypeError("Unsupported upload data type. Provide str, bytes, path, or file-like object.") - - -def normalize_upload_input(file: UploadInput) -> FileTypes: - """ - Normalize a variety of Python file representations into the generated client's FileTypes. - """ - if isinstance(file, tuple): - return file - if isinstance(file, bytes): - return file - if isinstance(file, bytearray): - return bytes(file) - if isinstance(file, (str, Path, os.PathLike)): - path_str = str(file) - return file_from_path(path_str) - if isinstance(file, io.TextIOBase): - return file.read().encode("utf-8") - if isinstance(file, io.BufferedIOBase) or isinstance(file, io.RawIOBase): - return cast(IO[bytes], file) - if isinstance(file, io.IOBase) and hasattr(file, "read"): - data = file.read() - if isinstance(data, str): - return data.encode("utf-8") - return data - raise TypeError("Unsupported file type for upload. Provide path, bytes, or file-like object.") diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index edda16f7d..9f3ae3be6 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -11,9 +11,9 @@ DevboxExecutionDetailView, DevboxCreateSSHKeyResponse, ) -from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given +from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, FileTypes, omit, not_given from .._client import AsyncRunloop -from ._helpers import LogCallback, UploadInput, normalize_upload_input +from ._helpers import LogCallback from .._streaming import AsyncStream from ..lib.polling import PollingConfig from .async_execution import AsyncExecution, _AsyncStreamingGroup @@ -505,7 +505,7 @@ async def download( async def upload( self, path: str, - file: UploadInput, + file: FileTypes, *, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -513,11 +513,10 @@ async def upload( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> object: - file_param = normalize_upload_input(file) return await self._devbox._client.devboxes.upload_file( self._devbox.id, path=path, - file=file_param, + file=file, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index b600979bd..b535e731e 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -11,9 +11,9 @@ DevboxExecutionDetailView, DevboxCreateSSHKeyResponse, ) -from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given +from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, FileTypes, omit, not_given from .._client import Runloop -from ._helpers import LogCallback, UploadInput, normalize_upload_input +from ._helpers import LogCallback from .execution import Execution, _StreamingGroup from .._streaming import Stream from ..lib.polling import PollingConfig @@ -498,7 +498,7 @@ def download( def upload( self, path: str, - file: UploadInput, + file: FileTypes, *, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -506,11 +506,10 @@ def upload( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> object: - file_param = normalize_upload_input(file) return self._devbox._client.devboxes.upload_file( self._devbox.id, path=path, - file=file_param, + file=file, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, From 0cccb785d2a3ae00ef1794ddbb816c1d05732081 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Wed, 12 Nov 2025 18:23:57 -0800 Subject: [PATCH 20/56] smoke tests --- tests/smoketests/sdk/README.md | 138 +++++ tests/smoketests/sdk/__init__.py | 2 + tests/smoketests/sdk/conftest.py | 68 ++ tests/smoketests/sdk/test_async_blueprint.py | 302 +++++++++ tests/smoketests/sdk/test_async_devbox.py | 578 +++++++++++++++++ tests/smoketests/sdk/test_async_sdk.py | 33 + tests/smoketests/sdk/test_async_snapshot.py | 413 +++++++++++++ .../sdk/test_async_storage_object.py | 467 ++++++++++++++ tests/smoketests/sdk/test_blueprint.py | 299 +++++++++ tests/smoketests/sdk/test_devbox.py | 579 ++++++++++++++++++ tests/smoketests/sdk/test_sdk.py | 33 + tests/smoketests/sdk/test_snapshot.py | 410 +++++++++++++ tests/smoketests/sdk/test_storage_object.py | 465 ++++++++++++++ 13 files changed, 3787 insertions(+) create mode 100644 tests/smoketests/sdk/README.md create mode 100644 tests/smoketests/sdk/__init__.py create mode 100644 tests/smoketests/sdk/conftest.py create mode 100644 tests/smoketests/sdk/test_async_blueprint.py create mode 100644 tests/smoketests/sdk/test_async_devbox.py create mode 100644 tests/smoketests/sdk/test_async_sdk.py create mode 100644 tests/smoketests/sdk/test_async_snapshot.py create mode 100644 tests/smoketests/sdk/test_async_storage_object.py create mode 100644 tests/smoketests/sdk/test_blueprint.py create mode 100644 tests/smoketests/sdk/test_devbox.py create mode 100644 tests/smoketests/sdk/test_sdk.py create mode 100644 tests/smoketests/sdk/test_snapshot.py create mode 100644 tests/smoketests/sdk/test_storage_object.py diff --git a/tests/smoketests/sdk/README.md b/tests/smoketests/sdk/README.md new file mode 100644 index 000000000..66e6c9dc4 --- /dev/null +++ b/tests/smoketests/sdk/README.md @@ -0,0 +1,138 @@ +# SDK End-to-End Smoke Tests + +Comprehensive end-to-end tests for the object-oriented Python SDK (`runloop_api_client.sdk`). These tests run against the real Runloop API to validate critical workflows including devboxes, blueprints, snapshots, and storage objects. + +## Overview + +The Python SDK provides both synchronous and asynchronous interfaces: +- **Synchronous SDK**: `RunloopSDK` with `Devbox`, `Blueprint`, `Snapshot`, `StorageObject` +- **Asynchronous SDK**: `AsyncRunloopSDK` with `AsyncDevbox`, `AsyncBlueprint`, `AsyncSnapshot`, `AsyncStorageObject` + +These tests ensure both interfaces work correctly in real-world scenarios. + +## Test Files + +### Infrastructure +- `conftest.py` - Pytest fixtures for SDK client instances + +### Devbox Tests +- `test_devbox.py` - Synchronous devbox operations +- `test_async_devbox.py` - Asynchronous devbox operations + +**Test Coverage:** +- Devbox lifecycle (create, get_info, shutdown) +- Command execution (exec, exec_async) with streaming callbacks +- File operations (read, write, upload, download) +- State management (suspend, resume, await_running, await_suspended, keep_alive) +- Networking (SSH keys, tunnels) +- Creation from blueprints and snapshots +- Snapshot creation +- Context manager support + +### Blueprint Tests +- `test_blueprint.py` - Synchronous blueprint operations +- `test_async_blueprint.py` - Asynchronous blueprint operations + +**Test Coverage:** +- Blueprint creation with dockerfiles and system setup commands +- Blueprint listing and retrieval +- Creating devboxes from blueprints +- Blueprint deletion + +### Snapshot Tests +- `test_snapshot.py` - Synchronous snapshot operations +- `test_async_snapshot.py` - Asynchronous snapshot operations + +**Test Coverage:** +- Snapshot creation from devboxes +- Snapshot info and status tracking +- Waiting for snapshot completion +- Creating devboxes from snapshots +- Snapshot listing and deletion + +### Storage Object Tests +- `test_storage_object.py` - Synchronous storage object operations +- `test_async_storage_object.py` - Asynchronous storage object operations + +**Test Coverage:** +- Storage object lifecycle (create, upload, complete, delete) +- Static upload methods (upload_from_text, upload_from_bytes, upload_from_file) +- Download methods (download_as_text, download_as_bytes, get_download_url) +- Storage object listing and retrieval +- Mounting storage objects to devboxes + +## Running the Tests + +### Prerequisites + +Set required environment variables: +```bash +export RUNLOOP_API_KEY=your_api_key_here +# Optional: override the API base URL +# export RUNLOOP_BASE_URL=https://api.runloop.ai +``` + +### Run All SDK Smoke Tests +```bash +RUN_SMOKETESTS=1 uv run pytest -q -vv -m smoketest tests/smoketests/sdk/ +``` + +### Run Specific Test File +```bash +RUN_SMOKETESTS=1 uv run pytest -q -vv -m smoketest tests/smoketests/sdk/test_devbox.py +``` + +### Run Specific Test +```bash +RUN_SMOKETESTS=1 uv run pytest -q -vv -m smoketest -k "test_devbox_lifecycle" tests/smoketests/sdk/ +``` + +### Run Only Sync or Async Tests +```bash +# Sync tests only (files without 'async' prefix) +RUN_SMOKETESTS=1 uv run pytest -q -vv -m smoketest tests/smoketests/sdk/test_devbox.py tests/smoketests/sdk/test_blueprint.py tests/smoketests/sdk/test_snapshot.py tests/smoketests/sdk/test_storage_object.py + +# Async tests only (files with 'async' prefix) +RUN_SMOKETESTS=1 uv run pytest -q -vv -m smoketest tests/smoketests/sdk/test_async_*.py +``` + +## Test Patterns + +### Resource Management +All tests include proper cleanup using try/finally blocks or pytest fixtures to ensure resources (devboxes, blueprints, etc.) are deleted after testing, even if tests fail. + +### Timeouts +Tests use appropriate timeouts based on operation types: +- **30 seconds**: Quick operations (create, retrieve, delete) +- **2+ minutes**: Long-running operations (devbox creation, blueprint builds) + +### Sequential Tests +Some tests within a file may be dependent on each other to save time and resources. Tests are designed to be run sequentially within each file. + +### Naming Convention +Tests use the `unique_name()` utility to generate unique resource names with timestamps, preventing conflicts between test runs. + +## Differences from TypeScript SDK + +The Python SDK includes both synchronous and asynchronous implementations, whereas the TypeScript SDK is async-only. This necessitates separate test files for each variant to ensure both work correctly. + +Key differences: +- Python uses `async`/`await` syntax for async operations +- Python has separate classes: `Devbox` vs `AsyncDevbox`, etc. +- Python uses context managers (`with` statement) for resource cleanup +- Python async tests require `pytest.mark.asyncio` decorator + +## CI Integration + +These tests can be integrated into CI workflows similar to other smoketests. Set the `RUNLOOP_API_KEY` secret in your CI environment and run with the `RUN_SMOKETESTS=1` environment variable. + +Example GitHub Actions workflow step: +```yaml +- name: Run SDK Smoke Tests + env: + RUNLOOP_API_KEY: ${{ secrets.RUNLOOP_SMOKETEST_API_KEY }} + RUN_SMOKETESTS: 1 + run: | + uv run pytest -q -vv -m smoketest tests/smoketests/sdk/ +``` + diff --git a/tests/smoketests/sdk/__init__.py b/tests/smoketests/sdk/__init__.py new file mode 100644 index 000000000..e6e524c21 --- /dev/null +++ b/tests/smoketests/sdk/__init__.py @@ -0,0 +1,2 @@ +# SDK End-to-End Smoke Tests + diff --git a/tests/smoketests/sdk/conftest.py b/tests/smoketests/sdk/conftest.py new file mode 100644 index 000000000..003b0f314 --- /dev/null +++ b/tests/smoketests/sdk/conftest.py @@ -0,0 +1,68 @@ +"""Pytest fixtures for SDK end-to-end smoke tests.""" + +from __future__ import annotations + +import os +from typing import Iterator, AsyncIterator + +import pytest + +from runloop_api_client.sdk import RunloopSDK, AsyncRunloopSDK + + +@pytest.fixture(scope="module") +def sdk_client() -> Iterator[RunloopSDK]: + """Provide a synchronous RunloopSDK client for tests. + + Reads configuration from environment variables: + - RUNLOOP_API_KEY: Required API key + - RUNLOOP_BASE_URL: Optional API base URL + """ + base_url = os.getenv("RUNLOOP_BASE_URL") + bearer_token = os.getenv("RUNLOOP_API_KEY") + + if not bearer_token: + pytest.skip("RUNLOOP_API_KEY environment variable not set") + + client = RunloopSDK( + bearer_token=bearer_token, + base_url=base_url, + ) + + try: + yield client + finally: + try: + client.close() + except Exception: + pass + + +@pytest.fixture(scope="module") +async def async_sdk_client() -> AsyncIterator[AsyncRunloopSDK]: + """Provide an asynchronous AsyncRunloopSDK client for tests. + + Reads configuration from environment variables: + - RUNLOOP_API_KEY: Required API key + - RUNLOOP_BASE_URL: Optional API base URL + """ + base_url = os.getenv("RUNLOOP_BASE_URL") + bearer_token = os.getenv("RUNLOOP_API_KEY") + + if not bearer_token: + pytest.skip("RUNLOOP_API_KEY environment variable not set") + + client = AsyncRunloopSDK( + bearer_token=bearer_token, + base_url=base_url, + ) + + try: + async with client: + yield client + except Exception: + # If context manager fails, try manual cleanup + try: + await client.aclose() + except Exception: + pass diff --git a/tests/smoketests/sdk/test_async_blueprint.py b/tests/smoketests/sdk/test_async_blueprint.py new file mode 100644 index 000000000..45ec2df34 --- /dev/null +++ b/tests/smoketests/sdk/test_async_blueprint.py @@ -0,0 +1,302 @@ +"""Asynchronous SDK smoke tests for Blueprint operations.""" + +from __future__ import annotations + +import pytest + +from runloop_api_client.sdk import AsyncRunloopSDK +from tests.smoketests.utils import unique_name + +pytestmark = [pytest.mark.smoketest, pytest.mark.asyncio] + +THIRTY_SECOND_TIMEOUT = 30 +TWO_MINUTE_TIMEOUT = 120 + + +class TestAsyncBlueprintLifecycle: + """Test basic async blueprint lifecycle operations.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_blueprint_create_basic(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a basic blueprint with dockerfile.""" + name = unique_name("sdk-async-blueprint-basic") + blueprint = await async_sdk_client.blueprint.create( + name=name, + dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y curl", + ) + + try: + assert blueprint is not None + assert blueprint.id is not None + assert len(blueprint.id) > 0 + + # Verify it's built successfully + info = await blueprint.get_info() + assert info.status == "build_complete" + assert info.name == name + finally: + await blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_blueprint_create_with_system_setup(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a blueprint with system setup commands.""" + name = unique_name("sdk-async-blueprint-setup") + blueprint = await async_sdk_client.blueprint.create( + name=name, + dockerfile="FROM ubuntu:20.04", + system_setup_commands=[ + "sudo apt-get update", + "sudo apt-get install -y wget", + ], + ) + + try: + assert blueprint.id is not None + info = await blueprint.get_info() + assert info.status == "build_complete" + assert info.name == name + finally: + await blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_blueprint_get_info(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test retrieving blueprint information.""" + name = unique_name("sdk-async-blueprint-info") + blueprint = await async_sdk_client.blueprint.create( + name=name, + dockerfile="FROM ubuntu:20.04\nRUN echo 'test'", + ) + + try: + info = await blueprint.get_info() + + assert info.id == blueprint.id + assert info.status == "build_complete" + assert info.name == name + finally: + await blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_blueprint_delete(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test deleting a blueprint.""" + blueprint = await async_sdk_client.blueprint.create( + name=unique_name("sdk-async-blueprint-delete"), + dockerfile="FROM ubuntu:20.04", + ) + + blueprint_id = blueprint.id + result = await blueprint.delete() + + assert result is not None + # Verify it's deleted by checking status + info = await async_sdk_client.api.blueprints.retrieve(blueprint_id) + assert info.state == "deleted" + + +class TestAsyncBlueprintCreationVariations: + """Test different async blueprint creation scenarios.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + async def test_blueprint_with_base_blueprint(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a blueprint based on another blueprint.""" + # Create base blueprint + base_blueprint = await async_sdk_client.blueprint.create( + name=unique_name("sdk-async-blueprint-base"), + dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y curl", + ) + + try: + # Create derived blueprint + name = unique_name("sdk-async-blueprint-derived") + derived_blueprint = await async_sdk_client.blueprint.create( + name=name, + base_blueprint_id=base_blueprint.id, + system_setup_commands=["sudo apt-get install -y wget"], + ) + + try: + assert derived_blueprint.id is not None + info = await derived_blueprint.get_info() + assert info.status == "build_complete" + assert info.name == name + finally: + await derived_blueprint.delete() + finally: + await base_blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_blueprint_with_metadata(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a blueprint with metadata.""" + name = unique_name("sdk-async-blueprint-metadata") + metadata = { + "purpose": "sdk-async-testing", + "version": "1.0", + } + + blueprint = await async_sdk_client.blueprint.create( + name=name, + dockerfile="FROM ubuntu:20.04", + metadata=metadata, + ) + + try: + assert blueprint.id is not None + info = await blueprint.get_info() + assert info.status == "build_complete" + assert info.name == name + # Metadata should be preserved + assert info.metadata is not None and info.metadata == metadata + finally: + await blueprint.delete() + + +class TestAsyncBlueprintListing: + """Test async blueprint listing and retrieval operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_list_blueprints(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test listing blueprints.""" + blueprints = await async_sdk_client.blueprint.list(limit=10) + + assert isinstance(blueprints, list) + # List might be empty, that's okay + assert len(blueprints) >= 0 + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_get_blueprint_by_id(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test retrieving blueprint by ID.""" + # Create a blueprint + created = await async_sdk_client.blueprint.create( + name=unique_name("sdk-async-blueprint-retrieve"), + dockerfile="FROM ubuntu:20.04", + ) + + try: + # Retrieve it by ID + retrieved = async_sdk_client.blueprint.from_id(created.id) + assert retrieved.id == created.id + + # Verify it's the same blueprint + info = await retrieved.get_info() + assert info.id == created.id + finally: + await created.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_list_blueprints_by_name(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test listing blueprints filtered by name.""" + blueprint_name = unique_name("sdk-async-blueprint-list-name") + + # Create a blueprint with a specific name + blueprint = await async_sdk_client.blueprint.create( + name=blueprint_name, + dockerfile="FROM ubuntu:20.04", + ) + + try: + # List blueprints with that name + blueprints = await async_sdk_client.blueprint.list(name=blueprint_name) + + assert isinstance(blueprints, list) + assert len(blueprints) >= 1 + + # Should find our blueprint + blueprint_ids = [bp.id for bp in blueprints] + assert blueprint.id in blueprint_ids + finally: + await blueprint.delete() + + +class TestAsyncBlueprintDevboxIntegration: + """Test integration between async blueprints and devboxes.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + async def test_create_devbox_from_blueprint(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a devbox from a blueprint.""" + # Create a blueprint + blueprint = await async_sdk_client.blueprint.create( + name=unique_name("sdk-async-blueprint-for-devbox"), + dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y python3", + ) + + try: + # Create devbox from the blueprint + devbox = await async_sdk_client.devbox.create_from_blueprint_id( + blueprint.id, + name=unique_name("sdk-async-devbox-from-blueprint"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + assert devbox.id is not None + + # Verify devbox is running + info = await devbox.get_info() + assert info.status == "running" + + # Verify the blueprint's software is installed + result = await devbox.cmd.exec("which python3") + assert result.exit_code == 0 + assert result.success is True + finally: + await devbox.shutdown() + finally: + await blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + async def test_create_multiple_devboxes_from_blueprint(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating multiple devboxes from the same blueprint.""" + # Create a blueprint + blueprint = await async_sdk_client.blueprint.create( + name=unique_name("sdk-async-blueprint-multi-devbox"), + dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y curl", + ) + + try: + # Create first devbox + devbox1 = await async_sdk_client.devbox.create_from_blueprint_id( + blueprint.id, + name=unique_name("sdk-async-devbox-1"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + # Create second devbox + devbox2 = await async_sdk_client.devbox.create_from_blueprint_id( + blueprint.id, + name=unique_name("sdk-async-devbox-2"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + assert devbox1.id != devbox2.id + info1 = await devbox1.get_info() + info2 = await devbox2.get_info() + assert info1.status == "running" + assert info2.status == "running" + finally: + await devbox1.shutdown() + await devbox2.shutdown() + finally: + await blueprint.delete() + + +class TestAsyncBlueprintErrorHandling: + """Test async blueprint error handling scenarios.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_blueprint_invalid_dockerfile(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a blueprint with an invalid dockerfile.""" + # This should fail because INVALID_COMMAND doesn't exist + # We expect this to raise an error during build + try: + blueprint = await async_sdk_client.blueprint.create( + name=unique_name("sdk-async-blueprint-invalid"), + dockerfile="FROM ubuntu:20.04\nRUN INVALID_COMMAND_THAT_DOES_NOT_EXIST", + ) + # If it somehow succeeds, verify it failed during build + info = await blueprint.get_info() + assert info.status in ["failed", "error", "build_failed"] + await blueprint.delete() + except Exception: + # Expected to fail - this is the success case + pass diff --git a/tests/smoketests/sdk/test_async_devbox.py b/tests/smoketests/sdk/test_async_devbox.py new file mode 100644 index 000000000..1a001fbe8 --- /dev/null +++ b/tests/smoketests/sdk/test_async_devbox.py @@ -0,0 +1,578 @@ +"""Asynchronous SDK smoke tests for Devbox operations.""" + +from __future__ import annotations + +import tempfile +from typing import AsyncIterator +from pathlib import Path + +import pytest + +from runloop_api_client.sdk import AsyncDevbox, AsyncRunloopSDK +from tests.smoketests.utils import unique_name +from runloop_api_client.lib.polling import PollingConfig + +pytestmark = [pytest.mark.smoketest, pytest.mark.asyncio] + +THIRTY_SECOND_TIMEOUT = 30 +TWO_MINUTE_TIMEOUT = 120 + + +@pytest.fixture(scope="module") +async def shared_devbox(async_sdk_client: AsyncRunloopSDK) -> AsyncIterator[AsyncDevbox]: + """Create a shared devbox for tests that don't modify state.""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-shared"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 10}, + ) + try: + yield devbox + finally: + try: + await devbox.shutdown() + except Exception: + pass + + +class TestAsyncDevboxLifecycle: + """Test basic async devbox lifecycle operations.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_devbox_create(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a devbox and verify it reaches running state.""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-create"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + assert devbox is not None + assert devbox.id is not None + assert len(devbox.id) > 0 + + # Verify it's running + info = await devbox.get_info() + assert info.status == "running" + assert info.name is not None + + # Cleanup + await devbox.shutdown() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_devbox_get_info(self, shared_devbox: AsyncDevbox) -> None: + """Test retrieving devbox information.""" + info = await shared_devbox.get_info() + + assert info.id == shared_devbox.id + assert info.status == "running" + assert info.name is not None + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_devbox_shutdown(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test shutting down a devbox.""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-shutdown"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + result = await devbox.shutdown() + assert result.id == devbox.id + assert result.status == "shutdown" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_devbox_context_manager(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test devbox async context manager automatically shuts down on exit.""" + devbox_id = None + + async with await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-context"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) as devbox: + devbox_id = devbox.id + assert devbox.id is not None + + # Verify it's running + info = await devbox.get_info() + assert info.status == "running" + + # After exiting context, devbox should be shutdown + final_info = await async_sdk_client.api.devboxes.retrieve(devbox_id) + assert final_info.status == "shutdown" + + +class TestAsyncDevboxCommandExecution: + """Test async command execution on devboxes.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_exec_simple_command(self, shared_devbox: AsyncDevbox) -> None: + """Test executing a simple command asynchronously.""" + result = await shared_devbox.cmd.exec("echo 'Hello from async SDK!'") + + assert result is not None + assert result.exit_code == 0 + assert result.success is True + + stdout = await result.stdout() + assert "Hello from async SDK!" in stdout + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_exec_with_exit_code(self, shared_devbox: AsyncDevbox) -> None: + """Test command execution captures exit codes correctly.""" + result = await shared_devbox.cmd.exec("exit 42") + + assert result.exit_code == 42 + assert result.success is False + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_exec_async_command(self, shared_devbox: AsyncDevbox) -> None: + """Test executing a command asynchronously with exec_async.""" + execution = await shared_devbox.cmd.exec_async("echo 'Async command' && sleep 1") + + assert execution is not None + assert execution.execution_id is not None + + # Wait for completion + result = await execution.result() + assert result.exit_code == 0 + assert result.success is True + + stdout = await result.stdout() + assert "Async command" in stdout + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_exec_with_stdout_callback(self, shared_devbox: AsyncDevbox) -> None: + """Test command execution with stdout streaming callback.""" + stdout_lines: list[str] = [] + + def stdout_callback(line: str) -> None: + stdout_lines.append(line) + + result = await shared_devbox.cmd.exec( + 'echo "line1" && echo "line2" && echo "line3"', + stdout=stdout_callback, + ) + + assert result.success is True + assert result.exit_code == 0 + + # Verify callback received output + assert len(stdout_lines) > 0 + stdout_combined = "".join(stdout_lines) + assert "line1" in stdout_combined + assert "line2" in stdout_combined + assert "line3" in stdout_combined + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_exec_with_stderr_callback(self, shared_devbox: AsyncDevbox) -> None: + """Test command execution with stderr streaming callback.""" + stderr_lines: list[str] = [] + + def stderr_callback(line: str) -> None: + stderr_lines.append(line) + + result = await shared_devbox.cmd.exec( + 'echo "error1" >&2 && echo "error2" >&2', + stderr=stderr_callback, + ) + + assert result.success is True + assert result.exit_code == 0 + + # Verify callback received stderr output + assert len(stderr_lines) > 0 + stderr_combined = "".join(stderr_lines) + assert "error1" in stderr_combined + assert "error2" in stderr_combined + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_exec_with_output_callback(self, shared_devbox: AsyncDevbox) -> None: + """Test command execution with combined output callback.""" + output_lines: list[str] = [] + + def output_callback(line: str) -> None: + output_lines.append(line) + + result = await shared_devbox.cmd.exec( + 'echo "stdout1" && echo "stderr1" >&2 && echo "stdout2"', + output=output_callback, + ) + + assert result.success is True + assert result.exit_code == 0 + + # Verify callback received both stdout and stderr + assert len(output_lines) > 0 + output_combined = "".join(output_lines) + assert "stdout1" in output_combined or "stdout2" in output_combined + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_exec_async_with_callbacks(self, shared_devbox: AsyncDevbox) -> None: + """Test async execution with streaming callbacks.""" + stdout_lines: list[str] = [] + + def stdout_callback(line: str) -> None: + stdout_lines.append(line) + + execution = await shared_devbox.cmd.exec_async( + 'echo "async output"', + stdout=stdout_callback, + ) + + assert execution.execution_id is not None + + # Wait for completion + result = await execution.result() + assert result.success is True + assert result.exit_code == 0 + + # Verify streaming captured output + assert len(stdout_lines) > 0 + stdout_combined = "".join(stdout_lines) + assert "async output" in stdout_combined + + +class TestAsyncDevboxFileOperations: + """Test file operations on async devboxes.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_file_write_and_read(self, shared_devbox: AsyncDevbox) -> None: + """Test writing and reading files.""" + file_path = "/tmp/test_async_sdk_file.txt" + content = "Hello from async SDK file operations!" + + # Write file + await shared_devbox.file.write(file_path, content) + + # Read file + read_content = await shared_devbox.file.read(file_path) + assert read_content == content + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_file_write_bytes(self, shared_devbox: AsyncDevbox) -> None: + """Test writing bytes to a file.""" + file_path = "/tmp/test_async_sdk_bytes.txt" + content = b"Binary content from async SDK" + + # Write bytes + await shared_devbox.file.write(file_path, content) + + # Read and verify + read_content = await shared_devbox.file.read(file_path) + assert read_content == content.decode("utf-8") + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_file_download(self, shared_devbox: AsyncDevbox) -> None: + """Test downloading a file.""" + file_path = "/tmp/test_async_download.txt" + content = "Content to download" + + # Write file first + await shared_devbox.file.write(file_path, content) + + # Download file + downloaded = await shared_devbox.file.download(file_path) + assert isinstance(downloaded, bytes) + assert downloaded.decode("utf-8") == content + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_file_upload(self, shared_devbox: AsyncDevbox) -> None: + """Test uploading a file.""" + # Create a temporary file + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as tmp_file: + tmp_file.write("Uploaded content from async SDK") + tmp_path = tmp_file.name + + try: + # Upload file + remote_path = "~/uploaded_async_test.txt" + await shared_devbox.file.upload(remote_path, Path(tmp_path)) + + # Verify by reading + content = await shared_devbox.file.read(remote_path) + assert content == "Uploaded content from async SDK" + finally: + # Cleanup temp file + Path(tmp_path).unlink(missing_ok=True) + + +class TestAsyncDevboxStateManagement: + """Test async devbox state management operations.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_suspend_and_resume(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test suspending and resuming a devbox.""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-suspend"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Suspend the devbox + suspended_info = await devbox.suspend() + assert suspended_info.status == "suspended" + + # Verify suspended state + info = await devbox.get_info() + assert info.status == "suspended" + + # Resume the devbox + resumed_info = await devbox.resume() + assert resumed_info.status == "running" + + # Verify running state + info = await devbox.get_info() + assert info.status == "running" + finally: + await devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_await_running(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test await_running method.""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-await"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # It should already be running, but test the await method + result = await devbox.await_running(polling_config=PollingConfig(timeout_seconds=60, interval_seconds=2)) + assert result.status == "running" + finally: + await devbox.shutdown() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_keep_alive(self, shared_devbox: AsyncDevbox) -> None: + """Test sending keep-alive signal.""" + result = await shared_devbox.keep_alive() + assert result is not None + + +class TestAsyncDevboxNetworking: + """Test async devbox networking operations.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_create_ssh_key(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating SSH key for devbox.""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-ssh"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + ssh_key = await devbox.net.create_ssh_key() + assert ssh_key is not None + assert ssh_key.ssh_private_key is not None + finally: + await devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_create_and_remove_tunnel(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating and removing a tunnel.""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-tunnel"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create tunnel + tunnel = await devbox.net.create_tunnel(port=8080) + assert tunnel is not None + assert tunnel.url is not None + assert tunnel.port == 8080 + assert tunnel.devbox_id == devbox.id + + # Remove tunnel + await devbox.net.remove_tunnel(port=8080) + finally: + await devbox.shutdown() + + +class TestAsyncDevboxCreationMethods: + """Test various async devbox creation methods.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + async def test_create_from_blueprint_id(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating devbox from blueprint ID.""" + # First create a blueprint + blueprint = await async_sdk_client.blueprint.create( + name=unique_name("sdk-async-blueprint-for-devbox"), + dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y curl", + ) + + try: + # Create devbox from blueprint + devbox = await async_sdk_client.devbox.create_from_blueprint_id( + blueprint.id, + name=unique_name("sdk-async-devbox-from-blueprint-id"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + assert devbox.id is not None + info = await devbox.get_info() + assert info.status == "running" + finally: + await devbox.shutdown() + finally: + await blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + async def test_create_from_blueprint_name(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating devbox from blueprint name.""" + blueprint_name = unique_name("sdk-async-blueprint-name") + + # Create blueprint + blueprint = await async_sdk_client.blueprint.create( + name=blueprint_name, + dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y wget", + ) + + try: + # Create devbox from blueprint name + devbox = await async_sdk_client.devbox.create_from_blueprint_name( + blueprint_name, + name=unique_name("sdk-async-devbox-from-blueprint-name"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + assert devbox.id is not None + info = await devbox.get_info() + assert info.status == "running" + finally: + await devbox.shutdown() + finally: + await blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + async def test_create_from_snapshot(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating devbox from snapshot.""" + # Create source devbox + source_devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-for-snapshot"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create a file in the devbox + await source_devbox.file.write("/tmp/test_async_snapshot.txt", "Async snapshot test content") + + # Create snapshot + snapshot = await source_devbox.snapshot_disk( + name=unique_name("sdk-async-snapshot-for-devbox"), + ) + + try: + # Create devbox from snapshot + devbox = await async_sdk_client.devbox.create_from_snapshot( + snapshot.id, + name=unique_name("sdk-async-devbox-from-snapshot"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + assert devbox.id is not None + info = await devbox.get_info() + assert info.status == "running" + + # Verify snapshot content is present + content = await devbox.file.read("/tmp/test_async_snapshot.txt") + assert content == "Async snapshot test content" + finally: + await devbox.shutdown() + finally: + await snapshot.delete() + finally: + await source_devbox.shutdown() + + +class TestAsyncDevboxListing: + """Test async devbox listing and retrieval.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_list_devboxes(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test listing devboxes.""" + devboxes = await async_sdk_client.devbox.list(limit=10) + + assert isinstance(devboxes, list) + # We should have at least the shared devbox + assert len(devboxes) >= 0 + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_get_devbox_by_id(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test retrieving devbox by ID.""" + # Create a devbox + created = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-retrieve"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Retrieve it by ID + retrieved = async_sdk_client.devbox.from_id(created.id) + assert retrieved.id == created.id + + # Verify it's the same devbox + info = await retrieved.get_info() + assert info.id == created.id + finally: + await created.shutdown() + + +class TestAsyncDevboxSnapshots: + """Test snapshot operations on async devboxes.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_snapshot_disk(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a snapshot from devbox (synchronous wait).""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-snapshot"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create a file to snapshot + await devbox.file.write("/tmp/async_snapshot_test.txt", "Async snapshot content") + + # Create snapshot (waits for completion) + snapshot = await devbox.snapshot_disk( + name=unique_name("sdk-async-snapshot"), + ) + + try: + assert snapshot.id is not None + + # Verify snapshot info + info = await snapshot.get_info() + assert info.status == "complete" + finally: + await snapshot.delete() + finally: + await devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_snapshot_disk_async(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a snapshot asynchronously.""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-snapshot-async"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create snapshot asynchronously (returns immediately) + snapshot = await devbox.snapshot_disk_async( + name=unique_name("sdk-async-snapshot-async"), + ) + + try: + assert snapshot.id is not None + + # Wait for completion + await snapshot.await_completed() + + # Verify it's completed + info = await snapshot.get_info() + assert info.status == "complete" + finally: + await snapshot.delete() + finally: + await devbox.shutdown() diff --git a/tests/smoketests/sdk/test_async_sdk.py b/tests/smoketests/sdk/test_async_sdk.py new file mode 100644 index 000000000..3ef4edc30 --- /dev/null +++ b/tests/smoketests/sdk/test_async_sdk.py @@ -0,0 +1,33 @@ +"""Asynchronous SDK smoke tests for AsyncRunloopSDK initialization.""" + +from __future__ import annotations + +import pytest + +from runloop_api_client.sdk import AsyncRunloopSDK + +pytestmark = [pytest.mark.smoketest] + +FIVE_SECOND_TIMEOUT = 5 + + +class TestAsyncRunloopSDKInitialization: + """Test AsyncRunloopSDK client initialization and structure.""" + + @pytest.mark.timeout(FIVE_SECOND_TIMEOUT) + async def test_sdk_instance_creation(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test that async SDK instance is created successfully with all client properties.""" + assert async_sdk_client is not None + assert async_sdk_client.devbox is not None + assert async_sdk_client.blueprint is not None + assert async_sdk_client.snapshot is not None + assert async_sdk_client.storage_object is not None + + @pytest.mark.timeout(FIVE_SECOND_TIMEOUT) + async def test_legacy_api_access(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test that legacy API client is accessible through sdk.api.""" + assert async_sdk_client.api is not None + assert async_sdk_client.api.devboxes is not None + assert async_sdk_client.api.blueprints is not None + assert async_sdk_client.api.objects is not None + diff --git a/tests/smoketests/sdk/test_async_snapshot.py b/tests/smoketests/sdk/test_async_snapshot.py new file mode 100644 index 000000000..ca5013564 --- /dev/null +++ b/tests/smoketests/sdk/test_async_snapshot.py @@ -0,0 +1,413 @@ +"""Asynchronous SDK smoke tests for Snapshot operations.""" + +from __future__ import annotations + +import pytest + +from runloop_api_client.sdk import AsyncRunloopSDK +from tests.smoketests.utils import unique_name +from runloop_api_client.lib.polling import PollingConfig + +pytestmark = [pytest.mark.smoketest, pytest.mark.asyncio] + +THIRTY_SECOND_TIMEOUT = 30 +TWO_MINUTE_TIMEOUT = 120 + + +class TestAsyncSnapshotLifecycle: + """Test basic async snapshot lifecycle operations.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_snapshot_create_and_info(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a snapshot from devbox.""" + # Create a devbox + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-for-snapshot"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create a file to verify snapshot captures state + await devbox.file.write("/tmp/async_snapshot_marker.txt", "This file should be in snapshot") + + # Create snapshot + snapshot = await devbox.snapshot_disk( + name=unique_name("sdk-async-snapshot"), + ) + + try: + assert snapshot is not None + assert snapshot.id is not None + assert len(snapshot.id) > 0 + + # Get snapshot info + info = await snapshot.get_info() + assert info.status == "complete" + assert info.snapshot is not None and info.snapshot.id == snapshot.id + finally: + await snapshot.delete() + finally: + await devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_snapshot_with_commit_message(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a snapshot with commit message.""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-snapshot-commit"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + snapshot = await devbox.snapshot_disk( + name=unique_name("sdk-async-snapshot-commit"), + commit_message="Test async commit message from SDK", + ) + + try: + assert snapshot.id is not None + info = await snapshot.get_info() + assert info.status == "complete" + # Check if commit message is preserved + assert info.snapshot is not None and info.snapshot.commit_message == "Test async commit message from SDK" + finally: + await snapshot.delete() + finally: + await devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_snapshot_with_metadata(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a snapshot with metadata.""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-snapshot-metadata"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + metadata = { + "purpose": "sdk-async-testing", + "version": "1.0", + } + + snapshot = await devbox.snapshot_disk( + name=unique_name("sdk-async-snapshot-metadata"), + metadata=metadata, + ) + + try: + assert snapshot.id is not None + info = await snapshot.get_info() + assert info.status == "complete" + assert info.snapshot is not None and info.snapshot.metadata == metadata + finally: + await snapshot.delete() + finally: + await devbox.shutdown() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_snapshot_delete(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test deleting a snapshot.""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-snapshot-delete"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + snapshot = await devbox.snapshot_disk( + name=unique_name("sdk-async-snapshot-delete"), + ) + + snapshot_id = snapshot.id + assert snapshot_id is not None + + # Delete should succeed without error + result = await snapshot.delete() + assert result is not None + + # Verify it's deleted by checking the status + info = await snapshot.get_info() + # After deletion, the snapshot should have a status indicating it's deleted + assert info.status == "error" + finally: + await devbox.shutdown() + + +class TestAsyncSnapshotCompletion: + """Test async snapshot completion and status tracking.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_snapshot_await_completed(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test waiting for snapshot completion.""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-await-snapshot"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create snapshot asynchronously + snapshot = await devbox.snapshot_disk_async( + name=unique_name("sdk-async-snapshot-await"), + ) + + try: + # Wait for completion + completed_info = await snapshot.await_completed( + polling_config=PollingConfig(timeout_seconds=120, interval_seconds=5) + ) + + assert completed_info.status == "complete" + assert completed_info.snapshot is not None and completed_info.snapshot.id == snapshot.id + finally: + await snapshot.delete() + finally: + await devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_snapshot_status_tracking(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test tracking snapshot status through lifecycle.""" + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-status"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create snapshot asynchronously to see status progression + snapshot = await devbox.snapshot_disk_async( + name=unique_name("sdk-async-snapshot-status"), + ) + + try: + # Check initial status (might be in_progress or complete) + info = await snapshot.get_info() + assert info.status in ["in_progress", "complete"] + + # Wait for completion + await snapshot.await_completed() + + # Check final status + final_info = await snapshot.get_info() + assert final_info.status == "complete" + finally: + await snapshot.delete() + finally: + await devbox.shutdown() + + +class TestAsyncSnapshotDevboxRestoration: + """Test creating devboxes from snapshots asynchronously.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + async def test_restore_devbox_from_snapshot(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a devbox from a snapshot and verifying state is restored.""" + # Create source devbox + source_devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-source-devbox"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create unique content in source devbox + test_content = f"Async unique content: {unique_name('content')}" + await source_devbox.file.write("/tmp/test_async_restore.txt", test_content) + + # Create snapshot + snapshot = await source_devbox.snapshot_disk( + name=unique_name("sdk-async-snapshot-restore"), + ) + + try: + # Create new devbox from snapshot + restored_devbox = await async_sdk_client.devbox.create_from_snapshot( + snapshot.id, + name=unique_name("sdk-async-restored-devbox"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Verify devbox is running + assert restored_devbox.id is not None + info = await restored_devbox.get_info() + assert info.status == "running" + + # Verify content from snapshot is present + restored_content = await restored_devbox.file.read("/tmp/test_async_restore.txt") + assert restored_content == test_content + finally: + await restored_devbox.shutdown() + finally: + await snapshot.delete() + finally: + await source_devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + async def test_multiple_devboxes_from_snapshot(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating multiple devboxes from the same snapshot.""" + # Create source devbox + source_devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-source-multi"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create content + await source_devbox.file.write("/tmp/async_shared.txt", "Async shared content") + + # Create snapshot + snapshot = await source_devbox.snapshot_disk( + name=unique_name("sdk-async-snapshot-multi"), + ) + + try: + # Create first devbox from snapshot + devbox1 = await async_sdk_client.devbox.create_from_snapshot( + snapshot.id, + name=unique_name("sdk-async-restored-1"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + # Create second devbox from snapshot + devbox2 = await async_sdk_client.devbox.create_from_snapshot( + snapshot.id, + name=unique_name("sdk-async-restored-2"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Both should be running + assert devbox1.id != devbox2.id + info1 = await devbox1.get_info() + info2 = await devbox2.get_info() + assert info1.status == "running" + assert info2.status == "running" + + # Both should have the snapshot content + content1 = await devbox1.file.read("/tmp/async_shared.txt") + content2 = await devbox2.file.read("/tmp/async_shared.txt") + assert content1 == "Async shared content" + assert content2 == "Async shared content" + finally: + await devbox1.shutdown() + await devbox2.shutdown() + finally: + await snapshot.delete() + finally: + await source_devbox.shutdown() + + +class TestAsyncSnapshotListing: + """Test async snapshot listing and retrieval operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_list_snapshots(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test listing snapshots.""" + snapshots = await async_sdk_client.snapshot.list(limit=10) + + assert isinstance(snapshots, list) + # List might be empty, that's okay + assert len(snapshots) >= 0 + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_get_snapshot_by_id(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test retrieving snapshot by ID.""" + # Create a devbox and snapshot + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-retrieve-snapshot"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + snapshot = await devbox.snapshot_disk( + name=unique_name("sdk-async-snapshot-retrieve"), + ) + + try: + # Retrieve it by ID + retrieved = async_sdk_client.snapshot.from_id(snapshot.id) + assert retrieved.id == snapshot.id + + # Verify it's the same snapshot + info = await retrieved.get_info() + assert info.snapshot is not None and info.snapshot.id == snapshot.id + finally: + await snapshot.delete() + finally: + await devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_list_snapshots_by_devbox(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test listing snapshots filtered by devbox.""" + # Create a devbox + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-list-snapshots"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create snapshot + snapshot = await devbox.snapshot_disk( + name=unique_name("sdk-async-snapshot-list"), + ) + + try: + # List snapshots for this devbox + snapshots = await async_sdk_client.snapshot.list(devbox_id=devbox.id) + + assert isinstance(snapshots, list) + assert len(snapshots) >= 1 + + # Should find our snapshot + snapshot_ids = [s.id for s in snapshots] + assert snapshot.id in snapshot_ids + finally: + await snapshot.delete() + finally: + await devbox.shutdown() + + +class TestAsyncSnapshotEdgeCases: + """Test async snapshot edge cases and special scenarios.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + async def test_snapshot_preserves_file_permissions(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test that snapshot preserves file permissions.""" + # Create devbox + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-permissions"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create executable file + await devbox.file.write("/tmp/test_async_exec.sh", "#!/bin/bash\necho 'Hello'") + await devbox.cmd.exec("chmod +x /tmp/test_async_exec.sh") + + # Verify it's executable + result = await devbox.cmd.exec("test -x /tmp/test_async_exec.sh && echo 'executable'") + stdout = await result.stdout() + assert "executable" in stdout + + # Create snapshot + snapshot = await devbox.snapshot_disk( + name=unique_name("sdk-async-snapshot-permissions"), + ) + + try: + # Restore from snapshot + restored_devbox = await async_sdk_client.devbox.create_from_snapshot( + snapshot.id, + name=unique_name("sdk-async-restored-permissions"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Verify file is still executable + result = await restored_devbox.cmd.exec( + "test -x /tmp/test_async_exec.sh && echo 'still_executable'" + ) + stdout = await result.stdout() + assert "still_executable" in stdout + finally: + await restored_devbox.shutdown() + finally: + await snapshot.delete() + finally: + await devbox.shutdown() diff --git a/tests/smoketests/sdk/test_async_storage_object.py b/tests/smoketests/sdk/test_async_storage_object.py new file mode 100644 index 000000000..9d18fc10c --- /dev/null +++ b/tests/smoketests/sdk/test_async_storage_object.py @@ -0,0 +1,467 @@ +"""Asynchronous SDK smoke tests for Storage Object operations.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +from runloop_api_client.sdk import AsyncRunloopSDK +from tests.smoketests.utils import unique_name + +pytestmark = [pytest.mark.smoketest, pytest.mark.asyncio] + +THIRTY_SECOND_TIMEOUT = 30 +TWO_MINUTE_TIMEOUT = 120 + + +class TestAsyncStorageObjectLifecycle: + """Test basic async storage object lifecycle operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_storage_object_create(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a storage object.""" + obj = await async_sdk_client.storage_object.create( + name=unique_name("sdk-async-storage-object"), + content_type="text", + metadata={"test": "sdk-async-smoketest"}, + ) + + try: + assert obj is not None + assert obj.id is not None + assert len(obj.id) > 0 + assert obj.upload_url is not None + finally: + await obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_storage_object_get_info(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test retrieving storage object information.""" + obj = await async_sdk_client.storage_object.create( + name=unique_name("sdk-async-storage-object-info"), + content_type="text", + ) + + try: + info = await obj.refresh() + + assert info.id == obj.id + assert info.name is not None + assert info.content_type == "text" + finally: + await obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_storage_object_upload_and_complete(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test uploading content and completing object.""" + obj = await async_sdk_client.storage_object.create( + name=unique_name("sdk-async-storage-upload"), + content_type="text", + ) + + try: + # Upload content + await obj.upload_content("Hello from async SDK storage!") + + # Complete the object + result = await obj.complete() + assert result is not None + assert result.state == "READ_ONLY" + finally: + await obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_storage_object_delete(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test deleting a storage object.""" + obj = await async_sdk_client.storage_object.create( + name=unique_name("sdk-async-storage-delete"), + content_type="text", + ) + + obj_id = obj.id + result = await obj.delete() + + assert result is not None + # Verify it's deleted + info = await async_sdk_client.api.objects.retrieve(obj_id) + assert info.state == "DELETED" + + +class TestAsyncStorageObjectUploadMethods: + """Test various async storage object upload methods.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_upload_from_text(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test uploading from text.""" + text_content = "Hello from async upload_from_text!" + obj = await async_sdk_client.storage_object.upload_from_text( + text_content, + unique_name("sdk-async-text-upload"), + metadata={"source": "upload_from_text"}, + ) + + try: + assert obj.id is not None + + # Verify content + downloaded = await obj.download_as_text() + assert downloaded == text_content + finally: + await obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_upload_from_bytes(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test uploading from bytes.""" + bytes_content = b"Binary content from async SDK" + obj = await async_sdk_client.storage_object.upload_from_bytes( + bytes_content, + unique_name("sdk-async-bytes-upload"), + content_type="text", + metadata={"source": "upload_from_bytes"}, + ) + + try: + assert obj.id is not None + + # Verify content + downloaded = await obj.download_as_bytes() + assert downloaded == bytes_content + finally: + await obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_upload_from_file(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test uploading from file.""" + # Create temporary file + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as tmp_file: + tmp_file.write("Content from async file upload") + tmp_path = tmp_file.name + + try: + obj = await async_sdk_client.storage_object.upload_from_file( + tmp_path, + unique_name("sdk-async-file-upload"), + metadata={"source": "upload_from_file"}, + ) + + try: + assert obj.id is not None + + # Verify content + downloaded = await obj.download_as_text() + assert downloaded == "Content from async file upload" + finally: + await obj.delete() + finally: + Path(tmp_path).unlink(missing_ok=True) + + +class TestAsyncStorageObjectDownloadMethods: + """Test async storage object download methods.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_download_as_text(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test downloading content as text.""" + content = "Async text content to download" + obj = await async_sdk_client.storage_object.upload_from_text( + content, + unique_name("sdk-async-download-text"), + ) + + try: + downloaded = await obj.download_as_text() + assert downloaded == content + finally: + await obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_download_as_bytes(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test downloading content as bytes.""" + content = b"Async bytes content to download" + obj = await async_sdk_client.storage_object.upload_from_bytes( + content, + unique_name("sdk-async-download-bytes"), + content_type="text", + ) + + try: + downloaded = await obj.download_as_bytes() + assert downloaded == content + assert isinstance(downloaded, bytes) + finally: + await obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_get_download_url(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test getting download URL.""" + obj = await async_sdk_client.storage_object.upload_from_text( + "Content for async URL", + unique_name("sdk-async-download-url"), + ) + + try: + url_info = await obj.get_download_url(duration_seconds=3600) + assert url_info.download_url is not None + assert "http" in url_info.download_url + finally: + await obj.delete() + + +class TestAsyncStorageObjectListing: + """Test async storage object listing and retrieval operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_list_storage_objects(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test listing storage objects.""" + objects = await async_sdk_client.storage_object.list(limit=10) + + assert isinstance(objects, list) + # List might be empty, that's okay + assert len(objects) >= 0 + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_get_storage_object_by_id(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test retrieving storage object by ID.""" + # Create an object + created = await async_sdk_client.storage_object.upload_from_text( + "Content for async retrieval", + unique_name("sdk-async-storage-retrieve"), + ) + + try: + # Retrieve it by ID + retrieved = async_sdk_client.storage_object.from_id(created.id) + assert retrieved.id == created.id + + # Verify it's the same object + info = await retrieved.refresh() + assert info.id == created.id + finally: + await created.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_list_storage_objects_by_content_type(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test listing storage objects filtered by content type.""" + # Create object with specific content type + obj = await async_sdk_client.storage_object.upload_from_text( + "Text content", + unique_name("sdk-async-storage-list-type"), + ) + + try: + # List objects with text content type + objects = await async_sdk_client.storage_object.list(content_type="text", limit=10) + + assert isinstance(objects, list) + # Should find our object + object_ids = [o.id for o in objects] + assert obj.id in object_ids + finally: + await obj.delete() + + +class TestAsyncStorageObjectDevboxIntegration: + """Test async storage object integration with devboxes.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_mount_storage_object_to_devbox(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test mounting storage object to devbox.""" + # Create storage object with content + obj = await async_sdk_client.storage_object.upload_from_text( + "Async mounted content from SDK", + unique_name("sdk-async-mount-object"), + ) + + try: + # Create devbox with mounted storage object + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-mount"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + mounts=[ + { + "type": "object_mount", + "object_id": obj.id, + "object_path": "/home/user/async-mounted-data", + } + ], + ) + + try: + assert devbox.id is not None + info = await devbox.get_info() + assert info.status == "running" + finally: + await devbox.shutdown() + finally: + await obj.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_access_mounted_storage_object(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test accessing mounted storage object content in devbox.""" + # Create storage object + obj = await async_sdk_client.storage_object.upload_from_text( + "Async content to mount and access", + unique_name("sdk-async-mount-access"), + ) + + try: + # Create devbox with mounted storage object + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-mount-access"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + mounts=[ + { + "type": "object_mount", + "object_id": obj.id, + "object_path": "/home/user/async-mounted-file", + } + ], + ) + + try: + # Read the mounted file + content = await devbox.file.read("/home/user/async-mounted-file") + assert content == "Async content to mount and access" + + # Verify file exists via command + result = await devbox.cmd.exec("test -f /home/user/async-mounted-file && echo 'exists'") + stdout = await result.stdout() + assert "exists" in stdout + finally: + await devbox.shutdown() + finally: + await obj.delete() + + +class TestAsyncStorageObjectEdgeCases: + """Test async storage object edge cases and special scenarios.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_storage_object_large_content(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test uploading larger content.""" + # Create 1MB of content + large_content = "x" * (1024 * 1024) + + obj = await async_sdk_client.storage_object.upload_from_text( + large_content, + unique_name("sdk-async-storage-large"), + ) + + try: + # Verify content + downloaded = await obj.download_as_text() + assert len(downloaded) == len(large_content) + assert downloaded == large_content + finally: + await obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_storage_object_binary_content(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test uploading binary content.""" + # Create some binary data + binary_content = bytes(range(256)) + + obj = await async_sdk_client.storage_object.upload_from_bytes( + binary_content, + unique_name("sdk-async-storage-binary"), + content_type="binary", + ) + + try: + # Verify content + downloaded = await obj.download_as_bytes() + assert downloaded == binary_content + finally: + await obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_storage_object_empty_content(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test uploading empty content.""" + obj = await async_sdk_client.storage_object.upload_from_text( + "", + unique_name("sdk-async-storage-empty"), + ) + + try: + # Verify content + downloaded = await obj.download_as_text() + assert downloaded == "" + finally: + await obj.delete() + + +class TestAsyncStorageObjectWorkflows: + """Test complete async storage object workflows.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_complete_upload_download_workflow(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test complete workflow: create, upload, complete, download, delete.""" + # Create object + obj = await async_sdk_client.storage_object.create( + name=unique_name("sdk-async-storage-workflow"), + content_type="text", + metadata={"workflow": "async-test"}, + ) + + try: + # Upload content + original_content = "Async workflow test content" + await obj.upload_content(original_content) + + # Complete + result = await obj.complete() + assert result.state == "READ_ONLY" + + # Download and verify + downloaded = await obj.download_as_text() + assert downloaded == original_content + + # Refresh info + info = await obj.refresh() + assert info.state == "READ_ONLY" + finally: + # Delete + await obj.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_storage_object_in_devbox_workflow(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test workflow: create storage object, write from devbox, download.""" + # Create empty storage object + obj = await async_sdk_client.storage_object.create( + name=unique_name("sdk-async-storage-devbox-workflow"), + content_type="text", + ) + + try: + # Upload initial content + await obj.upload_content("Async initial content") + await obj.complete() + + # Create devbox with mounted object + devbox = await async_sdk_client.devbox.create( + name=unique_name("sdk-async-devbox-workflow"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + mounts=[ + { + "type": "object_mount", + "object_id": obj.id, + "object_path": "/home/user/async-workflow-data", + } + ], + ) + + try: + # Read mounted content in devbox + content = await devbox.file.read("/home/user/async-workflow-data") + assert content == "Async initial content" + + # Verify we can work with the file + result = await devbox.cmd.exec("cat /home/user/async-workflow-data") + stdout = await result.stdout() + assert "Async initial content" in stdout + finally: + await devbox.shutdown() + finally: + await obj.delete() diff --git a/tests/smoketests/sdk/test_blueprint.py b/tests/smoketests/sdk/test_blueprint.py new file mode 100644 index 000000000..3d2c3a7e8 --- /dev/null +++ b/tests/smoketests/sdk/test_blueprint.py @@ -0,0 +1,299 @@ +"""Synchronous SDK smoke tests for Blueprint operations.""" + +from __future__ import annotations + +import pytest + +from runloop_api_client.sdk import RunloopSDK +from tests.smoketests.utils import unique_name + +pytestmark = [pytest.mark.smoketest] + +THIRTY_SECOND_TIMEOUT = 30 +TWO_MINUTE_TIMEOUT = 120 + + +class TestBlueprintLifecycle: + """Test basic blueprint lifecycle operations.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_blueprint_create_basic(self, sdk_client: RunloopSDK) -> None: + """Test creating a basic blueprint with dockerfile.""" + name = unique_name("sdk-blueprint-basic") + blueprint = sdk_client.blueprint.create( + name=name, + dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y curl", + ) + + try: + assert blueprint is not None + assert blueprint.id is not None + assert len(blueprint.id) > 0 + + # Verify it's built successfully + info = blueprint.get_info() + assert info.status == "build_complete" + assert info.name == name + finally: + blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_blueprint_create_with_system_setup(self, sdk_client: RunloopSDK) -> None: + """Test creating a blueprint with system setup commands.""" + name = unique_name("sdk-blueprint-setup") + blueprint = sdk_client.blueprint.create( + name=name, + dockerfile="FROM ubuntu:20.04", + system_setup_commands=[ + "sudo apt-get update", + "sudo apt-get install -y wget", + ], + ) + + try: + assert blueprint.id is not None + info = blueprint.get_info() + assert info.status == "build_complete" + assert info.name == name + finally: + blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_blueprint_get_info(self, sdk_client: RunloopSDK) -> None: + """Test retrieving blueprint information.""" + name = unique_name("sdk-blueprint-info") + blueprint = sdk_client.blueprint.create( + name=name, + dockerfile="FROM ubuntu:20.04\nRUN echo 'test'", + ) + + try: + info = blueprint.get_info() + + assert info.id == blueprint.id + assert info.status == "build_complete" + assert info.name == name + finally: + blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_blueprint_delete(self, sdk_client: RunloopSDK) -> None: + """Test deleting a blueprint.""" + blueprint = sdk_client.blueprint.create( + name=unique_name("sdk-blueprint-delete"), + dockerfile="FROM ubuntu:20.04", + ) + + blueprint_id = blueprint.id + result = blueprint.delete() + + assert result is not None + # Verify it's deleted by checking status + info = sdk_client.api.blueprints.retrieve(blueprint_id) + assert info.state == "deleted" + +class TestBlueprintCreationVariations: + """Test different blueprint creation scenarios.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + def test_blueprint_with_base_blueprint(self, sdk_client: RunloopSDK) -> None: + """Test creating a blueprint based on another blueprint.""" + # Create base blueprint + base_blueprint = sdk_client.blueprint.create( + name=unique_name("sdk-blueprint-base"), + dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y curl", + ) + + try: + # Create derived blueprint + name = unique_name("sdk-blueprint-derived") + derived_blueprint = sdk_client.blueprint.create( + name=name, + base_blueprint_id=base_blueprint.id, + system_setup_commands=["sudo apt-get install -y wget"], + ) + + try: + assert derived_blueprint.id is not None + info = derived_blueprint.get_info() + assert info.status == "build_complete" + assert info.name == name + finally: + derived_blueprint.delete() + finally: + base_blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_blueprint_with_metadata(self, sdk_client: RunloopSDK) -> None: + """Test creating a blueprint with metadata.""" + name = unique_name("sdk-blueprint-metadata") + metadata = { + "purpose": "sdk-testing", + "version": "1.0", + } + + blueprint = sdk_client.blueprint.create( + name=name, + dockerfile="FROM ubuntu:20.04", + metadata=metadata, + ) + + try: + assert blueprint.id is not None + info = blueprint.get_info() + assert info.status == "build_complete" + assert info.name == name + # Metadata should be preserved + assert info.metadata is not None and info.metadata == metadata + finally: + blueprint.delete() + + +class TestBlueprintListing: + """Test blueprint listing and retrieval operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_list_blueprints(self, sdk_client: RunloopSDK) -> None: + """Test listing blueprints.""" + blueprints = sdk_client.blueprint.list(limit=10) + + assert isinstance(blueprints, list) + # List might be empty, that's okay + assert len(blueprints) >= 0 + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_get_blueprint_by_id(self, sdk_client: RunloopSDK) -> None: + """Test retrieving blueprint by ID.""" + # Create a blueprint + created = sdk_client.blueprint.create( + name=unique_name("sdk-blueprint-retrieve"), + dockerfile="FROM ubuntu:20.04", + ) + + try: + # Retrieve it by ID + retrieved = sdk_client.blueprint.from_id(created.id) + assert retrieved.id == created.id + + # Verify it's the same blueprint + info = retrieved.get_info() + assert info.id == created.id + finally: + created.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_list_blueprints_by_name(self, sdk_client: RunloopSDK) -> None: + """Test listing blueprints filtered by name.""" + blueprint_name = unique_name("sdk-blueprint-list-name") + + # Create a blueprint with a specific name + blueprint = sdk_client.blueprint.create( + name=blueprint_name, + dockerfile="FROM ubuntu:20.04", + ) + + try: + # List blueprints with that name + blueprints = sdk_client.blueprint.list(name=blueprint_name) + + assert isinstance(blueprints, list) + assert len(blueprints) >= 1 + + # Should find our blueprint + blueprint_ids = [bp.id for bp in blueprints] + assert blueprint.id in blueprint_ids + finally: + blueprint.delete() + + +class TestBlueprintDevboxIntegration: + """Test integration between blueprints and devboxes.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + def test_create_devbox_from_blueprint(self, sdk_client: RunloopSDK) -> None: + """Test creating a devbox from a blueprint.""" + # Create a blueprint + blueprint = sdk_client.blueprint.create( + name=unique_name("sdk-blueprint-for-devbox"), + dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y python3", + ) + + try: + # Create devbox from the blueprint + devbox = sdk_client.devbox.create_from_blueprint_id( + blueprint.id, + name=unique_name("sdk-devbox-from-blueprint"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + assert devbox.id is not None + + # Verify devbox is running + info = devbox.get_info() + assert info.status == "running" + + # Verify the blueprint's software is installed + result = devbox.cmd.exec("which python3") + assert result.exit_code == 0 + assert result.success is True + finally: + devbox.shutdown() + finally: + blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + def test_create_multiple_devboxes_from_blueprint(self, sdk_client: RunloopSDK) -> None: + """Test creating multiple devboxes from the same blueprint.""" + # Create a blueprint + blueprint = sdk_client.blueprint.create( + name=unique_name("sdk-blueprint-multi-devbox"), + dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y curl", + ) + + try: + # Create first devbox + devbox1 = sdk_client.devbox.create_from_blueprint_id( + blueprint.id, + name=unique_name("sdk-devbox-1"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + # Create second devbox + devbox2 = sdk_client.devbox.create_from_blueprint_id( + blueprint.id, + name=unique_name("sdk-devbox-2"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + assert devbox1.id != devbox2.id + assert devbox1.get_info().status == "running" + assert devbox2.get_info().status == "running" + finally: + devbox1.shutdown() + devbox2.shutdown() + finally: + blueprint.delete() + + +class TestBlueprintErrorHandling: + """Test blueprint error handling scenarios.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_blueprint_invalid_dockerfile(self, sdk_client: RunloopSDK) -> None: + """Test creating a blueprint with an invalid dockerfile.""" + # This should fail because INVALID_COMMAND doesn't exist + # We expect this to raise an error during build + try: + blueprint = sdk_client.blueprint.create( + name=unique_name("sdk-blueprint-invalid"), + dockerfile="FROM ubuntu:20.04\nRUN INVALID_COMMAND_THAT_DOES_NOT_EXIST", + ) + # If it somehow succeeds, verify it failed during build + info = blueprint.get_info() + assert info.status in ["failed", "error", "build_failed"] + blueprint.delete() + except Exception: + # Expected to fail - this is the success case + pass diff --git a/tests/smoketests/sdk/test_devbox.py b/tests/smoketests/sdk/test_devbox.py new file mode 100644 index 000000000..1ae2f0e05 --- /dev/null +++ b/tests/smoketests/sdk/test_devbox.py @@ -0,0 +1,579 @@ +"""Synchronous SDK smoke tests for Devbox operations.""" + +from __future__ import annotations + +import tempfile +from typing import Iterator +from pathlib import Path + +import pytest + +from runloop_api_client.sdk import Devbox, RunloopSDK +from tests.smoketests.utils import unique_name +from runloop_api_client.lib.polling import PollingConfig + +pytestmark = [pytest.mark.smoketest] + +THIRTY_SECOND_TIMEOUT = 30 +TWO_MINUTE_TIMEOUT = 120 + + +@pytest.fixture(scope="module") +def shared_devbox(sdk_client: RunloopSDK) -> Iterator[Devbox]: + """Create a shared devbox for tests that don't modify state.""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-shared"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 10}, + ) + try: + yield devbox + finally: + try: + devbox.shutdown() + except Exception: + pass + + +class TestDevboxLifecycle: + """Test basic devbox lifecycle operations.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_devbox_create(self, sdk_client: RunloopSDK) -> None: + """Test creating a devbox and verify it reaches running state.""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-create"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + assert devbox is not None + assert devbox.id is not None + assert len(devbox.id) > 0 + + # Verify it's running + info = devbox.get_info() + assert info.status == "running" + assert info.name is not None + + # Cleanup + devbox.shutdown() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_devbox_get_info(self, shared_devbox: Devbox) -> None: + """Test retrieving devbox information.""" + info = shared_devbox.get_info() + + assert info.id == shared_devbox.id + assert info.status == "running" + assert info.name is not None + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_devbox_shutdown(self, sdk_client: RunloopSDK) -> None: + """Test shutting down a devbox.""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-shutdown"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + result = devbox.shutdown() + assert result.id == devbox.id + assert result.status == "shutdown" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_devbox_context_manager(self, sdk_client: RunloopSDK) -> None: + """Test devbox context manager automatically shuts down on exit.""" + devbox_id = None + + with sdk_client.devbox.create( + name=unique_name("sdk-devbox-context"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) as devbox: + devbox_id = devbox.id + assert devbox.id is not None + + # Verify it's running + info = devbox.get_info() + assert info.status == "running" + + # After exiting context, devbox should be shutdown + # We can verify by checking the status + final_info = sdk_client.api.devboxes.retrieve(devbox_id) + assert final_info.status == "shutdown" + + +class TestDevboxCommandExecution: + """Test command execution on devboxes.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_exec_simple_command(self, shared_devbox: Devbox) -> None: + """Test executing a simple command synchronously.""" + result = shared_devbox.cmd.exec("echo 'Hello from SDK!'") + + assert result is not None + assert result.exit_code == 0 + assert result.success is True + + stdout = result.stdout() + assert "Hello from SDK!" in stdout + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_exec_with_exit_code(self, shared_devbox: Devbox) -> None: + """Test command execution captures exit codes correctly.""" + result = shared_devbox.cmd.exec("exit 42") + + assert result.exit_code == 42 + assert result.success is False + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_exec_async_command(self, shared_devbox: Devbox) -> None: + """Test executing a command asynchronously.""" + execution = shared_devbox.cmd.exec_async("echo 'Async command' && sleep 1") + + assert execution is not None + assert execution.execution_id is not None + + # Wait for completion + result = execution.result() + assert result.exit_code == 0 + assert result.success is True + + stdout = result.stdout() + assert "Async command" in stdout + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_exec_with_stdout_callback(self, shared_devbox: Devbox) -> None: + """Test command execution with stdout streaming callback.""" + stdout_lines: list[str] = [] + + def stdout_callback(line: str) -> None: + stdout_lines.append(line) + + result = shared_devbox.cmd.exec( + 'echo "line1" && echo "line2" && echo "line3"', + stdout=stdout_callback, + ) + + assert result.success is True + assert result.exit_code == 0 + + # Verify callback received output + assert len(stdout_lines) > 0 + stdout_combined = "".join(stdout_lines) + assert "line1" in stdout_combined + assert "line2" in stdout_combined + assert "line3" in stdout_combined + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_exec_with_stderr_callback(self, shared_devbox: Devbox) -> None: + """Test command execution with stderr streaming callback.""" + stderr_lines: list[str] = [] + + def stderr_callback(line: str) -> None: + stderr_lines.append(line) + + result = shared_devbox.cmd.exec( + 'echo "error1" >&2 && echo "error2" >&2', + stderr=stderr_callback, + ) + + assert result.success is True + assert result.exit_code == 0 + + # Verify callback received stderr output + assert len(stderr_lines) > 0 + stderr_combined = "".join(stderr_lines) + assert "error1" in stderr_combined + assert "error2" in stderr_combined + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_exec_with_output_callback(self, shared_devbox: Devbox) -> None: + """Test command execution with combined output callback.""" + output_lines: list[str] = [] + + def output_callback(line: str) -> None: + output_lines.append(line) + + result = shared_devbox.cmd.exec( + 'echo "stdout1" && echo "stderr1" >&2 && echo "stdout2"', + output=output_callback, + ) + + assert result.success is True + assert result.exit_code == 0 + + # Verify callback received both stdout and stderr + assert len(output_lines) > 0 + output_combined = "".join(output_lines) + assert "stdout1" in output_combined or "stdout2" in output_combined + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_exec_async_with_callbacks(self, shared_devbox: Devbox) -> None: + """Test async execution with streaming callbacks.""" + stdout_lines: list[str] = [] + + def stdout_callback(line: str) -> None: + stdout_lines.append(line) + + execution = shared_devbox.cmd.exec_async( + 'echo "async output"', + stdout=stdout_callback, + ) + + assert execution.execution_id is not None + + # Wait for completion + result = execution.result() + assert result.success is True + assert result.exit_code == 0 + + # Verify streaming captured output + assert len(stdout_lines) > 0 + stdout_combined = "".join(stdout_lines) + assert "async output" in stdout_combined + + +class TestDevboxFileOperations: + """Test file operations on devboxes.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_file_write_and_read(self, shared_devbox: Devbox) -> None: + """Test writing and reading files.""" + file_path = "/tmp/test_sdk_file.txt" + content = "Hello from SDK file operations!" + + # Write file + shared_devbox.file.write(file_path, content) + + # Read file + read_content = shared_devbox.file.read(file_path) + assert read_content == content + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_file_write_bytes(self, shared_devbox: Devbox) -> None: + """Test writing bytes to a file.""" + file_path = "/tmp/test_sdk_bytes.txt" + content = b"Binary content from SDK" + + # Write bytes + shared_devbox.file.write(file_path, content) + + # Read and verify + read_content = shared_devbox.file.read(file_path) + assert read_content == content.decode("utf-8") + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_file_download(self, shared_devbox: Devbox) -> None: + """Test downloading a file.""" + file_path = "/tmp/test_download.txt" + content = "Content to download" + + # Write file first + shared_devbox.file.write(file_path, content) + + # Download file + downloaded = shared_devbox.file.download(file_path) + assert isinstance(downloaded, bytes) + assert downloaded.decode("utf-8") == content + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_file_upload(self, shared_devbox: Devbox) -> None: + """Test uploading a file.""" + # Create a temporary file + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as tmp_file: + tmp_file.write("Uploaded content from SDK") + tmp_path = tmp_file.name + + try: + # Upload file + remote_path = "~/uploaded_test.txt" + shared_devbox.file.upload(remote_path, Path(tmp_path)) + + # Verify by reading + content = shared_devbox.file.read(remote_path) + assert content == "Uploaded content from SDK" + finally: + # Cleanup temp file + Path(tmp_path).unlink(missing_ok=True) + + +class TestDevboxStateManagement: + """Test devbox state management operations.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_suspend_and_resume(self, sdk_client: RunloopSDK) -> None: + """Test suspending and resuming a devbox.""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-suspend"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Suspend the devbox + suspended_info = devbox.suspend() + assert suspended_info.status == "suspended" + + # Verify suspended state + info = devbox.get_info() + assert info.status == "suspended" + + # Resume the devbox + resumed_info = devbox.resume() + assert resumed_info.status == "running" + + # Verify running state + info = devbox.get_info() + assert info.status == "running" + finally: + devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_await_running(self, sdk_client: RunloopSDK) -> None: + """Test await_running method.""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-await"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # It should already be running, but test the await method + result = devbox.await_running(polling_config=PollingConfig(timeout_seconds=60, interval_seconds=2)) + assert result.status == "running" + finally: + devbox.shutdown() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_keep_alive(self, shared_devbox: Devbox) -> None: + """Test sending keep-alive signal.""" + result = shared_devbox.keep_alive() + assert result is not None + + +class TestDevboxNetworking: + """Test devbox networking operations.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_create_ssh_key(self, sdk_client: RunloopSDK) -> None: + """Test creating SSH key for devbox.""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-ssh"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + ssh_key = devbox.net.create_ssh_key() + assert ssh_key is not None + assert ssh_key.ssh_private_key is not None + finally: + devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_create_and_remove_tunnel(self, sdk_client: RunloopSDK) -> None: + """Test creating and removing a tunnel.""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-tunnel"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create tunnel + tunnel = devbox.net.create_tunnel(port=8080) + assert tunnel is not None + assert tunnel.url is not None + assert tunnel.port == 8080 + assert tunnel.devbox_id == devbox.id + + # Remove tunnel + devbox.net.remove_tunnel(port=8080) + finally: + devbox.shutdown() + + +class TestDevboxCreationMethods: + """Test various devbox creation methods.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + def test_create_from_blueprint_id(self, sdk_client: RunloopSDK) -> None: + """Test creating devbox from blueprint ID.""" + # First create a blueprint + blueprint = sdk_client.blueprint.create( + name=unique_name("sdk-blueprint-for-devbox"), + dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y curl", + ) + + try: + # Create devbox from blueprint + devbox = sdk_client.devbox.create_from_blueprint_id( + blueprint.id, + name=unique_name("sdk-devbox-from-blueprint-id"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + assert devbox.id is not None + info = devbox.get_info() + assert info.status == "running" + finally: + devbox.shutdown() + finally: + blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + def test_create_from_blueprint_name(self, sdk_client: RunloopSDK) -> None: + """Test creating devbox from blueprint name.""" + blueprint_name = unique_name("sdk-blueprint-name") + + # Create blueprint + blueprint = sdk_client.blueprint.create( + name=blueprint_name, + dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y wget", + ) + + try: + # Create devbox from blueprint name + devbox = sdk_client.devbox.create_from_blueprint_name( + blueprint_name, + name=unique_name("sdk-devbox-from-blueprint-name"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + assert devbox.id is not None + info = devbox.get_info() + assert info.status == "running" + finally: + devbox.shutdown() + finally: + blueprint.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + def test_create_from_snapshot(self, sdk_client: RunloopSDK) -> None: + """Test creating devbox from snapshot.""" + # Create source devbox + source_devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-for-snapshot"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create a file in the devbox + source_devbox.file.write("/tmp/test_snapshot.txt", "Snapshot test content") + + # Create snapshot + snapshot = source_devbox.snapshot_disk( + name=unique_name("sdk-snapshot-for-devbox"), + ) + + try: + # Create devbox from snapshot + devbox = sdk_client.devbox.create_from_snapshot( + snapshot.id, + name=unique_name("sdk-devbox-from-snapshot"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + assert devbox.id is not None + info = devbox.get_info() + assert info.status == "running" + + # Verify snapshot content is present + content = devbox.file.read("/tmp/test_snapshot.txt") + assert content == "Snapshot test content" + finally: + devbox.shutdown() + finally: + snapshot.delete() + finally: + source_devbox.shutdown() + + +class TestDevboxListing: + """Test devbox listing and retrieval.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_list_devboxes(self, sdk_client: RunloopSDK) -> None: + """Test listing devboxes.""" + devboxes = sdk_client.devbox.list(limit=10) + + assert isinstance(devboxes, list) + # We should have at least the shared devbox + assert len(devboxes) >= 0 + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_get_devbox_by_id(self, sdk_client: RunloopSDK) -> None: + """Test retrieving devbox by ID.""" + # Create a devbox + created = sdk_client.devbox.create( + name=unique_name("sdk-devbox-retrieve"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Retrieve it by ID + retrieved = sdk_client.devbox.from_id(created.id) + assert retrieved.id == created.id + + # Verify it's the same devbox + info = retrieved.get_info() + assert info.id == created.id + finally: + created.shutdown() + + +class TestDevboxSnapshots: + """Test snapshot operations on devboxes.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_snapshot_disk(self, sdk_client: RunloopSDK) -> None: + """Test creating a snapshot from devbox (synchronous).""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-snapshot"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create a file to snapshot + devbox.file.write("/tmp/snapshot_test.txt", "Snapshot content") + + # Create snapshot (waits for completion) + snapshot = devbox.snapshot_disk( + name=unique_name("sdk-snapshot"), + ) + + try: + assert snapshot.id is not None + + # Verify snapshot info + info = snapshot.get_info() + assert info.status == "complete" + finally: + snapshot.delete() + finally: + devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_snapshot_disk_async(self, sdk_client: RunloopSDK) -> None: + """Test creating a snapshot asynchronously.""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-snapshot-async"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create snapshot asynchronously (returns immediately) + snapshot = devbox.snapshot_disk_async( + name=unique_name("sdk-snapshot-async"), + ) + + try: + assert snapshot.id is not None + + # Wait for completion + snapshot.await_completed() + + # Verify it's completed + info = snapshot.get_info() + assert info.status == "complete" + finally: + snapshot.delete() + finally: + devbox.shutdown() diff --git a/tests/smoketests/sdk/test_sdk.py b/tests/smoketests/sdk/test_sdk.py new file mode 100644 index 000000000..d56861401 --- /dev/null +++ b/tests/smoketests/sdk/test_sdk.py @@ -0,0 +1,33 @@ +"""Synchronous SDK smoke tests for RunloopSDK initialization.""" + +from __future__ import annotations + +import pytest + +from runloop_api_client.sdk import RunloopSDK + +pytestmark = [pytest.mark.smoketest] + +FIVE_SECOND_TIMEOUT = 5 + + +class TestRunloopSDKInitialization: + """Test RunloopSDK client initialization and structure.""" + + @pytest.mark.timeout(FIVE_SECOND_TIMEOUT) + def test_sdk_instance_creation(self, sdk_client: RunloopSDK) -> None: + """Test that SDK instance is created successfully with all client properties.""" + assert sdk_client is not None + assert sdk_client.devbox is not None + assert sdk_client.blueprint is not None + assert sdk_client.snapshot is not None + assert sdk_client.storage_object is not None + + @pytest.mark.timeout(FIVE_SECOND_TIMEOUT) + def test_legacy_api_access(self, sdk_client: RunloopSDK) -> None: + """Test that legacy API client is accessible through sdk.api.""" + assert sdk_client.api is not None + assert sdk_client.api.devboxes is not None + assert sdk_client.api.blueprints is not None + assert sdk_client.api.objects is not None + diff --git a/tests/smoketests/sdk/test_snapshot.py b/tests/smoketests/sdk/test_snapshot.py new file mode 100644 index 000000000..f3b22a97b --- /dev/null +++ b/tests/smoketests/sdk/test_snapshot.py @@ -0,0 +1,410 @@ +"""Synchronous SDK smoke tests for Snapshot operations.""" + +from __future__ import annotations + +import pytest + +from runloop_api_client.sdk import RunloopSDK +from tests.smoketests.utils import unique_name +from runloop_api_client.lib.polling import PollingConfig + +pytestmark = [pytest.mark.smoketest] + +THIRTY_SECOND_TIMEOUT = 30 +TWO_MINUTE_TIMEOUT = 120 + + +class TestSnapshotLifecycle: + """Test basic snapshot lifecycle operations.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_snapshot_create_and_info(self, sdk_client: RunloopSDK) -> None: + """Test creating a snapshot from devbox.""" + # Create a devbox + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-for-snapshot"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create a file to verify snapshot captures state + devbox.file.write("/tmp/snapshot_marker.txt", "This file should be in snapshot") + + # Create snapshot + snapshot = devbox.snapshot_disk( + name=unique_name("sdk-snapshot"), + ) + + try: + assert snapshot is not None + assert snapshot.id is not None + assert len(snapshot.id) > 0 + + # Get snapshot info + info = snapshot.get_info() + assert info.snapshot is not None and info.snapshot.id == snapshot.id + assert info.status == "complete" + finally: + snapshot.delete() + finally: + devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_snapshot_with_commit_message(self, sdk_client: RunloopSDK) -> None: + """Test creating a snapshot with commit message.""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-snapshot-commit"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + snapshot = devbox.snapshot_disk( + name=unique_name("sdk-snapshot-commit"), + commit_message="Test commit message from SDK", + ) + + try: + assert snapshot.id is not None + info = snapshot.get_info() + assert info.status == "complete" + # Check if commit message is preserved + assert info.snapshot is not None and info.snapshot.commit_message == "Test commit message from SDK" + finally: + snapshot.delete() + finally: + devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_snapshot_with_metadata(self, sdk_client: RunloopSDK) -> None: + """Test creating a snapshot with metadata.""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-snapshot-metadata"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + metadata = { + "purpose": "sdk-testing", + "version": "1.0", + } + + snapshot = devbox.snapshot_disk( + name=unique_name("sdk-snapshot-metadata"), + metadata=metadata, + ) + + try: + assert snapshot.id is not None + info = snapshot.get_info() + assert info.status == "complete" + assert info.snapshot is not None and info.snapshot.metadata == metadata + finally: + snapshot.delete() + finally: + devbox.shutdown() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_snapshot_delete(self, sdk_client: RunloopSDK) -> None: + """Test deleting a snapshot.""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-snapshot-delete"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + snapshot = devbox.snapshot_disk( + name=unique_name("sdk-snapshot-delete"), + ) + + snapshot_id = snapshot.id + assert snapshot_id is not None + + # Delete should succeed without error + result = snapshot.delete() + assert result is not None + + # Verify it's deleted by checking the status + info = snapshot.get_info() + # After deletion, the snapshot should have a status indicating it's deleted + assert info.status == "error" + print(info.status) + print(info.error_message) + print(info.snapshot) + finally: + devbox.shutdown() + + +class TestSnapshotCompletion: + """Test snapshot completion and status tracking.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_snapshot_await_completed(self, sdk_client: RunloopSDK) -> None: + """Test waiting for snapshot completion.""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-await-snapshot"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create snapshot asynchronously + snapshot = devbox.snapshot_disk_async( + name=unique_name("sdk-snapshot-await"), + ) + + try: + # Wait for completion + completed_info = snapshot.await_completed( + polling_config=PollingConfig(timeout_seconds=120, interval_seconds=5) + ) + + assert completed_info.status == "complete" + assert completed_info.snapshot is not None and completed_info.snapshot.id == snapshot.id + finally: + snapshot.delete() + finally: + devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_snapshot_status_tracking(self, sdk_client: RunloopSDK) -> None: + """Test tracking snapshot status through lifecycle.""" + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-status"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create snapshot asynchronously to see status progression + snapshot = devbox.snapshot_disk_async( + name=unique_name("sdk-snapshot-status"), + ) + + try: + # Check initial status (might be in_progress or complete) + info = snapshot.get_info() + assert info.status in ["in_progress", "complete"] + + # Wait for completion + snapshot.await_completed() + + # Check final status + final_info = snapshot.get_info() + assert final_info.status == "complete" + finally: + snapshot.delete() + finally: + devbox.shutdown() + + +class TestSnapshotDevboxRestoration: + """Test creating devboxes from snapshots.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + def test_restore_devbox_from_snapshot(self, sdk_client: RunloopSDK) -> None: + """Test creating a devbox from a snapshot and verifying state is restored.""" + # Create source devbox + source_devbox = sdk_client.devbox.create( + name=unique_name("sdk-source-devbox"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create unique content in source devbox + test_content = f"Unique content: {unique_name('content')}" + source_devbox.file.write("/tmp/test_restore.txt", test_content) + + # Create snapshot + snapshot = source_devbox.snapshot_disk( + name=unique_name("sdk-snapshot-restore"), + ) + + try: + # Create new devbox from snapshot + restored_devbox = sdk_client.devbox.create_from_snapshot( + snapshot.id, + name=unique_name("sdk-restored-devbox"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Verify devbox is running + assert restored_devbox.id is not None + info = restored_devbox.get_info() + assert info.status == "running" + + # Verify content from snapshot is present + restored_content = restored_devbox.file.read("/tmp/test_restore.txt") + assert restored_content == test_content + finally: + restored_devbox.shutdown() + finally: + snapshot.delete() + finally: + source_devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + def test_multiple_devboxes_from_snapshot(self, sdk_client: RunloopSDK) -> None: + """Test creating multiple devboxes from the same snapshot.""" + # Create source devbox + source_devbox = sdk_client.devbox.create( + name=unique_name("sdk-source-multi"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create content + source_devbox.file.write("/tmp/shared.txt", "Shared content") + + # Create snapshot + snapshot = source_devbox.snapshot_disk( + name=unique_name("sdk-snapshot-multi"), + ) + + try: + # Create first devbox from snapshot + devbox1 = sdk_client.devbox.create_from_snapshot( + snapshot.id, + name=unique_name("sdk-restored-1"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + # Create second devbox from snapshot + devbox2 = sdk_client.devbox.create_from_snapshot( + snapshot.id, + name=unique_name("sdk-restored-2"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Both should be running + assert devbox1.id != devbox2.id + assert devbox1.get_info().status == "running" + assert devbox2.get_info().status == "running" + + # Both should have the snapshot content + content1 = devbox1.file.read("/tmp/shared.txt") + content2 = devbox2.file.read("/tmp/shared.txt") + assert content1 == "Shared content" + assert content2 == "Shared content" + finally: + devbox1.shutdown() + devbox2.shutdown() + finally: + snapshot.delete() + finally: + source_devbox.shutdown() + + +class TestSnapshotListing: + """Test snapshot listing and retrieval operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_list_snapshots(self, sdk_client: RunloopSDK) -> None: + """Test listing snapshots.""" + snapshots = sdk_client.snapshot.list(limit=10) + + assert isinstance(snapshots, list) + # List might be empty, that's okay + assert len(snapshots) >= 0 + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_get_snapshot_by_id(self, sdk_client: RunloopSDK) -> None: + """Test retrieving snapshot by ID.""" + # Create a devbox and snapshot + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-retrieve-snapshot"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + snapshot = devbox.snapshot_disk( + name=unique_name("sdk-snapshot-retrieve"), + ) + + try: + # Retrieve it by ID + retrieved = sdk_client.snapshot.from_id(snapshot.id) + assert retrieved.id == snapshot.id + + # Verify it's the same snapshot + info = retrieved.get_info() + assert info.snapshot is not None and info.snapshot.id == snapshot.id + finally: + snapshot.delete() + finally: + devbox.shutdown() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_list_snapshots_by_devbox(self, sdk_client: RunloopSDK) -> None: + """Test listing snapshots filtered by devbox.""" + # Create a devbox + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-list-snapshots"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create snapshot + snapshot = devbox.snapshot_disk( + name=unique_name("sdk-snapshot-list"), + ) + + try: + # List snapshots for this devbox + snapshots = sdk_client.snapshot.list(devbox_id=devbox.id) + + assert isinstance(snapshots, list) + assert len(snapshots) >= 1 + + # Should find our snapshot + snapshot_ids = [s.id for s in snapshots] + assert snapshot.id in snapshot_ids + finally: + snapshot.delete() + finally: + devbox.shutdown() + + +class TestSnapshotEdgeCases: + """Test snapshot edge cases and special scenarios.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + def test_snapshot_preserves_file_permissions(self, sdk_client: RunloopSDK) -> None: + """Test that snapshot preserves file permissions.""" + # Create devbox + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-permissions"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Create executable file + devbox.file.write("/tmp/test_exec.sh", "#!/bin/bash\necho 'Hello'") + devbox.cmd.exec("chmod +x /tmp/test_exec.sh") + + # Verify it's executable + result = devbox.cmd.exec("test -x /tmp/test_exec.sh && echo 'executable'") + assert "executable" in result.stdout() + + # Create snapshot + snapshot = devbox.snapshot_disk( + name=unique_name("sdk-snapshot-permissions"), + ) + + try: + # Restore from snapshot + restored_devbox = sdk_client.devbox.create_from_snapshot( + snapshot.id, + name=unique_name("sdk-restored-permissions"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + ) + + try: + # Verify file is still executable + result = restored_devbox.cmd.exec("test -x /tmp/test_exec.sh && echo 'still_executable'") + assert "still_executable" in result.stdout() + finally: + restored_devbox.shutdown() + finally: + snapshot.delete() + finally: + devbox.shutdown() diff --git a/tests/smoketests/sdk/test_storage_object.py b/tests/smoketests/sdk/test_storage_object.py new file mode 100644 index 000000000..eccbf140e --- /dev/null +++ b/tests/smoketests/sdk/test_storage_object.py @@ -0,0 +1,465 @@ +"""Synchronous SDK smoke tests for Storage Object operations.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +from runloop_api_client.sdk import RunloopSDK +from tests.smoketests.utils import unique_name + +pytestmark = [pytest.mark.smoketest] + +THIRTY_SECOND_TIMEOUT = 30 +TWO_MINUTE_TIMEOUT = 120 + + +class TestStorageObjectLifecycle: + """Test basic storage object lifecycle operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_storage_object_create(self, sdk_client: RunloopSDK) -> None: + """Test creating a storage object.""" + obj = sdk_client.storage_object.create( + name=unique_name("sdk-storage-object"), + content_type="text", + metadata={"test": "sdk-smoketest"}, + ) + + try: + assert obj is not None + assert obj.id is not None + assert len(obj.id) > 0 + assert obj.upload_url is not None + finally: + obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_storage_object_get_info(self, sdk_client: RunloopSDK) -> None: + """Test retrieving storage object information.""" + obj = sdk_client.storage_object.create( + name=unique_name("sdk-storage-object-info"), + content_type="text", + ) + + try: + info = obj.refresh() + + assert info.id == obj.id + assert info.name is not None + assert info.content_type == "text" + finally: + obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_storage_object_upload_and_complete(self, sdk_client: RunloopSDK) -> None: + """Test uploading content and completing object.""" + obj = sdk_client.storage_object.create( + name=unique_name("sdk-storage-upload"), + content_type="text", + ) + + try: + # Upload content + obj.upload_content("Hello from SDK storage!") + + # Complete the object + result = obj.complete() + assert result is not None + assert result.state == "READ_ONLY" + finally: + obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_storage_object_delete(self, sdk_client: RunloopSDK) -> None: + """Test deleting a storage object.""" + obj = sdk_client.storage_object.create( + name=unique_name("sdk-storage-delete"), + content_type="text", + ) + + obj_id = obj.id + result = obj.delete() + + assert result is not None + # Verify it's deleted + info = sdk_client.api.objects.retrieve(obj_id) + assert info.state == "DELETED" + + +class TestStorageObjectUploadMethods: + """Test various storage object upload methods.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_upload_from_text(self, sdk_client: RunloopSDK) -> None: + """Test uploading from text.""" + text_content = "Hello from upload_from_text!" + obj = sdk_client.storage_object.upload_from_text( + text_content, + unique_name("sdk-text-upload"), + metadata={"source": "upload_from_text"}, + ) + + try: + assert obj.id is not None + + # Verify content + downloaded = obj.download_as_text() + assert downloaded == text_content + finally: + obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_upload_from_bytes(self, sdk_client: RunloopSDK) -> None: + """Test uploading from bytes.""" + bytes_content = b"Binary content from SDK" + obj = sdk_client.storage_object.upload_from_bytes( + bytes_content, + unique_name("sdk-bytes-upload"), + content_type="text", + metadata={"source": "upload_from_bytes"}, + ) + + try: + assert obj.id is not None + + # Verify content + downloaded = obj.download_as_bytes() + assert downloaded == bytes_content + finally: + obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_upload_from_file(self, sdk_client: RunloopSDK) -> None: + """Test uploading from file.""" + # Create temporary file + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as tmp_file: + tmp_file.write("Content from file upload") + tmp_path = tmp_file.name + + try: + obj = sdk_client.storage_object.upload_from_file( + tmp_path, + unique_name("sdk-file-upload"), + metadata={"source": "upload_from_file"}, + ) + + try: + assert obj.id is not None + + # Verify content + downloaded = obj.download_as_text() + assert downloaded == "Content from file upload" + finally: + obj.delete() + finally: + Path(tmp_path).unlink(missing_ok=True) + + +class TestStorageObjectDownloadMethods: + """Test storage object download methods.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_download_as_text(self, sdk_client: RunloopSDK) -> None: + """Test downloading content as text.""" + content = "Text content to download" + obj = sdk_client.storage_object.upload_from_text( + content, + unique_name("sdk-download-text"), + ) + + try: + downloaded = obj.download_as_text() + assert downloaded == content + finally: + obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_download_as_bytes(self, sdk_client: RunloopSDK) -> None: + """Test downloading content as bytes.""" + content = b"Bytes content to download" + obj = sdk_client.storage_object.upload_from_bytes( + content, + unique_name("sdk-download-bytes"), + content_type="text", + ) + + try: + downloaded = obj.download_as_bytes() + assert downloaded == content + assert isinstance(downloaded, bytes) + finally: + obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_get_download_url(self, sdk_client: RunloopSDK) -> None: + """Test getting download URL.""" + obj = sdk_client.storage_object.upload_from_text( + "Content for URL", + unique_name("sdk-download-url"), + ) + + try: + url_info = obj.get_download_url(duration_seconds=3600) + assert url_info.download_url is not None + assert "http" in url_info.download_url + finally: + obj.delete() + + +class TestStorageObjectListing: + """Test storage object listing and retrieval operations.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_list_storage_objects(self, sdk_client: RunloopSDK) -> None: + """Test listing storage objects.""" + objects = sdk_client.storage_object.list(limit=10) + + assert isinstance(objects, list) + # List might be empty, that's okay + assert len(objects) >= 0 + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_get_storage_object_by_id(self, sdk_client: RunloopSDK) -> None: + """Test retrieving storage object by ID.""" + # Create an object + created = sdk_client.storage_object.upload_from_text( + "Content for retrieval", + unique_name("sdk-storage-retrieve"), + ) + + try: + # Retrieve it by ID + retrieved = sdk_client.storage_object.from_id(created.id) + assert retrieved.id == created.id + + # Verify it's the same object + info = retrieved.refresh() + assert info.id == created.id + finally: + created.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_list_storage_objects_by_content_type(self, sdk_client: RunloopSDK) -> None: + """Test listing storage objects filtered by content type.""" + # Create object with specific content type + obj = sdk_client.storage_object.upload_from_text( + "Text content", + unique_name("sdk-storage-list-type"), + ) + + try: + # List objects with text content type + objects = sdk_client.storage_object.list(content_type="text", limit=10) + + assert isinstance(objects, list) + # Should find our object + object_ids = [o.id for o in objects] + assert obj.id in object_ids + finally: + obj.delete() + + +class TestStorageObjectDevboxIntegration: + """Test storage object integration with devboxes.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_mount_storage_object_to_devbox(self, sdk_client: RunloopSDK) -> None: + """Test mounting storage object to devbox.""" + # Create storage object with content + obj = sdk_client.storage_object.upload_from_text( + "Mounted content from SDK", + unique_name("sdk-mount-object"), + ) + + try: + # Create devbox with mounted storage object + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-mount"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + mounts=[ + { + "type": "object_mount", + "object_id": obj.id, + "object_path": "/home/user/mounted-data", + } + ], + ) + + try: + assert devbox.id is not None + info = devbox.get_info() + assert info.status == "running" + finally: + devbox.shutdown() + finally: + obj.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_access_mounted_storage_object(self, sdk_client: RunloopSDK) -> None: + """Test accessing mounted storage object content in devbox.""" + # Create storage object + obj = sdk_client.storage_object.upload_from_text( + "Content to mount and access", + unique_name("sdk-mount-access"), + ) + + try: + # Create devbox with mounted storage object + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-mount-access"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + mounts=[ + { + "type": "object_mount", + "object_id": obj.id, + "object_path": "/home/user/mounted-file", + } + ], + ) + + try: + # Read the mounted file + content = devbox.file.read("/home/user/mounted-file") + assert content == "Content to mount and access" + + # Verify file exists via command + result = devbox.cmd.exec("test -f /home/user/mounted-file && echo 'exists'") + assert "exists" in result.stdout() + finally: + devbox.shutdown() + finally: + obj.delete() + + +class TestStorageObjectEdgeCases: + """Test storage object edge cases and special scenarios.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_storage_object_large_content(self, sdk_client: RunloopSDK) -> None: + """Test uploading larger content.""" + # Create 1MB of content + large_content = "x" * (1024 * 1024) + + obj = sdk_client.storage_object.upload_from_text( + large_content, + unique_name("sdk-storage-large"), + ) + + try: + # Verify content + downloaded = obj.download_as_text() + assert len(downloaded) == len(large_content) + assert downloaded == large_content + finally: + obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_storage_object_binary_content(self, sdk_client: RunloopSDK) -> None: + """Test uploading binary content.""" + # Create some binary data + binary_content = bytes(range(256)) + + obj = sdk_client.storage_object.upload_from_bytes( + binary_content, + unique_name("sdk-storage-binary"), + content_type="binary", + ) + + try: + # Verify content + downloaded = obj.download_as_bytes() + assert downloaded == binary_content + finally: + obj.delete() + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_storage_object_empty_content(self, sdk_client: RunloopSDK) -> None: + """Test uploading empty content.""" + obj = sdk_client.storage_object.upload_from_text( + "", + unique_name("sdk-storage-empty"), + ) + + try: + # Verify content + downloaded = obj.download_as_text() + assert downloaded == "" + finally: + obj.delete() + + +class TestStorageObjectWorkflows: + """Test complete storage object workflows.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_complete_upload_download_workflow(self, sdk_client: RunloopSDK) -> None: + """Test complete workflow: create, upload, complete, download, delete.""" + # Create object + obj = sdk_client.storage_object.create( + name=unique_name("sdk-storage-workflow"), + content_type="text", + metadata={"workflow": "test"}, + ) + + try: + # Upload content + original_content = "Workflow test content" + obj.upload_content(original_content) + + # Complete + result = obj.complete() + assert result.state == "READ_ONLY" + + # Download and verify + downloaded = obj.download_as_text() + assert downloaded == original_content + + # Refresh info + info = obj.refresh() + assert info.state == "READ_ONLY" + finally: + # Delete + obj.delete() + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_storage_object_in_devbox_workflow(self, sdk_client: RunloopSDK) -> None: + """Test workflow: create storage object, write from devbox, download.""" + # Create empty storage object + obj = sdk_client.storage_object.create( + name=unique_name("sdk-storage-devbox-workflow"), + content_type="text", + ) + + try: + # Upload initial content + obj.upload_content("Initial content") + obj.complete() + + # Create devbox with mounted object + devbox = sdk_client.devbox.create( + name=unique_name("sdk-devbox-workflow"), + launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, + mounts=[ + { + "type": "object_mount", + "object_id": obj.id, + "object_path": "/home/user/workflow-data", + } + ], + ) + + try: + # Read mounted content in devbox + content = devbox.file.read("/home/user/workflow-data") + assert content == "Initial content" + + # Verify we can work with the file + result = devbox.cmd.exec("cat /home/user/workflow-data") + assert "Initial content" in result.stdout() + finally: + devbox.shutdown() + finally: + obj.delete() From cec5c81dcfc84c6ef6871fdfc10be63e930e2b6d Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Wed, 12 Nov 2025 18:34:03 -0800 Subject: [PATCH 21/56] fixed tests to expect updated parameter/member names --- tests/sdk/async_devbox/test_interfaces.py | 4 ++-- tests/sdk/devbox/test_interfaces.py | 4 ++-- tests/sdk/test_async_clients.py | 2 +- tests/sdk/test_clients.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/sdk/async_devbox/test_interfaces.py b/tests/sdk/async_devbox/test_interfaces.py index 7ba857a3e..cb06a493c 100644 --- a/tests/sdk/async_devbox/test_interfaces.py +++ b/tests/sdk/async_devbox/test_interfaces.py @@ -14,7 +14,7 @@ from tests.sdk.conftest import MockExecutionView from runloop_api_client.sdk import AsyncDevbox -from runloop_api_client._types import NotGiven +from runloop_api_client._types import NotGiven, Omit class TestAsyncCommandInterface: @@ -34,7 +34,7 @@ async def test_exec_without_callbacks( assert await result.stdout() == "output" call_kwargs = mock_async_client.devboxes.execute_and_await_completion.call_args[1] assert call_kwargs["command"] == "echo hello" - assert isinstance(call_kwargs["shell_name"], NotGiven) or call_kwargs["shell_name"] is None + assert isinstance(call_kwargs["shell_name"], Omit) assert isinstance(call_kwargs["timeout"], NotGiven) @pytest.mark.asyncio diff --git a/tests/sdk/devbox/test_interfaces.py b/tests/sdk/devbox/test_interfaces.py index d6f36aeb7..e906ba4a8 100644 --- a/tests/sdk/devbox/test_interfaces.py +++ b/tests/sdk/devbox/test_interfaces.py @@ -14,7 +14,7 @@ from tests.sdk.conftest import MockExecutionView from runloop_api_client.sdk import Devbox -from runloop_api_client._types import NotGiven +from runloop_api_client._types import NotGiven, Omit class TestCommandInterface: @@ -31,7 +31,7 @@ def test_exec_without_callbacks(self, mock_client: Mock, execution_view: MockExe assert result.stdout() == "output" call_kwargs = mock_client.devboxes.execute_and_await_completion.call_args[1] assert call_kwargs["command"] == "echo hello" - assert isinstance(call_kwargs["shell_name"], NotGiven) or call_kwargs["shell_name"] is None + assert isinstance(call_kwargs["shell_name"], Omit) assert call_kwargs["polling_config"] is None assert isinstance(call_kwargs["timeout"], NotGiven) diff --git a/tests/sdk/test_async_clients.py b/tests/sdk/test_async_clients.py index 1fce85b4e..6924f974f 100644 --- a/tests/sdk/test_async_clients.py +++ b/tests/sdk/test_async_clients.py @@ -127,7 +127,7 @@ class TestAsyncSnapshotClient: @pytest.mark.asyncio async def test_list(self, mock_async_client: AsyncMock, snapshot_view: MockSnapshotView) -> None: """Test list method.""" - page = SimpleNamespace(disk_snapshots=[snapshot_view]) + page = SimpleNamespace(snapshots=[snapshot_view]) mock_async_client.devboxes.disk_snapshots.list = AsyncMock(return_value=page) client = AsyncSnapshotClient(mock_async_client) diff --git a/tests/sdk/test_clients.py b/tests/sdk/test_clients.py index 84a0bdf85..4453bb5c4 100644 --- a/tests/sdk/test_clients.py +++ b/tests/sdk/test_clients.py @@ -120,7 +120,7 @@ class TestSnapshotClient: def test_list(self, mock_client: Mock, snapshot_view: MockSnapshotView) -> None: """Test list method.""" - page = SimpleNamespace(disk_snapshots=[snapshot_view]) + page = SimpleNamespace(snapshots=[snapshot_view]) mock_client.devboxes.disk_snapshots.list.return_value = page client = SnapshotClient(mock_client) From fe3e592542277f9b70d31c06fefbcb62cd0ae43b Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Wed, 12 Nov 2025 18:41:37 -0800 Subject: [PATCH 22/56] lint fixes --- tests/sdk/async_devbox/test_interfaces.py | 2 +- tests/sdk/devbox/test_interfaces.py | 2 +- tests/smoketests/sdk/test_async_sdk.py | 1 - tests/smoketests/sdk/test_async_snapshot.py | 8 +++++--- tests/smoketests/sdk/test_blueprint.py | 1 + tests/smoketests/sdk/test_sdk.py | 1 - tests/smoketests/sdk/test_snapshot.py | 4 ++-- 7 files changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/sdk/async_devbox/test_interfaces.py b/tests/sdk/async_devbox/test_interfaces.py index cb06a493c..32154657d 100644 --- a/tests/sdk/async_devbox/test_interfaces.py +++ b/tests/sdk/async_devbox/test_interfaces.py @@ -14,7 +14,7 @@ from tests.sdk.conftest import MockExecutionView from runloop_api_client.sdk import AsyncDevbox -from runloop_api_client._types import NotGiven, Omit +from runloop_api_client._types import Omit, NotGiven class TestAsyncCommandInterface: diff --git a/tests/sdk/devbox/test_interfaces.py b/tests/sdk/devbox/test_interfaces.py index e906ba4a8..06bb32ee4 100644 --- a/tests/sdk/devbox/test_interfaces.py +++ b/tests/sdk/devbox/test_interfaces.py @@ -14,7 +14,7 @@ from tests.sdk.conftest import MockExecutionView from runloop_api_client.sdk import Devbox -from runloop_api_client._types import NotGiven, Omit +from runloop_api_client._types import Omit, NotGiven class TestCommandInterface: diff --git a/tests/smoketests/sdk/test_async_sdk.py b/tests/smoketests/sdk/test_async_sdk.py index 3ef4edc30..9e9da410b 100644 --- a/tests/smoketests/sdk/test_async_sdk.py +++ b/tests/smoketests/sdk/test_async_sdk.py @@ -30,4 +30,3 @@ async def test_legacy_api_access(self, async_sdk_client: AsyncRunloopSDK) -> Non assert async_sdk_client.api.devboxes is not None assert async_sdk_client.api.blueprints is not None assert async_sdk_client.api.objects is not None - diff --git a/tests/smoketests/sdk/test_async_snapshot.py b/tests/smoketests/sdk/test_async_snapshot.py index ca5013564..869f9ffc9 100644 --- a/tests/smoketests/sdk/test_async_snapshot.py +++ b/tests/smoketests/sdk/test_async_snapshot.py @@ -68,7 +68,9 @@ async def test_snapshot_with_commit_message(self, async_sdk_client: AsyncRunloop info = await snapshot.get_info() assert info.status == "complete" # Check if commit message is preserved - assert info.snapshot is not None and info.snapshot.commit_message == "Test async commit message from SDK" + assert ( + info.snapshot is not None and info.snapshot.commit_message == "Test async commit message from SDK" + ) finally: await snapshot.delete() finally: @@ -118,11 +120,11 @@ async def test_snapshot_delete(self, async_sdk_client: AsyncRunloopSDK) -> None: snapshot_id = snapshot.id assert snapshot_id is not None - + # Delete should succeed without error result = await snapshot.delete() assert result is not None - + # Verify it's deleted by checking the status info = await snapshot.get_info() # After deletion, the snapshot should have a status indicating it's deleted diff --git a/tests/smoketests/sdk/test_blueprint.py b/tests/smoketests/sdk/test_blueprint.py index 3d2c3a7e8..a6852eaf5 100644 --- a/tests/smoketests/sdk/test_blueprint.py +++ b/tests/smoketests/sdk/test_blueprint.py @@ -92,6 +92,7 @@ def test_blueprint_delete(self, sdk_client: RunloopSDK) -> None: info = sdk_client.api.blueprints.retrieve(blueprint_id) assert info.state == "deleted" + class TestBlueprintCreationVariations: """Test different blueprint creation scenarios.""" diff --git a/tests/smoketests/sdk/test_sdk.py b/tests/smoketests/sdk/test_sdk.py index d56861401..c17f97299 100644 --- a/tests/smoketests/sdk/test_sdk.py +++ b/tests/smoketests/sdk/test_sdk.py @@ -30,4 +30,3 @@ def test_legacy_api_access(self, sdk_client: RunloopSDK) -> None: assert sdk_client.api.devboxes is not None assert sdk_client.api.blueprints is not None assert sdk_client.api.objects is not None - diff --git a/tests/smoketests/sdk/test_snapshot.py b/tests/smoketests/sdk/test_snapshot.py index f3b22a97b..d619e0bf8 100644 --- a/tests/smoketests/sdk/test_snapshot.py +++ b/tests/smoketests/sdk/test_snapshot.py @@ -118,11 +118,11 @@ def test_snapshot_delete(self, sdk_client: RunloopSDK) -> None: snapshot_id = snapshot.id assert snapshot_id is not None - + # Delete should succeed without error result = snapshot.delete() assert result is not None - + # Verify it's deleted by checking the status info = snapshot.get_info() # After deletion, the snapshot should have a status indicating it's deleted From 1e9375d5e8c13182b5ddb2bb8b24dd1610e7253a Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 13 Nov 2025 10:28:05 -0800 Subject: [PATCH 23/56] docs + examples --- README-SDK.md | 624 +++++++++++++++++++++++- README.md | 17 + examples/async_devbox.py | 189 +++++++ examples/basic_devbox.py | 127 +++++ examples/blueprint_example.py | 220 +++++++++ examples/storage_example.py | 310 ++++++++++++ examples/streaming_output.py | 279 +++++++++++ src/runloop_api_client/sdk/_sync.py | 71 ++- src/runloop_api_client/sdk/devbox.py | 254 +++++++++- src/runloop_api_client/sdk/execution.py | 18 +- 10 files changed, 2071 insertions(+), 38 deletions(-) create mode 100644 examples/async_devbox.py create mode 100644 examples/basic_devbox.py create mode 100644 examples/blueprint_example.py create mode 100644 examples/storage_example.py create mode 100644 examples/streaming_output.py diff --git a/README-SDK.md b/README-SDK.md index 845c4bcb8..4541f806b 100644 --- a/README-SDK.md +++ b/README-SDK.md @@ -60,36 +60,169 @@ async def main(): asyncio.run(main()) ``` -## Available Resources +## Core Concepts -- **Devbox / AsyncDevbox** - - Creation helpers (`create`, `create_from_blueprint_id`, `create_from_snapshot`, `from_id`) - - Lifecycle management (`await_running`, `suspend`, `resume`, `keep_alive`, `shutdown`) - - Command execution (`cmd.exec`, `cmd.exec_async`) with optional streaming callbacks - - File operations (`read`, `write`, `upload`, `download`) - - Network helpers (`net.create_ssh_key`, `net.create_tunnel`, `net.remove_tunnel`) +### RunloopSDK -- **Blueprint / AsyncBlueprint** - - Build orchestration (`create`) - - Fetch metadata & logs (`get_info`, `logs`) - - Spawn devboxes from existing blueprints (`create_devbox`) +The main SDK class that provides access to all Runloop functionality: -- **Snapshot / AsyncSnapshot** - - List and inspect snapshots (`list`, `get_info`, `await_completed`) - - Metadata updates (`update`), deletion (`delete`) - - Provision new devboxes from snapshots (`create_devbox`) +```python +from runloop_api_client import RunloopSDK -- **StorageObject / AsyncStorageObject** - - Object creation (`create`, `from_id`, `list`) - - Convenience uploads (`upload_from_file`, `upload_from_text`, `upload_from_bytes`) - - Manual uploads via presigned URLs (`upload_content`, `complete`) - - Downloads (`download_as_text`, `download_as_bytes`) +sdk = RunloopSDK( + bearer_token="your-api-key", # defaults to RUNLOOP_API_KEY env var + # ... other options +) +``` -All objects expose the low-level REST ID through the `id` property, making it easy to cross-reference with existing tooling. +### Available Resources -## Streaming Command Output +The SDK provides object-oriented interfaces for all major Runloop resources: -Pass callbacks into `cmd.exec` / `cmd.exec_async` to process logs in real time. Synchronous callbacks receive strings; asynchronous callbacks may return either `None` or `Awaitable[None]`. +- **`sdk.devbox`** - Devbox management (create, list, execute commands, file operations) +- **`sdk.blueprint`** - Blueprint management (create, list, build blueprints) +- **`sdk.snapshot`** - Snapshot management (list disk snapshots) +- **`sdk.storage_object`** - Storage object management (upload, download, list objects) +- **`sdk.api`** - Direct access to the generated REST API client + +### Devbox + +Object-oriented interface for working with devboxes. Created via `sdk.devbox.create()`, `sdk.devbox.create_from_blueprint_id()`, `sdk.devbox.create_from_blueprint_name()`, `sdk.devbox.create_from_snapshot()`, or `sdk.devbox.from_id()`: + +```python +# Create a new devbox +devbox = sdk.devbox.create(name="my-devbox") + +# Create a devbox from a blueprint ID +devbox_from_blueprint = sdk.devbox.create_from_blueprint_id( + "bpt-123", + name="my-devbox-from-blueprint", +) + +# Create a devbox from a blueprint name +devbox_from_name = sdk.devbox.create_from_blueprint_name( + "my-blueprint-name", + name="my-devbox-from-blueprint", +) + +# Create a devbox from a snapshot +devbox_from_snapshot = sdk.devbox.create_from_snapshot( + "snp-123", + name="my-devbox-from-snapshot", +) + +# Or get an existing one (waits for it to be running) +existing_devbox = sdk.devbox.from_id("dev-123") + +# List all devboxes +devboxes = sdk.devbox.list(limit=10) + +# Get devbox information +info = devbox.get_info() +print(f"Devbox {info.name} is {info.status}") +``` + +#### Command Execution + +Execute commands synchronously or asynchronously: + +```python +# Synchronous command execution (waits for completion) +result = devbox.cmd.exec("ls -la") +print("Output:", result.stdout()) +print("Exit code:", result.exit_code) +print("Success:", result.success) + +# Asynchronous command execution (returns immediately) +execution = devbox.cmd.exec_async("npm run dev") + +# Check execution status +state = execution.get_state() +print("Status:", state.status) + +# Wait for completion and get result +result = execution.result() +print("Final output:", result.stdout()) + +# Kill the process +execution.kill() +``` + +#### Execution Management + +The `Execution` object provides fine-grained control over asynchronous command execution: + +```python +# Start a long-running process +execution = devbox.cmd.exec_async("python train_model.py") + +# Get the execution ID +print("Execution ID:", execution.execution_id) +print("Devbox ID:", execution.devbox_id) + +# Poll for current state +state = execution.get_state() +print("Status:", state.status) # "running", "completed", etc. +print("Exit code:", state.exit_status) + +# Wait for completion and get results +result = execution.result() +print("Exit code:", result.exit_code) +print("Output:", result.stdout()) +print("Errors:", result.stderr()) + +# Or kill the process early +execution.kill() +``` + +**Key methods:** + +- `execution.get_state()` - Get current execution state (status, exit_code, etc.) +- `execution.result()` - Wait for completion and return `ExecutionResult` +- `execution.kill()` - Terminate the running process +- `execution.execution_id` - Get the execution ID (property) +- `execution.devbox_id` - Get the devbox ID (property) + +#### Execution Results + +The `ExecutionResult` object contains the output and exit status of a completed command: + +```python +# From synchronous execution +result = devbox.cmd.exec("ls -la /tmp") + +# Or from asynchronous execution +execution = devbox.cmd.exec_async("echo 'test'") +result = execution.result() + +# Access execution results +print("Exit code:", result.exit_code) +print("Success:", result.success) # True if exit code is 0 +print("Failed:", result.failed) # True if exit code is non-zero + +# Get output streams +stdout = result.stdout() +stderr = result.stderr() +print("Standard output:", stdout) +print("Standard error:", stderr) + +# Access raw result data +raw_result = result.raw +print("Raw result:", raw_result) +``` + +**Key methods and properties:** + +- `result.exit_code` - The process exit code (property) +- `result.success` - Boolean indicating success (exit code 0) (property) +- `result.failed` - Boolean indicating failure (non-zero exit code) (property) +- `result.stdout()` - Get standard output as string +- `result.stderr()` - Get standard error as string +- `result.raw` - Get the raw result data (property) + +#### Streaming Command Output + +Pass callbacks into `cmd.exec` / `cmd.exec_async` to process logs in real time: ```python def handle_output(line: str) -> None: @@ -116,7 +249,260 @@ await devbox.cmd.exec( ) ``` -## Storage Object Upload Helpers +#### File Operations + +```python +# Write files +devbox.file.write( + path="/home/user/app.js", + contents='console.log("Hello from devbox!");', +) + +# Read files +content = devbox.file.read(path="/home/user/app.js") +print(content) + +# Upload files +from pathlib import Path +devbox.file.upload( + path="/home/user/upload.txt", + file=Path("local_file.txt"), +) + +# Download files +data = devbox.file.download(path="/home/user/download.txt") +with open("local_download.txt", "wb") as f: + f.write(data) +``` + +#### Network Operations + +```python +# Create SSH key for remote access +ssh_key = devbox.net.create_ssh_key() +print("SSH URL:", ssh_key.url) + +# Create tunnel to expose port +tunnel = devbox.net.create_tunnel(port=8080) +print("Public URL:", tunnel.url) + +# Remove tunnel when done +devbox.net.remove_tunnel(port=8080) +``` + +#### Snapshot Operations + +```python +# Create a snapshot (waits for completion) +snapshot = devbox.snapshot_disk( + name="my-snapshot", + commit_message="Added new features", +) + +# Create a snapshot asynchronously (returns immediately) +snapshot = devbox.snapshot_disk_async( + name="my-snapshot", + commit_message="Added new features", +) +# Wait for it to complete later +snapshot.await_completed() + +# Create new devbox from snapshot +new_devbox = snapshot.create_devbox(name="devbox-from-snapshot") +``` + +#### Devbox Lifecycle Management + +```python +# Suspend devbox (pause without losing state) +devbox.suspend() + +# Resume suspended devbox +devbox.resume() + +# Keep devbox alive (extend timeout) +devbox.keep_alive() + +# Wait for devbox to reach running state +devbox.await_running() + +# Wait for devbox to be suspended +devbox.await_suspended() + +# Shutdown devbox +devbox.shutdown() +``` + +#### Context Manager Support + +Devboxes support context managers for automatic cleanup: + +```python +# Synchronous +with sdk.devbox.create(name="temp-devbox") as devbox: + result = devbox.cmd.exec("echo 'Hello'") + print(result.stdout()) +# devbox is automatically shutdown when exiting the context + +# Asynchronous +async with sdk.devbox.create(name="temp-devbox") as devbox: + result = await devbox.cmd.exec("echo 'Hello'") + print(await result.stdout()) +# devbox is automatically shutdown when exiting the context +``` + +**Key methods:** + +- `devbox.get_info()` - Get devbox details and status +- `devbox.cmd.exec()` - Execute commands synchronously +- `devbox.cmd.exec_async()` - Execute commands asynchronously +- `devbox.file.read()` - Read file contents +- `devbox.file.write()` - Write file contents +- `devbox.file.upload()` - Upload files +- `devbox.file.download()` - Download files +- `devbox.net.create_ssh_key()` - Create SSH key for remote access +- `devbox.net.create_tunnel()` - Create network tunnel +- `devbox.net.remove_tunnel()` - Remove network tunnel +- `devbox.snapshot_disk()` - Create disk snapshot (waits for completion) +- `devbox.snapshot_disk_async()` - Create disk snapshot (async) +- `devbox.suspend()` - Suspend devbox +- `devbox.resume()` - Resume suspended devbox +- `devbox.keep_alive()` - Extend devbox timeout +- `devbox.await_running()` - Wait for devbox to be running +- `devbox.await_suspended()` - Wait for devbox to be suspended +- `devbox.shutdown()` - Shutdown the devbox + +### Blueprint + +Object-oriented interface for working with blueprints. Created via `sdk.blueprint.create()` or `sdk.blueprint.from_id()`: + +```python +# Create a new blueprint +blueprint = sdk.blueprint.create( + name="my-blueprint", + dockerfile="FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y python3\n", + system_setup_commands=["pip install numpy pandas"], +) + +# Or get an existing one +blueprint = sdk.blueprint.from_id("bp-123") + +# List all blueprints +blueprints = sdk.blueprint.list() + +# Get blueprint details and build logs +info = blueprint.get_info() +logs = blueprint.logs() + +# Create a devbox from this blueprint +devbox = blueprint.create_devbox(name="devbox-from-blueprint") + +# Delete the blueprint when done +blueprint.delete() +``` + +**Key methods:** + +- `blueprint.get_info()` - Get blueprint details +- `blueprint.logs()` - Get build logs for the blueprint +- `blueprint.delete()` - Delete the blueprint +- `blueprint.create_devbox()` - Create a devbox from this blueprint + +### Snapshot + +Object-oriented interface for working with disk snapshots. Created via `sdk.snapshot.from_id()`: + +```python +# Get an existing snapshot +snapshot = sdk.snapshot.from_id("snp-123") + +# List all snapshots +snapshots = sdk.snapshot.list() + +# List snapshots for a specific devbox +devbox_snapshots = sdk.snapshot.list(devbox_id="dev-123") + +# Get snapshot details and check status +info = snapshot.get_info() +print(f"Snapshot status: {info.status}") + +# Update snapshot metadata +snapshot.update( + name="updated-snapshot-name", + metadata={"version": "v2.0"}, +) + +# Wait for async snapshot to complete +snapshot.await_completed() + +# Create a devbox from this snapshot +devbox = snapshot.create_devbox(name="devbox-from-snapshot") + +# Delete the snapshot when done +snapshot.delete() +``` + +**Key methods:** + +- `snapshot.get_info()` - Get snapshot details and status +- `snapshot.update()` - Update snapshot name and metadata +- `snapshot.delete()` - Delete the snapshot +- `snapshot.await_completed()` - Wait for snapshot completion +- `snapshot.create_devbox()` - Create a devbox from this snapshot + +### StorageObject + +Object-oriented interface for working with storage objects. Created via `sdk.storage_object.create()` or `sdk.storage_object.from_id()`: + +```python +# Create a new storage object +storage_object = sdk.storage_object.create( + name="my-file.txt", + content_type="text", + metadata={"project": "demo"}, +) + +# Upload content to the object +storage_object.upload_content("Hello, World!") +storage_object.complete() + +# Upload from file +from pathlib import Path +uploaded = sdk.storage_object.upload_from_file( + Path("/path/to/file.txt"), + name="my-file.txt", +) + +# Upload text content directly +uploaded = sdk.storage_object.upload_from_text( + "Hello, World!", + name="my-text.txt", + metadata={"source": "text"}, +) + +# Upload from bytes +uploaded = sdk.storage_object.upload_from_bytes( + b"binary content", + name="my-file.bin", + content_type="binary", +) + +# Get object details and download +info = storage_object.refresh() +download_url = storage_object.get_download_url(duration_seconds=3600) + +# Download content +text_content = storage_object.download_as_text() +binary_content = storage_object.download_as_bytes() + +# List all storage objects +objects = sdk.storage_object.list() + +# Delete when done +storage_object.delete() +``` + +#### Storage Object Upload Helpers The storage helpers manage the multi-step upload flow (create → PUT to presigned URL → complete): @@ -132,6 +518,71 @@ obj.upload_content(b"\xDE\xAD\xBE\xEF") obj.complete() ``` +**Key methods:** + +- `storage_object.refresh()` - Get updated object details +- `storage_object.upload_content()` - Upload content to the object +- `storage_object.complete()` - Mark upload as complete +- `storage_object.get_download_url()` - Get presigned download URL +- `storage_object.download_as_text()` - Download content as text +- `storage_object.download_as_bytes()` - Download content as bytes +- `storage_object.delete()` - Delete the object + +**Static upload methods:** + +- `sdk.storage_object.upload_from_file()` - Upload from filesystem +- `sdk.storage_object.upload_from_text()` - Upload text content directly +- `sdk.storage_object.upload_from_bytes()` - Upload from bytes + +### Mounting Storage Objects to Devboxes + +You can mount storage objects to devboxes to access their contents: + +```python +# Create a storage object first +storage_object = sdk.storage_object.upload_from_text( + "Hello, World!", + name="my-data.txt", +) + +# Create a devbox and mount the storage object +devbox = sdk.devbox.create( + name="my-devbox", + mounts=[ + { + "type": "object_mount", + "object_id": storage_object.id, + "object_path": "/home/user/data.txt", + }, + ], +) + +# The storage object is now accessible at /home/user/data.txt in the devbox +result = devbox.cmd.exec("cat /home/user/data.txt") +print(result.stdout()) # "Hello, World!" + +# Mount archived objects (tar, tgz, gzip) - they get extracted to a directory +archive_object = sdk.storage_object.upload_from_file( + Path("./project.tar.gz"), + name="project.tar.gz", +) + +devbox_with_archive = sdk.devbox.create( + name="archive-devbox", + mounts=[ + { + "type": "object_mount", + "object_id": archive_object.id, + "object_path": "/home/user/project", # Archive gets extracted here + }, + ], +) + +# Access extracted archive contents +result = devbox_with_archive.cmd.exec("ls -la /home/user/project/") +print(result.stdout()) +``` + ## Accessing the Generated REST Client The SDK always exposes the underlying generated client through the `.api` attribute: @@ -143,7 +594,130 @@ raw_devbox = sdk.api.devboxes.create() This makes it straightforward to mix high-level helpers with low-level calls whenever you need advanced control. +## Error Handling + +The SDK provides comprehensive error handling with typed exceptions: + +```python +from runloop_api_client import RunloopSDK +import runloop_api_client + +sdk = RunloopSDK() + +try: + devbox = sdk.devbox.create() + result = devbox.cmd.exec("invalid-command") +except runloop_api_client.APIConnectionError as e: + print("The server could not be reached") + print(e.__cause__) # an underlying Exception, likely raised within httpx. +except runloop_api_client.RateLimitError as e: + print("A 429 status code was received; we should back off a bit.") +except runloop_api_client.APIStatusError as e: + print("Another non-200-range status code was received") + print(e.status_code) + print(e.response) +``` + +Error codes are as follows: + +| Status Code | Error Type | +| ----------- | -------------------------- | +| 400 | `BadRequestError` | +| 401 | `AuthenticationError` | +| 403 | `PermissionDeniedError` | +| 404 | `NotFoundError` | +| 422 | `UnprocessableEntityError` | +| 429 | `RateLimitError` | +| >=500 | `InternalServerError` | +| N/A | `APIConnectionError` | + +## Advanced Configuration + +```python +import httpx +from runloop_api_client import RunloopSDK, DefaultHttpxClient + +sdk = RunloopSDK( + bearer_token="your-api-key", # defaults to RUNLOOP_API_KEY env var + base_url="https://api.runloop.ai", # or use RUNLOOP_BASE_URL env var + timeout=60.0, # 60 second timeout (default is 30) + max_retries=3, # Retry failed requests (default is 5) + default_headers={ + "X-Custom-Header": "value", + }, + # Custom HTTP client with proxy + http_client=DefaultHttpxClient( + proxy="http://my.test.proxy.example.com", + transport=httpx.HTTPTransport(local_address="0.0.0.0"), + ), +) +``` + +## Async Usage + +The async SDK has the same interface as the synchronous version, but all I/O operations are async: + +```python +import asyncio +from runloop_api_client import AsyncRunloopSDK + +async def main(): + sdk = AsyncRunloopSDK() + + # All the same operations, but with await + async with sdk.devbox.create(name="async-devbox") as devbox: + result = await devbox.cmd.exec("pwd") + print(await result.stdout()) + + # Async streaming + async def capture(line: str) -> None: + print(">>", line) + + await devbox.cmd.exec("ls", stdout=capture) + + # Async file operations + await devbox.file.write(path="/tmp/test.txt", contents="Hello") + content = await devbox.file.read(path="/tmp/test.txt") + + # Async network operations + tunnel = await devbox.net.create_tunnel(port=8080) + print("Tunnel URL:", tunnel.url) + +asyncio.run(main()) +``` + +## Polling Configuration + +Many operations that wait for state changes accept a `polling_config` parameter: + +```python +from runloop_api_client.lib.polling import PollingConfig + +# Create devbox with custom polling +devbox = sdk.devbox.create( + name="my-devbox", + polling_config=PollingConfig( + timeout_seconds=300.0, # Wait up to 5 minutes + interval_seconds=2.0, # Poll every 2 seconds + ), +) + +# Wait for snapshot completion with custom polling +snapshot.await_completed( + polling_config=PollingConfig( + timeout_seconds=600.0, # Wait up to 10 minutes + interval_seconds=5.0, # Poll every 5 seconds + ), +) +``` + +## Complete API Reference + +For the full REST API documentation and all available parameters, see: + +- **[api.md](api.md)** - Complete REST API documentation +- **[README.md](README.md)** - Advanced topics (retries, timeouts, error handling, pagination) + ## Feedback The object-oriented SDK is new for Python—feedback and ideas are welcome! Please open an issue or pull request on GitHub if you spot gaps, bugs, or ergonomic improvements. - diff --git a/README.md b/README.md index dabd52fc7..fe6ab8302 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,23 @@ The full API of this library can be found in [api.md](api.md). For a higher-level, Pythonic interface, check out the new [`RunloopSDK`](README-SDK.md) which layers an object-oriented API on top of the generated client (including synchronous and asynchronous variants). +```python +from runloop_api_client import RunloopSDK + +sdk = RunloopSDK() # Uses RUNLOOP_API_KEY environment variable by default + +# Create a devbox and execute commands with a clean, object-oriented interface +with sdk.devbox.create(name="my-devbox") as devbox: + result = devbox.cmd.exec("echo 'Hello from Runloop!'") + print(result.stdout()) +``` + +**See the [SDK documentation](README-SDK.md) for complete examples and API reference.** + +### REST API Client + +Alternatively, you can use the generated REST API client directly: + ```python import os from runloop_api_client import Runloop diff --git a/examples/async_devbox.py b/examples/async_devbox.py new file mode 100644 index 000000000..eee9f75e8 --- /dev/null +++ b/examples/async_devbox.py @@ -0,0 +1,189 @@ +#!/usr/bin/env -S uv run python +""" +Async Runloop SDK Example - Concurrent Devbox Operations + +This example demonstrates the asynchronous capabilities of the Runloop SDK: +- Creating and managing devboxes asynchronously +- Concurrent command execution across multiple devboxes +- Async file operations +- Async command streaming +""" + +import asyncio +import os +from runloop_api_client import AsyncRunloopSDK + + +async def demonstrate_basic_async(): + """Demonstrate basic async devbox operations.""" + print("=== Basic Async Operations ===") + + sdk = AsyncRunloopSDK() + + # Create a devbox with async context manager + async with sdk.devbox.create(name="async-example-devbox") as devbox: + print(f"Created devbox: {devbox.id}") + + # Execute command asynchronously + result = await devbox.cmd.exec("echo 'Hello from async devbox!'") + output = await result.stdout() + print(f"Command output: {output.strip()}") + + # File operations + await devbox.file.write( + path="/home/user/async_test.txt", + contents="Hello from async operations!\n", + ) + content = await devbox.file.read(path="/home/user/async_test.txt") + print(f"File content: {content.strip()}") + + print("Devbox automatically shutdown\n") + + +async def demonstrate_concurrent_commands(): + """Execute multiple commands concurrently on the same devbox.""" + print("=== Concurrent Command Execution ===") + + sdk = AsyncRunloopSDK() + + async with sdk.devbox.create(name="concurrent-commands-devbox") as devbox: + print(f"Created devbox: {devbox.id}") + + # Execute multiple commands concurrently + async def run_command(cmd: str, label: str): + print(f"Starting: {label}") + result = await devbox.cmd.exec(cmd) + output = await result.stdout() + print(f"{label} completed: {output.strip()}") + return output + + # Run multiple commands in parallel + results = await asyncio.gather( + run_command("echo 'Task 1' && sleep 1", "Task 1"), + run_command("echo 'Task 2' && sleep 1", "Task 2"), + run_command("echo 'Task 3' && sleep 1", "Task 3"), + ) + + print(f"All {len(results)} tasks completed\n") + + +async def demonstrate_multiple_devboxes(): + """Create and manage multiple devboxes concurrently.""" + print("=== Managing Multiple Devboxes ===") + + sdk = AsyncRunloopSDK() + + async def create_and_use_devbox(name: str, number: int): + """Create a devbox, run a command, and return the result.""" + async with sdk.devbox.create(name=name) as devbox: + print(f"Devbox {number} ({devbox.id}): Created") + + # Run a command + result = await devbox.cmd.exec(f"echo 'Hello from devbox {number}'") + output = await result.stdout() + print(f"Devbox {number}: {output.strip()}") + + return output + + # Create and use multiple devboxes concurrently + results = await asyncio.gather( + create_and_use_devbox("multi-devbox-1", 1), + create_and_use_devbox("multi-devbox-2", 2), + create_and_use_devbox("multi-devbox-3", 3), + ) + + print(f"All {len(results)} devboxes completed and shutdown\n") + + +async def demonstrate_async_streaming(): + """Demonstrate real-time command output streaming with async callbacks.""" + print("=== Async Command Streaming ===") + + sdk = AsyncRunloopSDK() + + async with sdk.devbox.create(name="streaming-devbox") as devbox: + print(f"Created devbox: {devbox.id}") + + # Async callback to capture output + output_lines = [] + + async def capture_output(line: str): + print(f"[STREAM] {line.strip()}") + output_lines.append(line) + + # Execute command with streaming output + print("\nStreaming command output:") + await devbox.cmd.exec( + "for i in 1 2 3 4 5; do echo \"Line $i\"; sleep 0.2; done", + stdout=capture_output, + ) + + print(f"\nCaptured {len(output_lines)} lines of output\n") + + +async def demonstrate_async_execution(): + """Demonstrate async execution management.""" + print("=== Async Execution Management ===") + + sdk = AsyncRunloopSDK() + + async with sdk.devbox.create(name="async-exec-devbox") as devbox: + print(f"Created devbox: {devbox.id}") + + # Start an async execution + execution = await devbox.cmd.exec_async( + "echo 'Starting...'; sleep 2; echo 'Finished!'" + ) + print(f"Started execution: {execution.execution_id}") + + # Poll execution state + state = await execution.get_state() + print(f"Initial status: {state.status}") + + # Wait for completion + print("Waiting for completion...") + result = await execution.result() + print(f"Exit code: {result.exit_code}") + output = await result.stdout() + print(f"Output:\n{output}") + + # Start another execution and kill it + print("\nStarting long-running process...") + long_execution = await devbox.cmd.exec_async("sleep 30") + print(f"Execution ID: {long_execution.execution_id}") + + # Wait a bit then kill it + await asyncio.sleep(1) + print("Killing execution...") + await long_execution.kill() + print("Execution killed\n") + + +async def main(): + """Run all async demonstrations.""" + print("Initialized Async Runloop SDK\n") + + # Run demonstrations + await demonstrate_basic_async() + await demonstrate_concurrent_commands() + await demonstrate_multiple_devboxes() + await demonstrate_async_streaming() + await demonstrate_async_execution() + + print("All async demonstrations completed!") + + +if __name__ == "__main__": + # Ensure API key is set + if not os.getenv("RUNLOOP_API_KEY"): + print("Error: RUNLOOP_API_KEY environment variable is not set") + print("Please set it to your Runloop API key:") + print(" export RUNLOOP_API_KEY=your-api-key") + exit(1) + + try: + asyncio.run(main()) + except Exception as e: + print(f"\nError: {e}") + raise + diff --git a/examples/basic_devbox.py b/examples/basic_devbox.py new file mode 100644 index 000000000..dd1464360 --- /dev/null +++ b/examples/basic_devbox.py @@ -0,0 +1,127 @@ +#!/usr/bin/env -S uv run python +""" +Basic Runloop SDK Example - Devbox Operations + +This example demonstrates the core functionality of the Runloop SDK: +- Creating and managing devboxes +- Executing commands synchronously and asynchronously +- File operations (read, write, upload, download) +- Devbox lifecycle management +""" + +import os +from pathlib import Path +from runloop_api_client import RunloopSDK + + +def main(): + # Initialize the SDK (uses RUNLOOP_API_KEY environment variable by default) + sdk = RunloopSDK() + print("Initialized Runloop SDK") + + # Create a devbox with automatic cleanup using context manager + print("\n=== Creating Devbox ===") + with sdk.devbox.create(name="basic-example-devbox") as devbox: + print(f"Created devbox: {devbox.id}") + + # Get devbox information + info = devbox.get_info() + print(f"Devbox status: {info.status}") + print(f"Devbox name: {info.name}") + + # Execute a simple command + print("\n=== Executing Commands ===") + result = devbox.cmd.exec("echo 'Hello from Runloop!'") + print(f"Command output: {result.stdout().strip()}") + print(f"Exit code: {result.exit_code}") + print(f"Success: {result.success}") + + # Execute a command that generates output + result = devbox.cmd.exec("ls -la /home/user") + print(f"\nDirectory listing:\n{result.stdout()}") + + # Execute a command with error + result = devbox.cmd.exec("ls /nonexistent") + if result.failed: + print(f"\nCommand failed with exit code {result.exit_code}") + print(f"Error output: {result.stderr()}") + + # File operations + print("\n=== File Operations ===") + + # Write a file + file_path = "/home/user/test.txt" + content = "Hello, Runloop!\nThis is a test file.\n" + devbox.file.write(path=file_path, contents=content) + print(f"Wrote file: {file_path}") + + # Read the file back + read_content = devbox.file.read(path=file_path) + print(f"Read file content:\n{read_content}") + + # Create a local file to upload + local_file = Path("temp_upload.txt") + local_file.write_text("This file will be uploaded to the devbox.\n") + + try: + # Upload a file + upload_path = "/home/user/uploaded.txt" + devbox.file.upload(path=upload_path, file=local_file) + print(f"\nUploaded file to: {upload_path}") + + # Verify the upload by reading the file + uploaded_content = devbox.file.read(path=upload_path) + print(f"Uploaded file content: {uploaded_content.strip()}") + + # Download a file + download_data = devbox.file.download(path=upload_path) + local_download = Path("temp_download.txt") + local_download.write_bytes(download_data) + print(f"Downloaded file to: {local_download}") + print(f"Downloaded content: {local_download.read_text().strip()}") + finally: + # Cleanup local files + local_file.unlink(missing_ok=True) + if Path("temp_download.txt").exists(): + Path("temp_download.txt").unlink() + + # Asynchronous command execution + print("\n=== Asynchronous Command Execution ===") + + # Start a long-running command asynchronously + execution = devbox.cmd.exec_async("sleep 3 && echo 'Done sleeping!'") + print(f"Started async execution: {execution.execution_id}") + + # Check the execution state + state = execution.get_state() + print(f"Execution status: {state.status}") + + # Wait for completion and get the result + print("Waiting for execution to complete...") + result = execution.result() + print(f"Execution completed with exit code: {result.exit_code}") + print(f"Output: {result.stdout().strip()}") + + # Keep devbox alive (extends timeout) + print("\n=== Devbox Lifecycle ===") + devbox.keep_alive() + print("Extended devbox timeout") + + print("\n=== Devbox Cleanup ===") + print("Devbox automatically shutdown when exiting context manager") + + +if __name__ == "__main__": + # Ensure API key is set + if not os.getenv("RUNLOOP_API_KEY"): + print("Error: RUNLOOP_API_KEY environment variable is not set") + print("Please set it to your Runloop API key:") + print(" export RUNLOOP_API_KEY=your-api-key") + exit(1) + + try: + main() + except Exception as e: + print(f"\nError: {e}") + raise + diff --git a/examples/blueprint_example.py b/examples/blueprint_example.py new file mode 100644 index 000000000..cb2ba2b0f --- /dev/null +++ b/examples/blueprint_example.py @@ -0,0 +1,220 @@ +#!/usr/bin/env -S uv run python +""" +Runloop SDK Example - Blueprint Workflows + +This example demonstrates blueprint creation and management: +- Creating blueprints with Dockerfiles +- Creating blueprints with system setup commands +- Using blueprints to create devboxes +- Viewing blueprint build logs +- Blueprint lifecycle management +""" + +import os +from runloop_api_client import RunloopSDK + + +def create_simple_blueprint(sdk: RunloopSDK): + """Create a simple blueprint with a Dockerfile.""" + print("=== Creating Simple Blueprint ===") + + dockerfile = """FROM ubuntu:22.04 + +RUN apt-get update && apt-get install -y \\ + python3 \\ + python3-pip \\ + curl \\ + git + +WORKDIR /home/user +""" + + blueprint = sdk.blueprint.create( + name="simple-python-blueprint", + dockerfile=dockerfile, + ) + + print(f"Created blueprint: {blueprint.id}") + + # Get blueprint info + info = blueprint.get_info() + print(f"Blueprint name: {info.name}") + print(f"Blueprint status: {info.status}") + + return blueprint + + +def create_blueprint_with_setup(sdk: RunloopSDK): + """Create a blueprint with system setup commands.""" + print("\n=== Creating Blueprint with System Setup ===") + + dockerfile = """FROM ubuntu:22.04 + +RUN apt-get update && apt-get install -y \\ + python3 \\ + python3-pip + +WORKDIR /home/user +""" + + blueprint = sdk.blueprint.create( + name="ml-environment-blueprint", + dockerfile=dockerfile, + system_setup_commands=[ + "pip3 install numpy pandas scikit-learn", + "pip3 install matplotlib seaborn", + "echo 'ML environment ready!'", + ], + ) + + print(f"Created blueprint: {blueprint.id}") + + # View build logs + print("\nRetrieving build logs...") + logs = blueprint.logs() + if logs.logs: + print("Build log entries:") + for i, log_entry in enumerate(logs.logs[:5], 1): + print(f" {i}. {log_entry.message[:80]}...") + if len(logs.logs) > 5: + print(f" ... and {len(logs.logs) - 5} more log entries") + + return blueprint + + +def create_blueprint_from_base(sdk: RunloopSDK): + """Create a blueprint based on an existing blueprint.""" + print("\n=== Creating Blueprint from Base ===") + + # First create a base blueprint + base_blueprint = sdk.blueprint.create( + name="base-nodejs-blueprint", + dockerfile="""FROM ubuntu:22.04 + +RUN apt-get update && apt-get install -y \\ + curl \\ + && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \\ + && apt-get install -y nodejs + +WORKDIR /home/user +""", + ) + + print(f"Created base blueprint: {base_blueprint.id}") + + # Create a derived blueprint + derived_blueprint = sdk.blueprint.create( + name="nodejs-with-tools-blueprint", + base_blueprint_id=base_blueprint.id, + system_setup_commands=[ + "npm install -g typescript ts-node", + "npm install -g eslint prettier", + "echo 'Node.js with tools ready!'", + ], + ) + + print(f"Created derived blueprint: {derived_blueprint.id}") + + return base_blueprint, derived_blueprint + + +def use_blueprint_to_create_devbox(sdk: RunloopSDK, blueprint): + """Create and use a devbox from a blueprint.""" + print("\n=== Creating Devbox from Blueprint ===") + + # Create devbox from blueprint + devbox = blueprint.create_devbox(name="devbox-from-blueprint") + + print(f"Created devbox: {devbox.id}") + + try: + # Verify the devbox has the expected environment + result = devbox.cmd.exec("python3 --version") + print(f"Python version: {result.stdout().strip()}") + + result = devbox.cmd.exec("which pip3") + print(f"pip3 location: {result.stdout().strip()}") + + # Run a simple Python command + result = devbox.cmd.exec("python3 -c 'import sys; print(sys.version)'") + print(f"Python sys.version: {result.stdout().strip()}") + finally: + # Cleanup + devbox.shutdown() + print("Devbox shutdown") + + +def list_blueprints(sdk: RunloopSDK): + """List all available blueprints.""" + print("\n=== Listing Blueprints ===") + + blueprints = sdk.blueprint.list(limit=5) + + print(f"Found {len(blueprints)} blueprints:") + for bp in blueprints: + info = bp.get_info() + print(f" - {info.name} ({bp.id}): {info.status}") + + +def cleanup_blueprints(sdk: RunloopSDK, blueprints): + """Delete blueprints to clean up.""" + print("\n=== Cleaning Up Blueprints ===") + + for blueprint in blueprints: + try: + info = blueprint.get_info() + print(f"Deleting blueprint: {info.name} ({blueprint.id})") + blueprint.delete() + print(f" Deleted: {blueprint.id}") + except Exception as e: + print(f" Error deleting {blueprint.id}: {e}") + + +def main(): + # Initialize the SDK + sdk = RunloopSDK() + print("Initialized Runloop SDK\n") + + created_blueprints = [] + + try: + # Create simple blueprint + simple_bp = create_simple_blueprint(sdk) + created_blueprints.append(simple_bp) + + # Create blueprint with setup commands + ml_bp = create_blueprint_with_setup(sdk) + created_blueprints.append(ml_bp) + + # Create blueprint from base + base_bp, derived_bp = create_blueprint_from_base(sdk) + created_blueprints.extend([base_bp, derived_bp]) + + # Use a blueprint to create a devbox + use_blueprint_to_create_devbox(sdk, simple_bp) + + # List all blueprints + list_blueprints(sdk) + + finally: + # Cleanup all created blueprints + if created_blueprints: + cleanup_blueprints(sdk, created_blueprints) + + print("\nBlueprint example completed!") + + +if __name__ == "__main__": + # Ensure API key is set + if not os.getenv("RUNLOOP_API_KEY"): + print("Error: RUNLOOP_API_KEY environment variable is not set") + print("Please set it to your Runloop API key:") + print(" export RUNLOOP_API_KEY=your-api-key") + exit(1) + + try: + main() + except Exception as e: + print(f"\nError: {e}") + raise + diff --git a/examples/storage_example.py b/examples/storage_example.py new file mode 100644 index 000000000..2eba2c96b --- /dev/null +++ b/examples/storage_example.py @@ -0,0 +1,310 @@ +#!/usr/bin/env -S uv run python +""" +Runloop SDK Example - Storage Object Operations + +This example demonstrates storage object management: +- Creating storage objects +- Uploading content (text, bytes, files) +- Downloading content +- Mounting storage objects to devboxes +- Storage object lifecycle management +""" + +import os +from pathlib import Path +from runloop_api_client import RunloopSDK + + +def demonstrate_text_upload(sdk: RunloopSDK): + """Upload text content directly.""" + print("=== Text Content Upload ===") + + content = """Hello from Runloop! +This is a test file created by the SDK. +It contains multiple lines of text. +""" + + obj = sdk.storage_object.upload_from_text( + content, + name="test-text-file.txt", + metadata={"source": "example", "type": "text"}, + ) + + print(f"Uploaded text object: {obj.id}") + + # Verify by downloading + downloaded_text = obj.download_as_text() + print(f"Downloaded content:\n{downloaded_text}") + + return obj + + +def demonstrate_bytes_upload(sdk: RunloopSDK): + """Upload binary content.""" + print("\n=== Binary Content Upload ===") + + # Create some binary data + binary_data = b"\x89PNG\r\n\x1a\n" + b"Fake PNG header" + b"\x00" * 100 + + obj = sdk.storage_object.upload_from_bytes( + binary_data, + name="test-binary.bin", + content_type="binary", + metadata={"source": "example", "type": "binary"}, + ) + + print(f"Uploaded binary object: {obj.id}") + print(f"Content length: {len(binary_data)} bytes") + + # Verify by downloading + downloaded_bytes = obj.download_as_bytes() + print(f"Downloaded {len(downloaded_bytes)} bytes") + print(f"Content matches: {binary_data == downloaded_bytes}") + + return obj + + +def demonstrate_file_upload(sdk: RunloopSDK): + """Upload a file from the filesystem.""" + print("\n=== File Upload ===") + + # Create a temporary file + temp_file = Path("temp_example_file.txt") + temp_file.write_text("""This is a file from the filesystem. +It will be uploaded to Runloop storage. +Line 3 +Line 4 +""") + + try: + obj = sdk.storage_object.upload_from_file( + temp_file, + name="uploaded-file.txt", + metadata={"source": "filesystem", "original": str(temp_file)}, + ) + + print(f"Uploaded file object: {obj.id}") + + # Get object info + info = obj.refresh() + print(f"Object name: {info.name}") + print(f"Content type: {info.content_type}") + print(f"Metadata: {info.metadata}") + + return obj + finally: + # Cleanup temp file + temp_file.unlink(missing_ok=True) + + +def demonstrate_manual_upload(sdk: RunloopSDK): + """Demonstrate manual upload flow with create, upload, complete.""" + print("\n=== Manual Upload Flow ===") + + # Step 1: Create the storage object + obj = sdk.storage_object.create( + name="manual-upload.txt", + content_type="text", + metadata={"method": "manual"}, + ) + + print(f"Created storage object: {obj.id}") + print(f"Upload URL: {obj.upload_url[:50]}...") + + # Step 2: Upload content to the presigned URL + content = b"This content was uploaded manually using the upload flow." + obj.upload_content(content) + print("Content uploaded to presigned URL") + + # Step 3: Mark the upload as complete + obj.complete() + print("Upload marked as complete") + + # Verify + downloaded = obj.download_as_text() + print(f"Verified content: {downloaded[:50]}...") + + return obj + + +def demonstrate_storage_mounting(sdk: RunloopSDK): + """Mount a storage object to a devbox.""" + print("\n=== Mounting Storage Objects to Devbox ===") + + # Create a storage object with some data + obj = sdk.storage_object.upload_from_text( + "This file is mounted in the devbox!\n", + name="mounted-file.txt", + ) + print(f"Created storage object: {obj.id}") + + # Create a devbox with the storage object mounted + devbox = sdk.devbox.create( + name="storage-mount-devbox", + mounts=[ + { + "type": "object_mount", + "object_id": obj.id, + "object_path": "/home/user/mounted-data.txt", + } + ], + ) + + print(f"Created devbox: {devbox.id}") + + try: + # Verify the file is accessible in the devbox + result = devbox.cmd.exec("cat /home/user/mounted-data.txt") + print(f"Mounted file content: {result.stdout().strip()}") + + # Check file details + result = devbox.cmd.exec("ls -lh /home/user/mounted-data.txt") + print(f"File details: {result.stdout().strip()}") + + # Try to use the mounted file + result = devbox.cmd.exec("wc -l /home/user/mounted-data.txt") + print(f"Line count: {result.stdout().strip()}") + finally: + devbox.shutdown() + print("Devbox shutdown") + + return obj + + +def demonstrate_archive_mounting(sdk: RunloopSDK): + """Create and mount an archive that gets extracted.""" + print("\n=== Mounting Archive (Extraction) ===") + + # Create a temporary directory with files + import tarfile + import io + + # Create a tar.gz archive in memory + tar_buffer = io.BytesIO() + with tarfile.open(fileobj=tar_buffer, mode='w:gz') as tar: + # Add some files + for i in range(3): + content = f"File {i+1} content\n".encode() + info = tarfile.TarInfo(name=f"project/file{i+1}.txt") + info.size = len(content) + tar.addfile(info, io.BytesIO(content)) + + tar_data = tar_buffer.getvalue() + print(f"Created archive with {len(tar_data)} bytes") + + # Upload the archive + archive_obj = sdk.storage_object.upload_from_bytes( + tar_data, + name="project-archive.tar.gz", + content_type="tar", + ) + print(f"Uploaded archive: {archive_obj.id}") + + # Create devbox with archive mounted (it will be extracted) + devbox = sdk.devbox.create( + name="archive-mount-devbox", + mounts=[ + { + "type": "object_mount", + "object_id": archive_obj.id, + "object_path": "/home/user/project", + } + ], + ) + + print(f"Created devbox: {devbox.id}") + + try: + # List the extracted contents + result = devbox.cmd.exec("ls -la /home/user/project/") + print(f"Extracted archive contents:\n{result.stdout()}") + + # Read one of the files + result = devbox.cmd.exec("cat /home/user/project/file1.txt") + print(f"File1 content: {result.stdout().strip()}") + finally: + devbox.shutdown() + print("Devbox shutdown") + + return archive_obj + + +def list_storage_objects(sdk: RunloopSDK): + """List all storage objects.""" + print("\n=== Listing Storage Objects ===") + + objects = sdk.storage_object.list(limit=10) + + print(f"Found {len(objects)} storage objects:") + for obj in objects: + info = obj.refresh() + print(f" - {info.name} ({obj.id}): {info.content_type}") + + +def cleanup_storage_objects(sdk: RunloopSDK, objects): + """Delete storage objects to clean up.""" + print("\n=== Cleaning Up Storage Objects ===") + + for obj in objects: + try: + info = obj.refresh() + print(f"Deleting: {info.name} ({obj.id})") + obj.delete() + print(f" Deleted: {obj.id}") + except Exception as e: + print(f" Error deleting {obj.id}: {e}") + + +def main(): + # Initialize the SDK + sdk = RunloopSDK() + print("Initialized Runloop SDK\n") + + created_objects = [] + + try: + # Demonstrate different upload methods + text_obj = demonstrate_text_upload(sdk) + created_objects.append(text_obj) + + binary_obj = demonstrate_bytes_upload(sdk) + created_objects.append(binary_obj) + + file_obj = demonstrate_file_upload(sdk) + created_objects.append(file_obj) + + manual_obj = demonstrate_manual_upload(sdk) + created_objects.append(manual_obj) + + # Demonstrate mounting + mount_obj = demonstrate_storage_mounting(sdk) + created_objects.append(mount_obj) + + archive_obj = demonstrate_archive_mounting(sdk) + created_objects.append(archive_obj) + + # List all objects + list_storage_objects(sdk) + + finally: + # Cleanup all created objects + if created_objects: + cleanup_storage_objects(sdk, created_objects) + + print("\nStorage object example completed!") + + +if __name__ == "__main__": + # Ensure API key is set + if not os.getenv("RUNLOOP_API_KEY"): + print("Error: RUNLOOP_API_KEY environment variable is not set") + print("Please set it to your Runloop API key:") + print(" export RUNLOOP_API_KEY=your-api-key") + exit(1) + + try: + main() + except Exception as e: + print(f"\nError: {e}") + raise + diff --git a/examples/streaming_output.py b/examples/streaming_output.py new file mode 100644 index 000000000..dca8e067b --- /dev/null +++ b/examples/streaming_output.py @@ -0,0 +1,279 @@ +#!/usr/bin/env -S uv run python +""" +Runloop SDK Example - Real-time Command Output Streaming + +This example demonstrates streaming command output in real-time: +- Streaming stdout +- Streaming stderr +- Streaming combined output +- Processing output line-by-line +- Async streaming callbacks +""" + +import os +import asyncio +from datetime import datetime +from runloop_api_client import RunloopSDK, AsyncRunloopSDK + + +def demonstrate_basic_streaming(sdk: RunloopSDK): + """Demonstrate basic stdout streaming.""" + print("=== Basic Stdout Streaming ===") + + with sdk.devbox.create(name="streaming-basic-devbox") as devbox: + print(f"Created devbox: {devbox.id}\n") + + # Simple callback to print output + def print_output(line: str): + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{timestamp}] {line.rstrip()}") + + # Execute command with streaming + print("Streaming command output:") + result = devbox.cmd.exec( + "for i in 1 2 3 4 5; do echo \"Processing item $i\"; sleep 0.5; done", + stdout=print_output, + ) + + print(f"\nCommand completed with exit code: {result.exit_code}") + + +def demonstrate_stderr_streaming(sdk: RunloopSDK): + """Demonstrate stderr streaming separately.""" + print("\n=== Separate Stdout and Stderr Streaming ===") + + with sdk.devbox.create(name="streaming-stderr-devbox") as devbox: + print(f"Created devbox: {devbox.id}\n") + + def handle_stdout(line: str): + print(f"[STDOUT] {line.rstrip()}") + + def handle_stderr(line: str): + print(f"[STDERR] {line.rstrip()}") + + # Command that writes to both stdout and stderr + print("Streaming stdout and stderr separately:") + result = devbox.cmd.exec( + """ + echo "This goes to stdout" + echo "This goes to stderr" >&2 + echo "Back to stdout" + echo "More stderr" >&2 + """, + stdout=handle_stdout, + stderr=handle_stderr, + ) + + print(f"\nCommand completed with exit code: {result.exit_code}") + + +def demonstrate_combined_streaming(sdk: RunloopSDK): + """Demonstrate combined output streaming.""" + print("\n=== Combined Output Streaming ===") + + with sdk.devbox.create(name="streaming-combined-devbox") as devbox: + print(f"Created devbox: {devbox.id}\n") + + # Track all output + all_output = [] + + def capture_all(line: str): + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + all_output.append((timestamp, line.rstrip())) + print(f"[{timestamp}] {line.rstrip()}") + + # Use the 'output' parameter to capture both stdout and stderr + print("Streaming combined output:") + result = devbox.cmd.exec( + """ + echo "Line 1" + echo "Error 1" >&2 + echo "Line 2" + echo "Error 2" >&2 + echo "Line 3" + """, + output=capture_all, + ) + + print(f"\nCommand completed with exit code: {result.exit_code}") + print(f"Captured {len(all_output)} lines of output") + + +def demonstrate_output_processing(sdk: RunloopSDK): + """Demonstrate processing streaming output.""" + print("\n=== Processing Streaming Output ===") + + with sdk.devbox.create(name="streaming-processing-devbox") as devbox: + print(f"Created devbox: {devbox.id}\n") + + # Process and analyze output + stats = { + "total_lines": 0, + "error_lines": 0, + "warning_lines": 0, + "info_lines": 0, + } + + def analyze_output(line: str): + stats["total_lines"] += 1 + line_lower = line.lower() + + if "error" in line_lower: + stats["error_lines"] += 1 + print(f"❌ ERROR: {line.rstrip()}") + elif "warning" in line_lower: + stats["warning_lines"] += 1 + print(f"⚠️ WARNING: {line.rstrip()}") + else: + stats["info_lines"] += 1 + print(f"ℹ️ INFO: {line.rstrip()}") + + # Execute a script that produces different types of output + print("Analyzing output in real-time:") + result = devbox.cmd.exec( + """ + echo "Starting process..." + echo "Warning: Low memory" + echo "Processing data..." + echo "Error: Connection timeout" + echo "Retrying..." + echo "Warning: Slow response" + echo "Success: Operation complete" + """, + stdout=analyze_output, + ) + + print(f"\nCommand completed with exit code: {result.exit_code}") + print(f"\nOutput Statistics:") + print(f" Total lines: {stats['total_lines']}") + print(f" Errors: {stats['error_lines']}") + print(f" Warnings: {stats['warning_lines']}") + print(f" Info: {stats['info_lines']}") + + +def demonstrate_long_running_stream(sdk: RunloopSDK): + """Demonstrate streaming output from a long-running command.""" + print("\n=== Long-running Command Streaming ===") + + with sdk.devbox.create(name="streaming-longrun-devbox") as devbox: + print(f"Created devbox: {devbox.id}\n") + + progress_items = [] + + def track_progress(line: str): + line = line.rstrip() + if "Progress:" in line: + progress_items.append(line) + # Extract percentage if present + print(f"📊 {line}") + else: + print(f" {line}") + + print("Streaming output from long-running task:") + result = devbox.cmd.exec( + """ + echo "Starting long-running task..." + for i in 1 2 3 4 5 6 7 8 9 10; do + echo "Progress: $((i * 10))% complete" + sleep 0.3 + done + echo "Task completed successfully!" + """, + stdout=track_progress, + ) + + print(f"\nCommand completed with exit code: {result.exit_code}") + print(f"Tracked {len(progress_items)} progress updates") + + +async def demonstrate_async_streaming(): + """Demonstrate async streaming with async callbacks.""" + print("\n=== Async Streaming ===") + + sdk = AsyncRunloopSDK() + + async with sdk.devbox.create(name="async-streaming-devbox") as devbox: + print(f"Created devbox: {devbox.id}\n") + + # Async callback with async operations + output_queue = asyncio.Queue() + + async def async_capture(line: str): + # Simulate async processing (e.g., writing to a database) + await asyncio.sleep(0.01) + await output_queue.put(line.rstrip()) + print(f"[ASYNC] {line.rstrip()}") + + # Start processing task + async def process_queue(): + processed = [] + while True: + try: + line = await asyncio.wait_for(output_queue.get(), timeout=2.0) + processed.append(line) + except asyncio.TimeoutError: + break + return processed + + processor = asyncio.create_task(process_queue()) + + # Execute with async streaming + print("Streaming with async callbacks:") + await devbox.cmd.exec( + "for i in 1 2 3 4 5; do echo \"Async line $i\"; sleep 0.2; done", + stdout=async_capture, + ) + + # Wait for queue processing + processed = await processor + print(f"\nProcessed {len(processed)} lines asynchronously") + + +def main(): + # Initialize the SDK + sdk = RunloopSDK() + print("Initialized Runloop SDK\n") + + # Run synchronous streaming demonstrations + demonstrate_basic_streaming(sdk) + demonstrate_stderr_streaming(sdk) + demonstrate_combined_streaming(sdk) + demonstrate_output_processing(sdk) + demonstrate_long_running_stream(sdk) + + print("\nSynchronous streaming examples completed!") + + +async def async_main(): + """Run async streaming demonstrations.""" + print("\n" + "="*60) + print("Running Async Examples") + print("="*60 + "\n") + + await demonstrate_async_streaming() + + print("\nAsync streaming examples completed!") + + +if __name__ == "__main__": + # Ensure API key is set + if not os.getenv("RUNLOOP_API_KEY"): + print("Error: RUNLOOP_API_KEY environment variable is not set") + print("Please set it to your Runloop API key:") + print(" export RUNLOOP_API_KEY=your-api-key") + exit(1) + + try: + # Run synchronous examples + main() + + # Run async examples + asyncio.run(async_main()) + + print("\n" + "="*60) + print("All streaming examples completed successfully!") + print("="*60) + except Exception as e: + print(f"\nError: {e}") + raise + diff --git a/src/runloop_api_client/sdk/_sync.py b/src/runloop_api_client/sdk/_sync.py index 356d81754..05ef030f4 100644 --- a/src/runloop_api_client/sdk/_sync.py +++ b/src/runloop_api_client/sdk/_sync.py @@ -20,7 +20,16 @@ class DevboxClient: - """High-level manager for :class:`Devbox` wrappers.""" + """High-level manager for creating and managing Devbox instances. + + Accessed via sdk.devbox, provides methods to create devboxes from scratch, + blueprints, or snapshots, and to list existing devboxes. + + Example: + >>> sdk = RunloopSDK() + >>> devbox = sdk.devbox.create(name="my-devbox") + >>> devboxes = sdk.devbox.list(limit=10) + """ def __init__(self, client: Runloop) -> None: self._client = client @@ -228,7 +237,16 @@ def list( class SnapshotClient: - """Manager for :class:`Snapshot` wrappers.""" + """High-level manager for working with disk snapshots. + + Accessed via sdk.snapshot, provides methods to list snapshots and access + snapshot details. + + Example: + >>> sdk = RunloopSDK() + >>> snapshots = sdk.snapshot.list(devbox_id="dev-123") + >>> snapshot = sdk.snapshot.from_id("snap-123") + """ def __init__(self, client: Runloop) -> None: self._client = client @@ -264,7 +282,19 @@ def from_id(self, snapshot_id: str) -> Snapshot: class BlueprintClient: - """Manager for :class:`Blueprint` wrappers.""" + """High-level manager for creating and managing blueprints. + + Accessed via sdk.blueprint, provides methods to create blueprints with + Dockerfiles and system setup commands, and to list existing blueprints. + + Example: + >>> sdk = RunloopSDK() + >>> blueprint = sdk.blueprint.create( + ... name="my-blueprint", + ... dockerfile="FROM ubuntu:22.04\\nRUN apt-get update" + ... ) + >>> blueprints = sdk.blueprint.list() + """ def __init__(self, client: Runloop) -> None: self._client = client @@ -340,7 +370,17 @@ def list( class StorageObjectClient: - """Manager for :class:`StorageObject` wrappers and upload helpers.""" + """High-level manager for creating and managing storage objects. + + Accessed via sdk.storage_object, provides methods to create, upload, download, + and list storage objects with convenient helpers for file and text uploads. + + Example: + >>> sdk = RunloopSDK() + >>> obj = sdk.storage_object.upload_from_text("Hello!", "greeting.txt") + >>> content = obj.download_as_text() + >>> objects = sdk.storage_object.list() + """ def __init__(self, client: Runloop) -> None: self._client = client @@ -429,11 +469,24 @@ def upload_from_bytes( class RunloopSDK: - """ - High-level synchronous entry point for the Runloop SDK. - - This thin wrapper exposes the generated REST client via the ``api`` attribute. - Higher-level object-oriented helpers will be layered on top incrementally. + """High-level synchronous entry point for the Runloop SDK. + + Provides a Pythonic, object-oriented interface for managing devboxes, blueprints, + snapshots, and storage objects. Exposes the generated REST client via the ``api`` + attribute for advanced use cases. + + Attributes: + api: Direct access to the generated REST API client. + devbox: High-level interface for devbox management. + blueprint: High-level interface for blueprint management. + snapshot: High-level interface for snapshot management. + storage_object: High-level interface for storage object management. + + Example: + >>> sdk = RunloopSDK() # Uses RUNLOOP_API_KEY env var + >>> with sdk.devbox.create(name="my-devbox") as devbox: + ... result = devbox.cmd.exec("echo 'hello'") + ... print(result.stdout()) """ api: Runloop diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index b535e731e..492aaeffa 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -26,8 +26,22 @@ class Devbox: - """ - Object-oriented wrapper around devbox operations. + """High-level interface for managing a Runloop devbox. + + This class provides a Pythonic, object-oriented API for interacting with devboxes, + including command execution, file operations, networking, and lifecycle management. + + The Devbox class supports context manager protocol for automatic cleanup: + >>> with sdk.devbox.create(name="my-devbox") as devbox: + ... result = devbox.cmd.exec("echo 'hello'") + ... print(result.stdout()) + # Devbox is automatically shutdown on exit + + Attributes: + id: The devbox identifier. + cmd: Command execution interface (exec, exec_async). + file: File operations interface (read, write, upload, download). + net: Network operations interface (SSH keys, tunnels). """ def __init__(self, client: Runloop, devbox_id: str) -> None: @@ -60,6 +74,11 @@ def get_info( extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, ) -> DevboxView: + """Retrieve current devbox status and metadata. + + Returns: + DevboxView containing the devbox's current state, status, and metadata. + """ return self._client.devboxes.retrieve( self._id, extra_headers=extra_headers, @@ -69,9 +88,29 @@ def get_info( ) def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView: + """Wait for the devbox to reach running state. + + Blocks until the devbox is running or the polling timeout is reached. + + Args: + polling_config: Optional configuration for polling behavior (timeout, interval). + + Returns: + DevboxView with the devbox in running state. + """ return self._client.devboxes.await_running(self._id, polling_config=polling_config) def await_suspended(self, *, polling_config: PollingConfig | None = None) -> DevboxView: + """Wait for the devbox to reach suspended state. + + Blocks until the devbox is suspended or the polling timeout is reached. + + Args: + polling_config: Optional configuration for polling behavior (timeout, interval). + + Returns: + DevboxView with the devbox in suspended state. + """ return self._client.devboxes.await_suspended(self._id, polling_config=polling_config) def shutdown( @@ -83,6 +122,11 @@ def shutdown( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> DevboxView: + """Shutdown the devbox, terminating all processes and releasing resources. + + Returns: + DevboxView with the final devbox state. + """ return self._client.devboxes.shutdown( self._id, extra_headers=extra_headers, @@ -102,6 +146,17 @@ def suspend( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> DevboxView: + """Suspend the devbox, pausing execution while preserving state. + + This saves resources while maintaining the devbox state for later resumption. + Waits for the devbox to reach suspended state before returning. + + Args: + polling_config: Optional configuration for polling behavior (timeout, interval). + + Returns: + DevboxView with the devbox in suspended state. + """ self._client.devboxes.suspend( self._id, extra_headers=extra_headers, @@ -122,6 +177,16 @@ def resume( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> DevboxView: + """Resume a suspended devbox, restoring it to running state. + + Waits for the devbox to reach running state before returning. + + Args: + polling_config: Optional configuration for polling behavior (timeout, interval). + + Returns: + DevboxView with the devbox in running state. + """ self._client.devboxes.resume( self._id, extra_headers=extra_headers, @@ -141,6 +206,14 @@ def keep_alive( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> object: + """Extend the devbox timeout, preventing automatic shutdown. + + Call this periodically for long-running workflows to prevent the devbox + from being automatically shut down due to inactivity. + + Returns: + Response object confirming the keep-alive request. + """ return self._client.devboxes.keep_alive( self._id, extra_headers=extra_headers, @@ -163,6 +236,20 @@ def snapshot_disk( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> "Snapshot": + """Create a disk snapshot of the devbox and wait for completion. + + Captures the current state of the devbox disk, which can be used to create + new devboxes with the same state. + + Args: + commit_message: Optional message describing the snapshot. + metadata: Optional key-value metadata to attach to the snapshot. + name: Optional name for the snapshot. + polling_config: Optional configuration for polling behavior (timeout, interval). + + Returns: + Snapshot object representing the completed snapshot. + """ snapshot_data = self._client.devboxes.snapshot_disk_async( self._id, commit_message=commit_message, @@ -196,6 +283,19 @@ def snapshot_disk_async( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> "Snapshot": + """Create a disk snapshot of the devbox asynchronously. + + Starts the snapshot creation process and returns immediately without waiting + for completion. Use snapshot.await_completed() to wait for completion. + + Args: + commit_message: Optional message describing the snapshot. + metadata: Optional key-value metadata to attach to the snapshot. + name: Optional name for the snapshot. + + Returns: + Snapshot object (snapshot may still be in progress). + """ snapshot_data = self._client.devboxes.snapshot_disk_async( self._id, commit_message=commit_message, @@ -241,9 +341,15 @@ def _start_streaming( stderr: Optional[LogCallback] = None, output: Optional[LogCallback] = None, ) -> Optional[_StreamingGroup]: + """Set up background threads to stream command output to callbacks. + + Creates separate threads for stdout and stderr streams, allowing real-time + processing of command output through user-provided callbacks. + """ threads: list[threading.Thread] = [] stop_event = threading.Event() + # Set up stdout streaming if stdout or output callbacks are provided if stdout or output: callbacks = [cb for cb in (stdout, output) if cb is not None] threads.append( @@ -258,6 +364,7 @@ def _start_streaming( ) ) + # Set up stderr streaming if stderr or output callbacks are provided if stderr or output: callbacks = [cb for cb in (stderr, output) if cb is not None] threads.append( @@ -312,6 +419,12 @@ def worker() -> None: class _CommandInterface: + """Interface for executing commands on a devbox. + + Accessed via devbox.cmd property. Provides exec() for synchronous execution + and exec_async() for asynchronous execution with process management. + """ + def __init__(self, devbox: Devbox) -> None: self._devbox = devbox @@ -331,6 +444,25 @@ def exec( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> ExecutionResult: + """Execute a command synchronously and wait for completion. + + Args: + command: The shell command to execute. + shell_name: Optional shell to use (e.g., "bash", "sh"). + stdout: Optional callback to receive stdout lines in real-time. + stderr: Optional callback to receive stderr lines in real-time. + output: Optional callback to receive combined output lines in real-time. + polling_config: Optional configuration for polling behavior. + attach_stdin: Whether to attach stdin for interactive commands. + + Returns: + ExecutionResult with exit code and captured output. + + Example: + >>> result = devbox.cmd.exec("ls -la") + >>> print(result.stdout()) + >>> print(f"Exit code: {result.exit_code}") + """ devbox = self._devbox client = devbox._client @@ -398,6 +530,29 @@ def exec_async( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> Execution: + """Execute a command asynchronously without waiting for completion. + + Starts command execution and returns immediately with an Execution object + for process management. Use execution.result() to wait for completion or + execution.kill() to terminate the process. + + Args: + command: The shell command to execute. + shell_name: Optional shell to use (e.g., "bash", "sh"). + stdout: Optional callback to receive stdout lines in real-time. + stderr: Optional callback to receive stderr lines in real-time. + output: Optional callback to receive combined output lines in real-time. + attach_stdin: Whether to attach stdin for interactive commands. + + Returns: + Execution object for managing the running process. + + Example: + >>> execution = devbox.cmd.exec_async("sleep 10") + >>> state = execution.get_state() + >>> print(f"Status: {state.status}") + >>> execution.kill() # Terminate early if needed + """ devbox = self._devbox client = devbox._client @@ -424,6 +579,12 @@ def exec_async( class _FileInterface: + """Interface for file operations on a devbox. + + Accessed via devbox.file property. Provides methods for reading, writing, + uploading, and downloading files. + """ + def __init__(self, devbox: Devbox) -> None: self._devbox = devbox @@ -437,6 +598,18 @@ def read( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> str: + """Read a file from the devbox. + + Args: + path: Absolute path to the file in the devbox. + + Returns: + File contents as a string. + + Example: + >>> content = devbox.file.read("/home/user/data.txt") + >>> print(content) + """ return self._devbox._client.devboxes.read_file_contents( self._devbox.id, file_path=path, @@ -458,6 +631,20 @@ def write( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> DevboxExecutionDetailView: + """Write contents to a file in the devbox. + + Creates or overwrites the file at the specified path. + + Args: + path: Absolute path to the file in the devbox. + contents: File contents as string or bytes (bytes are decoded as UTF-8). + + Returns: + Execution details for the write operation. + + Example: + >>> devbox.file.write("/home/user/config.json", '{"key": "value"}') + """ if isinstance(contents, bytes): contents_str = contents.decode("utf-8") else: @@ -484,6 +671,19 @@ def download( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> bytes: + """Download a file from the devbox. + + Args: + path: Absolute path to the file in the devbox. + + Returns: + File contents as bytes. + + Example: + >>> data = devbox.file.download("/home/user/output.bin") + >>> with open("local_output.bin", "wb") as f: + ... f.write(data) + """ response = self._devbox._client.devboxes.download_file( self._devbox.id, path=path, @@ -506,6 +706,19 @@ def upload( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> object: + """Upload a file to the devbox. + + Args: + path: Destination path in the devbox. + file: File to upload (Path, file-like object, or bytes). + + Returns: + Response object confirming the upload. + + Example: + >>> from pathlib import Path + >>> devbox.file.upload("/home/user/data.csv", Path("local_data.csv")) + """ return self._devbox._client.devboxes.upload_file( self._devbox.id, path=path, @@ -519,6 +732,11 @@ def upload( class _NetworkInterface: + """Interface for network operations on a devbox. + + Accessed via devbox.net property. Provides methods for SSH access and tunneling. + """ + def __init__(self, devbox: Devbox) -> None: self._devbox = devbox @@ -531,6 +749,15 @@ def create_ssh_key( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> DevboxCreateSSHKeyResponse: + """Create an SSH key for remote access to the devbox. + + Returns: + SSH key response containing the SSH URL and credentials. + + Example: + >>> ssh_key = devbox.net.create_ssh_key() + >>> print(f"SSH URL: {ssh_key.url}") + """ return self._devbox._client.devboxes.create_ssh_key( self._devbox.id, extra_headers=extra_headers, @@ -550,6 +777,18 @@ def create_tunnel( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> DevboxTunnelView: + """Create a network tunnel to expose a devbox port publicly. + + Args: + port: The port number in the devbox to expose. + + Returns: + DevboxTunnelView containing the public URL for the tunnel. + + Example: + >>> tunnel = devbox.net.create_tunnel(port=8080) + >>> print(f"Public URL: {tunnel.url}") + """ return self._devbox._client.devboxes.create_tunnel( self._devbox.id, port=port, @@ -570,6 +809,17 @@ def remove_tunnel( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> object: + """Remove a network tunnel, disabling public access to the port. + + Args: + port: The port number of the tunnel to remove. + + Returns: + Response object confirming the tunnel removal. + + Example: + >>> devbox.net.remove_tunnel(port=8080) + """ return self._devbox._client.devboxes.remove_tunnel( self._devbox.id, port=port, diff --git a/src/runloop_api_client/sdk/execution.py b/src/runloop_api_client/sdk/execution.py index a26773af2..ab00894c4 100644 --- a/src/runloop_api_client/sdk/execution.py +++ b/src/runloop_api_client/sdk/execution.py @@ -35,8 +35,22 @@ def active(self) -> bool: class Execution: - """ - Represents an asynchronous command execution on a devbox. + """Manages an asynchronous command execution on a devbox. + + Provides methods to poll execution state, wait for completion, and terminate + the running process. Created by devbox.cmd.exec_async(). + + Attributes: + execution_id: The unique execution identifier. + devbox_id: The devbox where the command is executing. + + Example: + >>> execution = devbox.cmd.exec_async("python train.py") + >>> state = execution.get_state() + >>> if state.status == "running": + ... execution.kill() + >>> result = execution.result() # Wait for completion + >>> print(result.stdout()) """ def __init__( From 7bf84bbc424eacd9c4a13a0805906d61a357bed4 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 13 Nov 2025 13:46:59 -0800 Subject: [PATCH 24/56] add missing mount parameters --- src/runloop_api_client/sdk/_async.py | 6 ++++++ src/runloop_api_client/sdk/async_blueprint.py | 3 +++ src/runloop_api_client/sdk/async_snapshot.py | 3 +++ 3 files changed, 12 insertions(+) diff --git a/src/runloop_api_client/sdk/_async.py b/src/runloop_api_client/sdk/_async.py index 0f8935a74..57fa7ac73 100644 --- a/src/runloop_api_client/sdk/_async.py +++ b/src/runloop_api_client/sdk/_async.py @@ -81,6 +81,7 @@ async def create_from_blueprint_id( file_mounts: Optional[Dict[str, str]] | Omit = omit, launch_parameters: Optional[LaunchParameters] | Omit = omit, metadata: Optional[Dict[str, str]] | Omit = omit, + mounts: Optional[Iterable[Mount]] | Omit = omit, name: Optional[str] | Omit = omit, repo_connection_id: Optional[str] | Omit = omit, secrets: Optional[Dict[str, str]] | Omit = omit, @@ -99,6 +100,7 @@ async def create_from_blueprint_id( file_mounts=file_mounts, launch_parameters=launch_parameters, metadata=metadata, + mounts=mounts, name=name, repo_connection_id=repo_connection_id, secrets=secrets, @@ -121,6 +123,7 @@ async def create_from_blueprint_name( file_mounts: Optional[Dict[str, str]] | Omit = omit, launch_parameters: Optional[LaunchParameters] | Omit = omit, metadata: Optional[Dict[str, str]] | Omit = omit, + mounts: Optional[Iterable[Mount]] | Omit = omit, name: Optional[str] | Omit = omit, repo_connection_id: Optional[str] | Omit = omit, secrets: Optional[Dict[str, str]] | Omit = omit, @@ -139,6 +142,7 @@ async def create_from_blueprint_name( file_mounts=file_mounts, launch_parameters=launch_parameters, metadata=metadata, + mounts=mounts, name=name, repo_connection_id=repo_connection_id, secrets=secrets, @@ -161,6 +165,7 @@ async def create_from_snapshot( file_mounts: Optional[Dict[str, str]] | Omit = omit, launch_parameters: Optional[LaunchParameters] | Omit = omit, metadata: Optional[Dict[str, str]] | Omit = omit, + mounts: Optional[Iterable[Mount]] | Omit = omit, name: Optional[str] | Omit = omit, repo_connection_id: Optional[str] | Omit = omit, secrets: Optional[Dict[str, str]] | Omit = omit, @@ -179,6 +184,7 @@ async def create_from_snapshot( file_mounts=file_mounts, launch_parameters=launch_parameters, metadata=metadata, + mounts=mounts, name=name, repo_connection_id=repo_connection_id, secrets=secrets, diff --git a/src/runloop_api_client/sdk/async_blueprint.py b/src/runloop_api_client/sdk/async_blueprint.py index ce2f1416e..237a469bd 100644 --- a/src/runloop_api_client/sdk/async_blueprint.py +++ b/src/runloop_api_client/sdk/async_blueprint.py @@ -9,6 +9,7 @@ from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given from .._client import AsyncRunloop from ..lib.polling import PollingConfig +from ..types.shared_params.mount import Mount from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView from ..types.shared_params.launch_parameters import LaunchParameters from ..types.shared_params.code_mount_parameters import CodeMountParameters @@ -92,6 +93,7 @@ async def create_devbox( file_mounts: Optional[Dict[str, str]] | Omit = omit, launch_parameters: Optional[LaunchParameters] | Omit = omit, metadata: Optional[Dict[str, str]] | Omit = omit, + mounts: Optional[Iterable[Mount]] | Omit = omit, name: Optional[str] | Omit = omit, repo_connection_id: Optional[str] | Omit = omit, secrets: Optional[Dict[str, str]] | Omit = omit, @@ -113,6 +115,7 @@ async def create_devbox( file_mounts=file_mounts, launch_parameters=launch_parameters, metadata=metadata, + mounts=mounts, name=name, repo_connection_id=repo_connection_id, secrets=secrets, diff --git a/src/runloop_api_client/sdk/async_snapshot.py b/src/runloop_api_client/sdk/async_snapshot.py index 43d66445c..4fc0e75cf 100644 --- a/src/runloop_api_client/sdk/async_snapshot.py +++ b/src/runloop_api_client/sdk/async_snapshot.py @@ -8,6 +8,7 @@ from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given from .._client import AsyncRunloop from ..lib.polling import PollingConfig +from ..types.shared_params.mount import Mount from ..types.devbox_snapshot_view import DevboxSnapshotView from ..types.shared_params.launch_parameters import LaunchParameters from ..types.shared_params.code_mount_parameters import CodeMountParameters @@ -120,6 +121,7 @@ async def create_devbox( file_mounts: Optional[Dict[str, str]] | Omit = omit, launch_parameters: Optional[LaunchParameters] | Omit = omit, metadata: Optional[Dict[str, str]] | Omit = omit, + mounts: Optional[Iterable[Mount]] | Omit = omit, name: Optional[str] | Omit = omit, repo_connection_id: Optional[str] | Omit = omit, secrets: Optional[Dict[str, str]] | Omit = omit, @@ -141,6 +143,7 @@ async def create_devbox( file_mounts=file_mounts, launch_parameters=launch_parameters, metadata=metadata, + mounts=mounts, name=name, repo_connection_id=repo_connection_id, secrets=secrets, From f3f12ba6bb7321d252dc3849345ee7398bc35e23 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 13 Nov 2025 13:49:46 -0800 Subject: [PATCH 25/56] lint fixes --- examples/async_devbox.py | 74 +++++++-------- examples/basic_devbox.py | 38 ++++---- examples/blueprint_example.py | 66 ++++++------- examples/storage_example.py | 112 +++++++++++----------- examples/streaming_output.py | 94 +++++++++---------- src/runloop_api_client/sdk/devbox.py | 120 ++++++++++++------------ src/runloop_api_client/sdk/execution.py | 6 +- 7 files changed, 254 insertions(+), 256 deletions(-) diff --git a/examples/async_devbox.py b/examples/async_devbox.py index eee9f75e8..315edbaad 100644 --- a/examples/async_devbox.py +++ b/examples/async_devbox.py @@ -9,26 +9,27 @@ - Async command streaming """ -import asyncio import os +import asyncio + from runloop_api_client import AsyncRunloopSDK async def demonstrate_basic_async(): """Demonstrate basic async devbox operations.""" print("=== Basic Async Operations ===") - + sdk = AsyncRunloopSDK() - + # Create a devbox with async context manager async with sdk.devbox.create(name="async-example-devbox") as devbox: print(f"Created devbox: {devbox.id}") - + # Execute command asynchronously result = await devbox.cmd.exec("echo 'Hello from async devbox!'") output = await result.stdout() print(f"Command output: {output.strip()}") - + # File operations await devbox.file.write( path="/home/user/async_test.txt", @@ -36,19 +37,19 @@ async def demonstrate_basic_async(): ) content = await devbox.file.read(path="/home/user/async_test.txt") print(f"File content: {content.strip()}") - + print("Devbox automatically shutdown\n") async def demonstrate_concurrent_commands(): """Execute multiple commands concurrently on the same devbox.""" print("=== Concurrent Command Execution ===") - + sdk = AsyncRunloopSDK() - + async with sdk.devbox.create(name="concurrent-commands-devbox") as devbox: print(f"Created devbox: {devbox.id}") - + # Execute multiple commands concurrently async def run_command(cmd: str, label: str): print(f"Starting: {label}") @@ -56,102 +57,100 @@ async def run_command(cmd: str, label: str): output = await result.stdout() print(f"{label} completed: {output.strip()}") return output - + # Run multiple commands in parallel results = await asyncio.gather( run_command("echo 'Task 1' && sleep 1", "Task 1"), run_command("echo 'Task 2' && sleep 1", "Task 2"), run_command("echo 'Task 3' && sleep 1", "Task 3"), ) - + print(f"All {len(results)} tasks completed\n") async def demonstrate_multiple_devboxes(): """Create and manage multiple devboxes concurrently.""" print("=== Managing Multiple Devboxes ===") - + sdk = AsyncRunloopSDK() - + async def create_and_use_devbox(name: str, number: int): """Create a devbox, run a command, and return the result.""" async with sdk.devbox.create(name=name) as devbox: print(f"Devbox {number} ({devbox.id}): Created") - + # Run a command result = await devbox.cmd.exec(f"echo 'Hello from devbox {number}'") output = await result.stdout() print(f"Devbox {number}: {output.strip()}") - + return output - + # Create and use multiple devboxes concurrently results = await asyncio.gather( create_and_use_devbox("multi-devbox-1", 1), create_and_use_devbox("multi-devbox-2", 2), create_and_use_devbox("multi-devbox-3", 3), ) - + print(f"All {len(results)} devboxes completed and shutdown\n") async def demonstrate_async_streaming(): """Demonstrate real-time command output streaming with async callbacks.""" print("=== Async Command Streaming ===") - + sdk = AsyncRunloopSDK() - + async with sdk.devbox.create(name="streaming-devbox") as devbox: print(f"Created devbox: {devbox.id}") - + # Async callback to capture output output_lines = [] - + async def capture_output(line: str): print(f"[STREAM] {line.strip()}") output_lines.append(line) - + # Execute command with streaming output print("\nStreaming command output:") await devbox.cmd.exec( - "for i in 1 2 3 4 5; do echo \"Line $i\"; sleep 0.2; done", + 'for i in 1 2 3 4 5; do echo "Line $i"; sleep 0.2; done', stdout=capture_output, ) - + print(f"\nCaptured {len(output_lines)} lines of output\n") async def demonstrate_async_execution(): """Demonstrate async execution management.""" print("=== Async Execution Management ===") - + sdk = AsyncRunloopSDK() - + async with sdk.devbox.create(name="async-exec-devbox") as devbox: print(f"Created devbox: {devbox.id}") - + # Start an async execution - execution = await devbox.cmd.exec_async( - "echo 'Starting...'; sleep 2; echo 'Finished!'" - ) + execution = await devbox.cmd.exec_async("echo 'Starting...'; sleep 2; echo 'Finished!'") print(f"Started execution: {execution.execution_id}") - + # Poll execution state state = await execution.get_state() print(f"Initial status: {state.status}") - + # Wait for completion print("Waiting for completion...") result = await execution.result() print(f"Exit code: {result.exit_code}") output = await result.stdout() print(f"Output:\n{output}") - + # Start another execution and kill it print("\nStarting long-running process...") long_execution = await devbox.cmd.exec_async("sleep 30") print(f"Execution ID: {long_execution.execution_id}") - + # Wait a bit then kill it await asyncio.sleep(1) print("Killing execution...") @@ -162,14 +161,14 @@ async def demonstrate_async_execution(): async def main(): """Run all async demonstrations.""" print("Initialized Async Runloop SDK\n") - + # Run demonstrations await demonstrate_basic_async() await demonstrate_concurrent_commands() await demonstrate_multiple_devboxes() await demonstrate_async_streaming() await demonstrate_async_execution() - + print("All async demonstrations completed!") @@ -180,10 +179,9 @@ async def main(): print("Please set it to your Runloop API key:") print(" export RUNLOOP_API_KEY=your-api-key") exit(1) - + try: asyncio.run(main()) except Exception as e: print(f"\nError: {e}") raise - diff --git a/examples/basic_devbox.py b/examples/basic_devbox.py index dd1464360..d035f36c6 100644 --- a/examples/basic_devbox.py +++ b/examples/basic_devbox.py @@ -11,6 +11,7 @@ import os from pathlib import Path + from runloop_api_client import RunloopSDK @@ -23,56 +24,56 @@ def main(): print("\n=== Creating Devbox ===") with sdk.devbox.create(name="basic-example-devbox") as devbox: print(f"Created devbox: {devbox.id}") - + # Get devbox information info = devbox.get_info() print(f"Devbox status: {info.status}") print(f"Devbox name: {info.name}") - + # Execute a simple command print("\n=== Executing Commands ===") result = devbox.cmd.exec("echo 'Hello from Runloop!'") print(f"Command output: {result.stdout().strip()}") print(f"Exit code: {result.exit_code}") print(f"Success: {result.success}") - + # Execute a command that generates output result = devbox.cmd.exec("ls -la /home/user") print(f"\nDirectory listing:\n{result.stdout()}") - + # Execute a command with error result = devbox.cmd.exec("ls /nonexistent") if result.failed: print(f"\nCommand failed with exit code {result.exit_code}") print(f"Error output: {result.stderr()}") - + # File operations print("\n=== File Operations ===") - + # Write a file file_path = "/home/user/test.txt" content = "Hello, Runloop!\nThis is a test file.\n" devbox.file.write(path=file_path, contents=content) print(f"Wrote file: {file_path}") - + # Read the file back read_content = devbox.file.read(path=file_path) print(f"Read file content:\n{read_content}") - + # Create a local file to upload local_file = Path("temp_upload.txt") local_file.write_text("This file will be uploaded to the devbox.\n") - + try: # Upload a file upload_path = "/home/user/uploaded.txt" devbox.file.upload(path=upload_path, file=local_file) print(f"\nUploaded file to: {upload_path}") - + # Verify the upload by reading the file uploaded_content = devbox.file.read(path=upload_path) print(f"Uploaded file content: {uploaded_content.strip()}") - + # Download a file download_data = devbox.file.download(path=upload_path) local_download = Path("temp_download.txt") @@ -84,29 +85,29 @@ def main(): local_file.unlink(missing_ok=True) if Path("temp_download.txt").exists(): Path("temp_download.txt").unlink() - + # Asynchronous command execution print("\n=== Asynchronous Command Execution ===") - + # Start a long-running command asynchronously execution = devbox.cmd.exec_async("sleep 3 && echo 'Done sleeping!'") print(f"Started async execution: {execution.execution_id}") - + # Check the execution state state = execution.get_state() print(f"Execution status: {state.status}") - + # Wait for completion and get the result print("Waiting for execution to complete...") result = execution.result() print(f"Execution completed with exit code: {result.exit_code}") print(f"Output: {result.stdout().strip()}") - + # Keep devbox alive (extends timeout) print("\n=== Devbox Lifecycle ===") devbox.keep_alive() print("Extended devbox timeout") - + print("\n=== Devbox Cleanup ===") print("Devbox automatically shutdown when exiting context manager") @@ -118,10 +119,9 @@ def main(): print("Please set it to your Runloop API key:") print(" export RUNLOOP_API_KEY=your-api-key") exit(1) - + try: main() except Exception as e: print(f"\nError: {e}") raise - diff --git a/examples/blueprint_example.py b/examples/blueprint_example.py index cb2ba2b0f..1ffd3c2bd 100644 --- a/examples/blueprint_example.py +++ b/examples/blueprint_example.py @@ -11,13 +11,14 @@ """ import os + from runloop_api_client import RunloopSDK def create_simple_blueprint(sdk: RunloopSDK): """Create a simple blueprint with a Dockerfile.""" print("=== Creating Simple Blueprint ===") - + dockerfile = """FROM ubuntu:22.04 RUN apt-get update && apt-get install -y \\ @@ -28,26 +29,26 @@ def create_simple_blueprint(sdk: RunloopSDK): WORKDIR /home/user """ - + blueprint = sdk.blueprint.create( name="simple-python-blueprint", dockerfile=dockerfile, ) - + print(f"Created blueprint: {blueprint.id}") - + # Get blueprint info info = blueprint.get_info() print(f"Blueprint name: {info.name}") print(f"Blueprint status: {info.status}") - + return blueprint def create_blueprint_with_setup(sdk: RunloopSDK): """Create a blueprint with system setup commands.""" print("\n=== Creating Blueprint with System Setup ===") - + dockerfile = """FROM ubuntu:22.04 RUN apt-get update && apt-get install -y \\ @@ -56,7 +57,7 @@ def create_blueprint_with_setup(sdk: RunloopSDK): WORKDIR /home/user """ - + blueprint = sdk.blueprint.create( name="ml-environment-blueprint", dockerfile=dockerfile, @@ -66,9 +67,9 @@ def create_blueprint_with_setup(sdk: RunloopSDK): "echo 'ML environment ready!'", ], ) - + print(f"Created blueprint: {blueprint.id}") - + # View build logs print("\nRetrieving build logs...") logs = blueprint.logs() @@ -78,14 +79,14 @@ def create_blueprint_with_setup(sdk: RunloopSDK): print(f" {i}. {log_entry.message[:80]}...") if len(logs.logs) > 5: print(f" ... and {len(logs.logs) - 5} more log entries") - + return blueprint def create_blueprint_from_base(sdk: RunloopSDK): """Create a blueprint based on an existing blueprint.""" print("\n=== Creating Blueprint from Base ===") - + # First create a base blueprint base_blueprint = sdk.blueprint.create( name="base-nodejs-blueprint", @@ -99,9 +100,9 @@ def create_blueprint_from_base(sdk: RunloopSDK): WORKDIR /home/user """, ) - + print(f"Created base blueprint: {base_blueprint.id}") - + # Create a derived blueprint derived_blueprint = sdk.blueprint.create( name="nodejs-with-tools-blueprint", @@ -112,29 +113,29 @@ def create_blueprint_from_base(sdk: RunloopSDK): "echo 'Node.js with tools ready!'", ], ) - + print(f"Created derived blueprint: {derived_blueprint.id}") - + return base_blueprint, derived_blueprint def use_blueprint_to_create_devbox(sdk: RunloopSDK, blueprint): """Create and use a devbox from a blueprint.""" print("\n=== Creating Devbox from Blueprint ===") - + # Create devbox from blueprint devbox = blueprint.create_devbox(name="devbox-from-blueprint") - + print(f"Created devbox: {devbox.id}") - + try: # Verify the devbox has the expected environment result = devbox.cmd.exec("python3 --version") print(f"Python version: {result.stdout().strip()}") - + result = devbox.cmd.exec("which pip3") print(f"pip3 location: {result.stdout().strip()}") - + # Run a simple Python command result = devbox.cmd.exec("python3 -c 'import sys; print(sys.version)'") print(f"Python sys.version: {result.stdout().strip()}") @@ -147,9 +148,9 @@ def use_blueprint_to_create_devbox(sdk: RunloopSDK, blueprint): def list_blueprints(sdk: RunloopSDK): """List all available blueprints.""" print("\n=== Listing Blueprints ===") - + blueprints = sdk.blueprint.list(limit=5) - + print(f"Found {len(blueprints)} blueprints:") for bp in blueprints: info = bp.get_info() @@ -159,7 +160,7 @@ def list_blueprints(sdk: RunloopSDK): def cleanup_blueprints(sdk: RunloopSDK, blueprints): """Delete blueprints to clean up.""" print("\n=== Cleaning Up Blueprints ===") - + for blueprint in blueprints: try: info = blueprint.get_info() @@ -174,33 +175,33 @@ def main(): # Initialize the SDK sdk = RunloopSDK() print("Initialized Runloop SDK\n") - + created_blueprints = [] - + try: # Create simple blueprint simple_bp = create_simple_blueprint(sdk) created_blueprints.append(simple_bp) - + # Create blueprint with setup commands ml_bp = create_blueprint_with_setup(sdk) created_blueprints.append(ml_bp) - + # Create blueprint from base base_bp, derived_bp = create_blueprint_from_base(sdk) created_blueprints.extend([base_bp, derived_bp]) - + # Use a blueprint to create a devbox use_blueprint_to_create_devbox(sdk, simple_bp) - + # List all blueprints list_blueprints(sdk) - + finally: # Cleanup all created blueprints if created_blueprints: cleanup_blueprints(sdk, created_blueprints) - + print("\nBlueprint example completed!") @@ -211,10 +212,9 @@ def main(): print("Please set it to your Runloop API key:") print(" export RUNLOOP_API_KEY=your-api-key") exit(1) - + try: main() except Exception as e: print(f"\nError: {e}") raise - diff --git a/examples/storage_example.py b/examples/storage_example.py index 2eba2c96b..09f9dd6b5 100644 --- a/examples/storage_example.py +++ b/examples/storage_example.py @@ -12,62 +12,63 @@ import os from pathlib import Path + from runloop_api_client import RunloopSDK def demonstrate_text_upload(sdk: RunloopSDK): """Upload text content directly.""" print("=== Text Content Upload ===") - + content = """Hello from Runloop! This is a test file created by the SDK. It contains multiple lines of text. """ - + obj = sdk.storage_object.upload_from_text( content, name="test-text-file.txt", metadata={"source": "example", "type": "text"}, ) - + print(f"Uploaded text object: {obj.id}") - + # Verify by downloading downloaded_text = obj.download_as_text() print(f"Downloaded content:\n{downloaded_text}") - + return obj def demonstrate_bytes_upload(sdk: RunloopSDK): """Upload binary content.""" print("\n=== Binary Content Upload ===") - + # Create some binary data binary_data = b"\x89PNG\r\n\x1a\n" + b"Fake PNG header" + b"\x00" * 100 - + obj = sdk.storage_object.upload_from_bytes( binary_data, name="test-binary.bin", content_type="binary", metadata={"source": "example", "type": "binary"}, ) - + print(f"Uploaded binary object: {obj.id}") print(f"Content length: {len(binary_data)} bytes") - + # Verify by downloading downloaded_bytes = obj.download_as_bytes() print(f"Downloaded {len(downloaded_bytes)} bytes") print(f"Content matches: {binary_data == downloaded_bytes}") - + return obj def demonstrate_file_upload(sdk: RunloopSDK): """Upload a file from the filesystem.""" print("\n=== File Upload ===") - + # Create a temporary file temp_file = Path("temp_example_file.txt") temp_file.write_text("""This is a file from the filesystem. @@ -75,22 +76,22 @@ def demonstrate_file_upload(sdk: RunloopSDK): Line 3 Line 4 """) - + try: obj = sdk.storage_object.upload_from_file( temp_file, name="uploaded-file.txt", metadata={"source": "filesystem", "original": str(temp_file)}, ) - + print(f"Uploaded file object: {obj.id}") - + # Get object info info = obj.refresh() print(f"Object name: {info.name}") print(f"Content type: {info.content_type}") print(f"Metadata: {info.metadata}") - + return obj finally: # Cleanup temp file @@ -100,44 +101,44 @@ def demonstrate_file_upload(sdk: RunloopSDK): def demonstrate_manual_upload(sdk: RunloopSDK): """Demonstrate manual upload flow with create, upload, complete.""" print("\n=== Manual Upload Flow ===") - + # Step 1: Create the storage object obj = sdk.storage_object.create( name="manual-upload.txt", content_type="text", metadata={"method": "manual"}, ) - + print(f"Created storage object: {obj.id}") print(f"Upload URL: {obj.upload_url[:50]}...") - + # Step 2: Upload content to the presigned URL content = b"This content was uploaded manually using the upload flow." obj.upload_content(content) print("Content uploaded to presigned URL") - + # Step 3: Mark the upload as complete obj.complete() print("Upload marked as complete") - + # Verify downloaded = obj.download_as_text() print(f"Verified content: {downloaded[:50]}...") - + return obj def demonstrate_storage_mounting(sdk: RunloopSDK): """Mount a storage object to a devbox.""" print("\n=== Mounting Storage Objects to Devbox ===") - + # Create a storage object with some data obj = sdk.storage_object.upload_from_text( "This file is mounted in the devbox!\n", name="mounted-file.txt", ) print(f"Created storage object: {obj.id}") - + # Create a devbox with the storage object mounted devbox = sdk.devbox.create( name="storage-mount-devbox", @@ -149,49 +150,49 @@ def demonstrate_storage_mounting(sdk: RunloopSDK): } ], ) - + print(f"Created devbox: {devbox.id}") - + try: # Verify the file is accessible in the devbox result = devbox.cmd.exec("cat /home/user/mounted-data.txt") print(f"Mounted file content: {result.stdout().strip()}") - + # Check file details result = devbox.cmd.exec("ls -lh /home/user/mounted-data.txt") print(f"File details: {result.stdout().strip()}") - + # Try to use the mounted file result = devbox.cmd.exec("wc -l /home/user/mounted-data.txt") print(f"Line count: {result.stdout().strip()}") finally: devbox.shutdown() print("Devbox shutdown") - + return obj def demonstrate_archive_mounting(sdk: RunloopSDK): """Create and mount an archive that gets extracted.""" print("\n=== Mounting Archive (Extraction) ===") - + # Create a temporary directory with files - import tarfile import io - + import tarfile + # Create a tar.gz archive in memory tar_buffer = io.BytesIO() - with tarfile.open(fileobj=tar_buffer, mode='w:gz') as tar: + with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar: # Add some files for i in range(3): - content = f"File {i+1} content\n".encode() - info = tarfile.TarInfo(name=f"project/file{i+1}.txt") + content = f"File {i + 1} content\n".encode() + info = tarfile.TarInfo(name=f"project/file{i + 1}.txt") info.size = len(content) tar.addfile(info, io.BytesIO(content)) - + tar_data = tar_buffer.getvalue() print(f"Created archive with {len(tar_data)} bytes") - + # Upload the archive archive_obj = sdk.storage_object.upload_from_bytes( tar_data, @@ -199,7 +200,7 @@ def demonstrate_archive_mounting(sdk: RunloopSDK): content_type="tar", ) print(f"Uploaded archive: {archive_obj.id}") - + # Create devbox with archive mounted (it will be extracted) devbox = sdk.devbox.create( name="archive-mount-devbox", @@ -211,30 +212,30 @@ def demonstrate_archive_mounting(sdk: RunloopSDK): } ], ) - + print(f"Created devbox: {devbox.id}") - + try: # List the extracted contents result = devbox.cmd.exec("ls -la /home/user/project/") print(f"Extracted archive contents:\n{result.stdout()}") - + # Read one of the files result = devbox.cmd.exec("cat /home/user/project/file1.txt") print(f"File1 content: {result.stdout().strip()}") finally: devbox.shutdown() print("Devbox shutdown") - + return archive_obj def list_storage_objects(sdk: RunloopSDK): """List all storage objects.""" print("\n=== Listing Storage Objects ===") - + objects = sdk.storage_object.list(limit=10) - + print(f"Found {len(objects)} storage objects:") for obj in objects: info = obj.refresh() @@ -244,7 +245,7 @@ def list_storage_objects(sdk: RunloopSDK): def cleanup_storage_objects(sdk: RunloopSDK, objects): """Delete storage objects to clean up.""" print("\n=== Cleaning Up Storage Objects ===") - + for obj in objects: try: info = obj.refresh() @@ -259,38 +260,38 @@ def main(): # Initialize the SDK sdk = RunloopSDK() print("Initialized Runloop SDK\n") - + created_objects = [] - + try: # Demonstrate different upload methods text_obj = demonstrate_text_upload(sdk) created_objects.append(text_obj) - + binary_obj = demonstrate_bytes_upload(sdk) created_objects.append(binary_obj) - + file_obj = demonstrate_file_upload(sdk) created_objects.append(file_obj) - + manual_obj = demonstrate_manual_upload(sdk) created_objects.append(manual_obj) - + # Demonstrate mounting mount_obj = demonstrate_storage_mounting(sdk) created_objects.append(mount_obj) - + archive_obj = demonstrate_archive_mounting(sdk) created_objects.append(archive_obj) - + # List all objects list_storage_objects(sdk) - + finally: # Cleanup all created objects if created_objects: cleanup_storage_objects(sdk, created_objects) - + print("\nStorage object example completed!") @@ -301,10 +302,9 @@ def main(): print("Please set it to your Runloop API key:") print(" export RUNLOOP_API_KEY=your-api-key") exit(1) - + try: main() except Exception as e: print(f"\nError: {e}") raise - diff --git a/examples/streaming_output.py b/examples/streaming_output.py index dca8e067b..b479e4426 100644 --- a/examples/streaming_output.py +++ b/examples/streaming_output.py @@ -13,44 +13,45 @@ import os import asyncio from datetime import datetime + from runloop_api_client import RunloopSDK, AsyncRunloopSDK def demonstrate_basic_streaming(sdk: RunloopSDK): """Demonstrate basic stdout streaming.""" print("=== Basic Stdout Streaming ===") - + with sdk.devbox.create(name="streaming-basic-devbox") as devbox: print(f"Created devbox: {devbox.id}\n") - + # Simple callback to print output def print_output(line: str): timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] print(f"[{timestamp}] {line.rstrip()}") - + # Execute command with streaming print("Streaming command output:") result = devbox.cmd.exec( - "for i in 1 2 3 4 5; do echo \"Processing item $i\"; sleep 0.5; done", + 'for i in 1 2 3 4 5; do echo "Processing item $i"; sleep 0.5; done', stdout=print_output, ) - + print(f"\nCommand completed with exit code: {result.exit_code}") def demonstrate_stderr_streaming(sdk: RunloopSDK): """Demonstrate stderr streaming separately.""" print("\n=== Separate Stdout and Stderr Streaming ===") - + with sdk.devbox.create(name="streaming-stderr-devbox") as devbox: print(f"Created devbox: {devbox.id}\n") - + def handle_stdout(line: str): print(f"[STDOUT] {line.rstrip()}") - + def handle_stderr(line: str): print(f"[STDERR] {line.rstrip()}") - + # Command that writes to both stdout and stderr print("Streaming stdout and stderr separately:") result = devbox.cmd.exec( @@ -63,25 +64,25 @@ def handle_stderr(line: str): stdout=handle_stdout, stderr=handle_stderr, ) - + print(f"\nCommand completed with exit code: {result.exit_code}") def demonstrate_combined_streaming(sdk: RunloopSDK): """Demonstrate combined output streaming.""" print("\n=== Combined Output Streaming ===") - + with sdk.devbox.create(name="streaming-combined-devbox") as devbox: print(f"Created devbox: {devbox.id}\n") - + # Track all output all_output = [] - + def capture_all(line: str): timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] all_output.append((timestamp, line.rstrip())) print(f"[{timestamp}] {line.rstrip()}") - + # Use the 'output' parameter to capture both stdout and stderr print("Streaming combined output:") result = devbox.cmd.exec( @@ -94,7 +95,7 @@ def capture_all(line: str): """, output=capture_all, ) - + print(f"\nCommand completed with exit code: {result.exit_code}") print(f"Captured {len(all_output)} lines of output") @@ -102,10 +103,10 @@ def capture_all(line: str): def demonstrate_output_processing(sdk: RunloopSDK): """Demonstrate processing streaming output.""" print("\n=== Processing Streaming Output ===") - + with sdk.devbox.create(name="streaming-processing-devbox") as devbox: print(f"Created devbox: {devbox.id}\n") - + # Process and analyze output stats = { "total_lines": 0, @@ -113,11 +114,11 @@ def demonstrate_output_processing(sdk: RunloopSDK): "warning_lines": 0, "info_lines": 0, } - + def analyze_output(line: str): stats["total_lines"] += 1 line_lower = line.lower() - + if "error" in line_lower: stats["error_lines"] += 1 print(f"❌ ERROR: {line.rstrip()}") @@ -127,7 +128,7 @@ def analyze_output(line: str): else: stats["info_lines"] += 1 print(f"ℹ️ INFO: {line.rstrip()}") - + # Execute a script that produces different types of output print("Analyzing output in real-time:") result = devbox.cmd.exec( @@ -142,7 +143,7 @@ def analyze_output(line: str): """, stdout=analyze_output, ) - + print(f"\nCommand completed with exit code: {result.exit_code}") print(f"\nOutput Statistics:") print(f" Total lines: {stats['total_lines']}") @@ -154,12 +155,12 @@ def analyze_output(line: str): def demonstrate_long_running_stream(sdk: RunloopSDK): """Demonstrate streaming output from a long-running command.""" print("\n=== Long-running Command Streaming ===") - + with sdk.devbox.create(name="streaming-longrun-devbox") as devbox: print(f"Created devbox: {devbox.id}\n") - + progress_items = [] - + def track_progress(line: str): line = line.rstrip() if "Progress:" in line: @@ -168,7 +169,7 @@ def track_progress(line: str): print(f"📊 {line}") else: print(f" {line}") - + print("Streaming output from long-running task:") result = devbox.cmd.exec( """ @@ -181,7 +182,7 @@ def track_progress(line: str): """, stdout=track_progress, ) - + print(f"\nCommand completed with exit code: {result.exit_code}") print(f"Tracked {len(progress_items)} progress updates") @@ -189,21 +190,21 @@ def track_progress(line: str): async def demonstrate_async_streaming(): """Demonstrate async streaming with async callbacks.""" print("\n=== Async Streaming ===") - + sdk = AsyncRunloopSDK() - + async with sdk.devbox.create(name="async-streaming-devbox") as devbox: print(f"Created devbox: {devbox.id}\n") - + # Async callback with async operations output_queue = asyncio.Queue() - + async def async_capture(line: str): # Simulate async processing (e.g., writing to a database) await asyncio.sleep(0.01) await output_queue.put(line.rstrip()) print(f"[ASYNC] {line.rstrip()}") - + # Start processing task async def process_queue(): processed = [] @@ -214,16 +215,16 @@ async def process_queue(): except asyncio.TimeoutError: break return processed - + processor = asyncio.create_task(process_queue()) - + # Execute with async streaming print("Streaming with async callbacks:") await devbox.cmd.exec( - "for i in 1 2 3 4 5; do echo \"Async line $i\"; sleep 0.2; done", + 'for i in 1 2 3 4 5; do echo "Async line $i"; sleep 0.2; done', stdout=async_capture, ) - + # Wait for queue processing processed = await processor print(f"\nProcessed {len(processed)} lines asynchronously") @@ -233,25 +234,25 @@ def main(): # Initialize the SDK sdk = RunloopSDK() print("Initialized Runloop SDK\n") - + # Run synchronous streaming demonstrations demonstrate_basic_streaming(sdk) demonstrate_stderr_streaming(sdk) demonstrate_combined_streaming(sdk) demonstrate_output_processing(sdk) demonstrate_long_running_stream(sdk) - + print("\nSynchronous streaming examples completed!") async def async_main(): """Run async streaming demonstrations.""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("Running Async Examples") - print("="*60 + "\n") - + print("=" * 60 + "\n") + await demonstrate_async_streaming() - + print("\nAsync streaming examples completed!") @@ -262,18 +263,17 @@ async def async_main(): print("Please set it to your Runloop API key:") print(" export RUNLOOP_API_KEY=your-api-key") exit(1) - + try: # Run synchronous examples main() - + # Run async examples asyncio.run(async_main()) - - print("\n" + "="*60) + + print("\n" + "=" * 60) print("All streaming examples completed successfully!") - print("="*60) + print("=" * 60) except Exception as e: print(f"\nError: {e}") raise - diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index 492aaeffa..e6419ccc3 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -27,16 +27,16 @@ class Devbox: """High-level interface for managing a Runloop devbox. - + This class provides a Pythonic, object-oriented API for interacting with devboxes, including command execution, file operations, networking, and lifecycle management. - + The Devbox class supports context manager protocol for automatic cleanup: >>> with sdk.devbox.create(name="my-devbox") as devbox: ... result = devbox.cmd.exec("echo 'hello'") ... print(result.stdout()) # Devbox is automatically shutdown on exit - + Attributes: id: The devbox identifier. cmd: Command execution interface (exec, exec_async). @@ -75,7 +75,7 @@ def get_info( timeout: float | Timeout | None | NotGiven = not_given, ) -> DevboxView: """Retrieve current devbox status and metadata. - + Returns: DevboxView containing the devbox's current state, status, and metadata. """ @@ -89,12 +89,12 @@ def get_info( def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView: """Wait for the devbox to reach running state. - + Blocks until the devbox is running or the polling timeout is reached. - + Args: polling_config: Optional configuration for polling behavior (timeout, interval). - + Returns: DevboxView with the devbox in running state. """ @@ -102,12 +102,12 @@ def await_running(self, *, polling_config: PollingConfig | None = None) -> Devbo def await_suspended(self, *, polling_config: PollingConfig | None = None) -> DevboxView: """Wait for the devbox to reach suspended state. - + Blocks until the devbox is suspended or the polling timeout is reached. - + Args: polling_config: Optional configuration for polling behavior (timeout, interval). - + Returns: DevboxView with the devbox in suspended state. """ @@ -123,7 +123,7 @@ def shutdown( idempotency_key: str | None = None, ) -> DevboxView: """Shutdown the devbox, terminating all processes and releasing resources. - + Returns: DevboxView with the final devbox state. """ @@ -147,13 +147,13 @@ def suspend( idempotency_key: str | None = None, ) -> DevboxView: """Suspend the devbox, pausing execution while preserving state. - + This saves resources while maintaining the devbox state for later resumption. Waits for the devbox to reach suspended state before returning. - + Args: polling_config: Optional configuration for polling behavior (timeout, interval). - + Returns: DevboxView with the devbox in suspended state. """ @@ -178,12 +178,12 @@ def resume( idempotency_key: str | None = None, ) -> DevboxView: """Resume a suspended devbox, restoring it to running state. - + Waits for the devbox to reach running state before returning. - + Args: polling_config: Optional configuration for polling behavior (timeout, interval). - + Returns: DevboxView with the devbox in running state. """ @@ -207,10 +207,10 @@ def keep_alive( idempotency_key: str | None = None, ) -> object: """Extend the devbox timeout, preventing automatic shutdown. - + Call this periodically for long-running workflows to prevent the devbox from being automatically shut down due to inactivity. - + Returns: Response object confirming the keep-alive request. """ @@ -237,16 +237,16 @@ def snapshot_disk( idempotency_key: str | None = None, ) -> "Snapshot": """Create a disk snapshot of the devbox and wait for completion. - + Captures the current state of the devbox disk, which can be used to create new devboxes with the same state. - + Args: commit_message: Optional message describing the snapshot. metadata: Optional key-value metadata to attach to the snapshot. name: Optional name for the snapshot. polling_config: Optional configuration for polling behavior (timeout, interval). - + Returns: Snapshot object representing the completed snapshot. """ @@ -284,15 +284,15 @@ def snapshot_disk_async( idempotency_key: str | None = None, ) -> "Snapshot": """Create a disk snapshot of the devbox asynchronously. - + Starts the snapshot creation process and returns immediately without waiting for completion. Use snapshot.await_completed() to wait for completion. - + Args: commit_message: Optional message describing the snapshot. metadata: Optional key-value metadata to attach to the snapshot. name: Optional name for the snapshot. - + Returns: Snapshot object (snapshot may still be in progress). """ @@ -342,7 +342,7 @@ def _start_streaming( output: Optional[LogCallback] = None, ) -> Optional[_StreamingGroup]: """Set up background threads to stream command output to callbacks. - + Creates separate threads for stdout and stderr streams, allowing real-time processing of command output through user-provided callbacks. """ @@ -420,11 +420,11 @@ def worker() -> None: class _CommandInterface: """Interface for executing commands on a devbox. - + Accessed via devbox.cmd property. Provides exec() for synchronous execution and exec_async() for asynchronous execution with process management. """ - + def __init__(self, devbox: Devbox) -> None: self._devbox = devbox @@ -445,7 +445,7 @@ def exec( idempotency_key: str | None = None, ) -> ExecutionResult: """Execute a command synchronously and wait for completion. - + Args: command: The shell command to execute. shell_name: Optional shell to use (e.g., "bash", "sh"). @@ -454,10 +454,10 @@ def exec( output: Optional callback to receive combined output lines in real-time. polling_config: Optional configuration for polling behavior. attach_stdin: Whether to attach stdin for interactive commands. - + Returns: ExecutionResult with exit code and captured output. - + Example: >>> result = devbox.cmd.exec("ls -la") >>> print(result.stdout()) @@ -531,11 +531,11 @@ def exec_async( idempotency_key: str | None = None, ) -> Execution: """Execute a command asynchronously without waiting for completion. - + Starts command execution and returns immediately with an Execution object for process management. Use execution.result() to wait for completion or execution.kill() to terminate the process. - + Args: command: The shell command to execute. shell_name: Optional shell to use (e.g., "bash", "sh"). @@ -543,10 +543,10 @@ def exec_async( stderr: Optional callback to receive stderr lines in real-time. output: Optional callback to receive combined output lines in real-time. attach_stdin: Whether to attach stdin for interactive commands. - + Returns: Execution object for managing the running process. - + Example: >>> execution = devbox.cmd.exec_async("sleep 10") >>> state = execution.get_state() @@ -580,11 +580,11 @@ def exec_async( class _FileInterface: """Interface for file operations on a devbox. - + Accessed via devbox.file property. Provides methods for reading, writing, uploading, and downloading files. """ - + def __init__(self, devbox: Devbox) -> None: self._devbox = devbox @@ -599,13 +599,13 @@ def read( idempotency_key: str | None = None, ) -> str: """Read a file from the devbox. - + Args: path: Absolute path to the file in the devbox. - + Returns: File contents as a string. - + Example: >>> content = devbox.file.read("/home/user/data.txt") >>> print(content) @@ -632,16 +632,16 @@ def write( idempotency_key: str | None = None, ) -> DevboxExecutionDetailView: """Write contents to a file in the devbox. - + Creates or overwrites the file at the specified path. - + Args: path: Absolute path to the file in the devbox. contents: File contents as string or bytes (bytes are decoded as UTF-8). - + Returns: Execution details for the write operation. - + Example: >>> devbox.file.write("/home/user/config.json", '{"key": "value"}') """ @@ -672,13 +672,13 @@ def download( idempotency_key: str | None = None, ) -> bytes: """Download a file from the devbox. - + Args: path: Absolute path to the file in the devbox. - + Returns: File contents as bytes. - + Example: >>> data = devbox.file.download("/home/user/output.bin") >>> with open("local_output.bin", "wb") as f: @@ -707,14 +707,14 @@ def upload( idempotency_key: str | None = None, ) -> object: """Upload a file to the devbox. - + Args: path: Destination path in the devbox. file: File to upload (Path, file-like object, or bytes). - + Returns: Response object confirming the upload. - + Example: >>> from pathlib import Path >>> devbox.file.upload("/home/user/data.csv", Path("local_data.csv")) @@ -733,10 +733,10 @@ def upload( class _NetworkInterface: """Interface for network operations on a devbox. - + Accessed via devbox.net property. Provides methods for SSH access and tunneling. """ - + def __init__(self, devbox: Devbox) -> None: self._devbox = devbox @@ -750,10 +750,10 @@ def create_ssh_key( idempotency_key: str | None = None, ) -> DevboxCreateSSHKeyResponse: """Create an SSH key for remote access to the devbox. - + Returns: SSH key response containing the SSH URL and credentials. - + Example: >>> ssh_key = devbox.net.create_ssh_key() >>> print(f"SSH URL: {ssh_key.url}") @@ -778,13 +778,13 @@ def create_tunnel( idempotency_key: str | None = None, ) -> DevboxTunnelView: """Create a network tunnel to expose a devbox port publicly. - + Args: port: The port number in the devbox to expose. - + Returns: DevboxTunnelView containing the public URL for the tunnel. - + Example: >>> tunnel = devbox.net.create_tunnel(port=8080) >>> print(f"Public URL: {tunnel.url}") @@ -810,13 +810,13 @@ def remove_tunnel( idempotency_key: str | None = None, ) -> object: """Remove a network tunnel, disabling public access to the port. - + Args: port: The port number of the tunnel to remove. - + Returns: Response object confirming the tunnel removal. - + Example: >>> devbox.net.remove_tunnel(port=8080) """ diff --git a/src/runloop_api_client/sdk/execution.py b/src/runloop_api_client/sdk/execution.py index ab00894c4..31eba32d3 100644 --- a/src/runloop_api_client/sdk/execution.py +++ b/src/runloop_api_client/sdk/execution.py @@ -36,14 +36,14 @@ def active(self) -> bool: class Execution: """Manages an asynchronous command execution on a devbox. - + Provides methods to poll execution state, wait for completion, and terminate the running process. Created by devbox.cmd.exec_async(). - + Attributes: execution_id: The unique execution identifier. devbox_id: The devbox where the command is executing. - + Example: >>> execution = devbox.cmd.exec_async("python train.py") >>> state = execution.get_state() From d8c8d738b32481926c31ca3c0e0e80193e4ae83d Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 13 Nov 2025 14:03:58 -0800 Subject: [PATCH 26/56] unpack TypedDict params directly instead of explicitly declaring them --- src/runloop_api_client/sdk/_async.py | 111 ++++----------------- src/runloop_api_client/sdk/_sync.py | 138 +++++++-------------------- 2 files changed, 54 insertions(+), 195 deletions(-) diff --git a/src/runloop_api_client/sdk/_async.py b/src/runloop_api_client/sdk/_async.py index 57fa7ac73..4dcbc087c 100644 --- a/src/runloop_api_client/sdk/_async.py +++ b/src/runloop_api_client/sdk/_async.py @@ -1,11 +1,12 @@ from __future__ import annotations -from typing import Dict, Literal, Mapping, Iterable, Optional +from typing import Dict, Mapping, Iterable, Optional from pathlib import Path +from typing_extensions import Unpack import httpx -from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, SequenceNotStr, omit, not_given +from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given from .._client import AsyncRunloop from ._helpers import ContentType, detect_content_type from ..lib.polling import PollingConfig @@ -13,9 +14,14 @@ from .async_snapshot import AsyncSnapshot from .async_blueprint import AsyncBlueprint from .async_storage_object import AsyncStorageObject +from ..types.devbox_list_params import DevboxListParams +from ..types.object_list_params import ObjectListParams from ..types.shared_params.mount import Mount -from ..types.blueprint_create_params import Service +from ..types.devbox_create_params import DevboxCreateParams +from ..types.blueprint_list_params import BlueprintListParams +from ..types.blueprint_create_params import BlueprintCreateParams from ..types.shared_params.launch_parameters import LaunchParameters +from ..types.devboxes.disk_snapshot_list_params import DiskSnapshotListParams from ..types.shared_params.code_mount_parameters import CodeMountParameters @@ -28,46 +34,22 @@ def __init__(self, client: AsyncRunloop) -> None: async def create( self, *, - blueprint_id: Optional[str] | Omit = omit, - blueprint_name: Optional[str] | Omit = omit, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - entrypoint: Optional[str] | Omit = omit, - environment_variables: Optional[Dict[str, str]] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - mounts: Optional[Iterable[Mount]] | Omit = omit, - name: Optional[str] | Omit = omit, - repo_connection_id: Optional[str] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - snapshot_id: Optional[str] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, + **params: Unpack[DevboxCreateParams], ) -> AsyncDevbox: devbox_view = await self._client.devboxes.create_and_await_running( - blueprint_id=blueprint_id, - blueprint_name=blueprint_name, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - mounts=mounts, - name=name, - repo_connection_id=repo_connection_id, - secrets=secrets, - snapshot_id=snapshot_id, polling_config=polling_config, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, idempotency_key=idempotency_key, + **params, ) return AsyncDevbox(self._client, devbox_view.id) @@ -203,25 +185,18 @@ def from_id(self, devbox_id: str) -> AsyncDevbox: async def list( self, *, - limit: int | Omit = omit, - starting_after: str | Omit = omit, - status: Literal[ - "provisioning", "initializing", "running", "suspending", "suspended", "resuming", "failure", "shutdown" - ] - | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[DevboxListParams], ) -> list[AsyncDevbox]: page = await self._client.devboxes.list( - limit=limit, - starting_after=starting_after, - status=status, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, + **params, ) return [AsyncDevbox(self._client, item.id) for item in page.devboxes] @@ -235,26 +210,18 @@ def __init__(self, client: AsyncRunloop) -> None: async def list( self, *, - devbox_id: str | Omit = omit, - limit: int | Omit = omit, - metadata_key: str | Omit = omit, - metadata_key_in: str | Omit = omit, - starting_after: str | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[DiskSnapshotListParams], ) -> list[AsyncSnapshot]: page = await self._client.devboxes.disk_snapshots.list( - devbox_id=devbox_id, - limit=limit, - metadata_key=metadata_key, - metadata_key_in=metadata_key_in, - starting_after=starting_after, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, + **params, ) return [AsyncSnapshot(self._client, item.id) for item in page.snapshots] @@ -271,44 +238,22 @@ def __init__(self, client: AsyncRunloop) -> None: async def create( self, *, - name: str, - base_blueprint_id: Optional[str] | Omit = omit, - base_blueprint_name: Optional[str] | Omit = omit, - build_args: Optional[Dict[str, str]] | Omit = omit, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - dockerfile: Optional[str] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - services: Optional[Iterable[Service]] | Omit = omit, - system_setup_commands: Optional[SequenceNotStr[str]] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, + **params: Unpack[BlueprintCreateParams], ) -> AsyncBlueprint: blueprint = await self._client.blueprints.create_and_await_build_complete( - name=name, - base_blueprint_id=base_blueprint_id, - base_blueprint_name=base_blueprint_name, - build_args=build_args, - code_mounts=code_mounts, - dockerfile=dockerfile, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - secrets=secrets, - services=services, - system_setup_commands=system_setup_commands, polling_config=polling_config, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, idempotency_key=idempotency_key, + **params, ) return AsyncBlueprint(self._client, blueprint.id) @@ -318,22 +263,18 @@ def from_id(self, blueprint_id: str) -> AsyncBlueprint: async def list( self, *, - limit: int | Omit = omit, - name: str | Omit = omit, - starting_after: str | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[BlueprintListParams], ) -> list[AsyncBlueprint]: page = await self._client.blueprints.list( - limit=limit, - name=name, - starting_after=starting_after, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, + **params, ) return [AsyncBlueprint(self._client, item.id) for item in page.blueprints] @@ -361,28 +302,18 @@ def from_id(self, object_id: str) -> AsyncStorageObject: async def list( self, *, - content_type: str | Omit = omit, - limit: int | Omit = omit, - name: str | Omit = omit, - search: str | Omit = omit, - starting_after: str | Omit = omit, - state: str | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[ObjectListParams], ) -> list[AsyncStorageObject]: page = await self._client.objects.list( - content_type=content_type, - limit=limit, - name=name, - search=search, - starting_after=starting_after, - state=state, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, + **params, ) return [AsyncStorageObject(self._client, item.id, upload_url=item.upload_url) for item in page.objects] diff --git a/src/runloop_api_client/sdk/_sync.py b/src/runloop_api_client/sdk/_sync.py index 05ef030f4..ac36303d6 100644 --- a/src/runloop_api_client/sdk/_sync.py +++ b/src/runloop_api_client/sdk/_sync.py @@ -1,30 +1,36 @@ from __future__ import annotations -from typing import Dict, Literal, Mapping, Iterable, Optional +from typing import Dict, Mapping, Iterable, Optional from pathlib import Path +from typing_extensions import Unpack import httpx from .devbox import Devbox -from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, SequenceNotStr, omit, not_given +from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given from .._client import Runloop from ._helpers import ContentType, detect_content_type from .snapshot import Snapshot from .blueprint import Blueprint from ..lib.polling import PollingConfig from .storage_object import StorageObject +from ..types.devbox_list_params import DevboxListParams +from ..types.object_list_params import ObjectListParams from ..types.shared_params.mount import Mount -from ..types.blueprint_create_params import Service +from ..types.devbox_create_params import DevboxCreateParams +from ..types.blueprint_list_params import BlueprintListParams +from ..types.blueprint_create_params import BlueprintCreateParams from ..types.shared_params.launch_parameters import LaunchParameters +from ..types.devboxes.disk_snapshot_list_params import DiskSnapshotListParams from ..types.shared_params.code_mount_parameters import CodeMountParameters class DevboxClient: """High-level manager for creating and managing Devbox instances. - + Accessed via sdk.devbox, provides methods to create devboxes from scratch, blueprints, or snapshots, and to list existing devboxes. - + Example: >>> sdk = RunloopSDK() >>> devbox = sdk.devbox.create(name="my-devbox") @@ -37,46 +43,22 @@ def __init__(self, client: Runloop) -> None: def create( self, *, - blueprint_id: Optional[str] | Omit = omit, - blueprint_name: Optional[str] | Omit = omit, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - entrypoint: Optional[str] | Omit = omit, - environment_variables: Optional[Dict[str, str]] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - mounts: Optional[Iterable[Mount]] | Omit = omit, - name: Optional[str] | Omit = omit, - repo_connection_id: Optional[str] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - snapshot_id: Optional[str] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, + **params: Unpack[DevboxCreateParams], ) -> Devbox: devbox_view = self._client.devboxes.create_and_await_running( - blueprint_id=blueprint_id, - blueprint_name=blueprint_name, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - mounts=mounts, - name=name, - repo_connection_id=repo_connection_id, - secrets=secrets, - snapshot_id=snapshot_id, polling_config=polling_config, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, idempotency_key=idempotency_key, + **params, ) return Devbox(self._client, devbox_view.id) @@ -213,35 +195,28 @@ def from_id(self, devbox_id: str) -> Devbox: def list( self, *, - limit: int | Omit = omit, - starting_after: str | Omit = omit, - status: Literal[ - "provisioning", "initializing", "running", "suspending", "suspended", "resuming", "failure", "shutdown" - ] - | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[DevboxListParams], ) -> list[Devbox]: page = self._client.devboxes.list( - limit=limit, - starting_after=starting_after, - status=status, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, + **params, ) return [Devbox(self._client, item.id) for item in page.devboxes] class SnapshotClient: """High-level manager for working with disk snapshots. - + Accessed via sdk.snapshot, provides methods to list snapshots and access snapshot details. - + Example: >>> sdk = RunloopSDK() >>> snapshots = sdk.snapshot.list(devbox_id="dev-123") @@ -254,26 +229,18 @@ def __init__(self, client: Runloop) -> None: def list( self, *, - devbox_id: str | Omit = omit, - limit: int | Omit = omit, - metadata_key: str | Omit = omit, - metadata_key_in: str | Omit = omit, - starting_after: str | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[DiskSnapshotListParams], ) -> list[Snapshot]: page = self._client.devboxes.disk_snapshots.list( - devbox_id=devbox_id, - limit=limit, - metadata_key=metadata_key, - metadata_key_in=metadata_key_in, - starting_after=starting_after, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, + **params, ) return [Snapshot(self._client, item.id) for item in page.snapshots] @@ -283,16 +250,13 @@ def from_id(self, snapshot_id: str) -> Snapshot: class BlueprintClient: """High-level manager for creating and managing blueprints. - + Accessed via sdk.blueprint, provides methods to create blueprints with Dockerfiles and system setup commands, and to list existing blueprints. - + Example: >>> sdk = RunloopSDK() - >>> blueprint = sdk.blueprint.create( - ... name="my-blueprint", - ... dockerfile="FROM ubuntu:22.04\\nRUN apt-get update" - ... ) + >>> blueprint = sdk.blueprint.create(name="my-blueprint", dockerfile="FROM ubuntu:22.04\\nRUN apt-get update") >>> blueprints = sdk.blueprint.list() """ @@ -302,44 +266,22 @@ def __init__(self, client: Runloop) -> None: def create( self, *, - name: str, - base_blueprint_id: Optional[str] | Omit = omit, - base_blueprint_name: Optional[str] | Omit = omit, - build_args: Optional[Dict[str, str]] | Omit = omit, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - dockerfile: Optional[str] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - services: Optional[Iterable[Service]] | Omit = omit, - system_setup_commands: Optional[SequenceNotStr[str]] | Omit = omit, polling_config: PollingConfig | None = None, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, + **params: Unpack[BlueprintCreateParams], ) -> Blueprint: blueprint = self._client.blueprints.create_and_await_build_complete( - name=name, - base_blueprint_id=base_blueprint_id, - base_blueprint_name=base_blueprint_name, - build_args=build_args, - code_mounts=code_mounts, - dockerfile=dockerfile, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - secrets=secrets, - services=services, - system_setup_commands=system_setup_commands, polling_config=polling_config, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, idempotency_key=idempotency_key, + **params, ) return Blueprint(self._client, blueprint.id) @@ -349,32 +291,28 @@ def from_id(self, blueprint_id: str) -> Blueprint: def list( self, *, - limit: int | Omit = omit, - name: str | Omit = omit, - starting_after: str | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[BlueprintListParams], ) -> list[Blueprint]: page = self._client.blueprints.list( - limit=limit, - name=name, - starting_after=starting_after, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, + **params, ) return [Blueprint(self._client, item.id) for item in page.blueprints] class StorageObjectClient: """High-level manager for creating and managing storage objects. - + Accessed via sdk.storage_object, provides methods to create, upload, download, and list storage objects with convenient helpers for file and text uploads. - + Example: >>> sdk = RunloopSDK() >>> obj = sdk.storage_object.upload_from_text("Hello!", "greeting.txt") @@ -402,28 +340,18 @@ def from_id(self, object_id: str) -> StorageObject: def list( self, *, - content_type: str | Omit = omit, - limit: int | Omit = omit, - name: str | Omit = omit, - search: str | Omit = omit, - starting_after: str | Omit = omit, - state: str | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[ObjectListParams], ) -> list[StorageObject]: page = self._client.objects.list( - content_type=content_type, - limit=limit, - name=name, - search=search, - starting_after=starting_after, - state=state, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout, + **params, ) return [StorageObject(self._client, item.id, upload_url=item.upload_url) for item in page.objects] @@ -470,18 +398,18 @@ def upload_from_bytes( class RunloopSDK: """High-level synchronous entry point for the Runloop SDK. - + Provides a Pythonic, object-oriented interface for managing devboxes, blueprints, snapshots, and storage objects. Exposes the generated REST client via the ``api`` attribute for advanced use cases. - + Attributes: api: Direct access to the generated REST API client. devbox: High-level interface for devbox management. blueprint: High-level interface for blueprint management. snapshot: High-level interface for snapshot management. storage_object: High-level interface for storage object management. - + Example: >>> sdk = RunloopSDK() # Uses RUNLOOP_API_KEY env var >>> with sdk.devbox.create(name="my-devbox") as devbox: From aba0b81ace151597fb58ff21f3f1b760f23322de Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 13 Nov 2025 14:46:59 -0800 Subject: [PATCH 27/56] corrected examples --- examples/async_devbox.py | 18 +++++------ examples/blueprint_example.py | 11 ++++--- examples/storage_example.py | 12 ++++--- examples/streaming_output.py | 59 ++++++++++++++--------------------- 4 files changed, 46 insertions(+), 54 deletions(-) diff --git a/examples/async_devbox.py b/examples/async_devbox.py index 315edbaad..dab9312f0 100644 --- a/examples/async_devbox.py +++ b/examples/async_devbox.py @@ -22,7 +22,7 @@ async def demonstrate_basic_async(): sdk = AsyncRunloopSDK() # Create a devbox with async context manager - async with sdk.devbox.create(name="async-example-devbox") as devbox: + async with await sdk.devbox.create(name="async-example-devbox") as devbox: print(f"Created devbox: {devbox.id}") # Execute command asynchronously @@ -47,7 +47,7 @@ async def demonstrate_concurrent_commands(): sdk = AsyncRunloopSDK() - async with sdk.devbox.create(name="concurrent-commands-devbox") as devbox: + async with await sdk.devbox.create(name="concurrent-commands-devbox") as devbox: print(f"Created devbox: {devbox.id}") # Execute multiple commands concurrently @@ -76,7 +76,7 @@ async def demonstrate_multiple_devboxes(): async def create_and_use_devbox(name: str, number: int): """Create a devbox, run a command, and return the result.""" - async with sdk.devbox.create(name=name) as devbox: + async with await sdk.devbox.create(name=name) as devbox: print(f"Devbox {number} ({devbox.id}): Created") # Run a command @@ -97,18 +97,18 @@ async def create_and_use_devbox(name: str, number: int): async def demonstrate_async_streaming(): - """Demonstrate real-time command output streaming with async callbacks.""" + """Demonstrate real-time command output streaming with synchronous callbacks.""" print("=== Async Command Streaming ===") sdk = AsyncRunloopSDK() - async with sdk.devbox.create(name="streaming-devbox") as devbox: + async with await sdk.devbox.create(name="streaming-devbox") as devbox: print(f"Created devbox: {devbox.id}") - # Async callback to capture output - output_lines = [] + # Synchronous callback to capture output (callbacks must be sync, not async) + output_lines: list[str] = [] - async def capture_output(line: str): + def capture_output(line: str): print(f"[STREAM] {line.strip()}") output_lines.append(line) @@ -128,7 +128,7 @@ async def demonstrate_async_execution(): sdk = AsyncRunloopSDK() - async with sdk.devbox.create(name="async-exec-devbox") as devbox: + async with await sdk.devbox.create(name="async-exec-devbox") as devbox: print(f"Created devbox: {devbox.id}") # Start an async execution diff --git a/examples/blueprint_example.py b/examples/blueprint_example.py index 1ffd3c2bd..d76a822ae 100644 --- a/examples/blueprint_example.py +++ b/examples/blueprint_example.py @@ -13,6 +13,7 @@ import os from runloop_api_client import RunloopSDK +from runloop_api_client.sdk import Blueprint def create_simple_blueprint(sdk: RunloopSDK): @@ -119,7 +120,7 @@ def create_blueprint_from_base(sdk: RunloopSDK): return base_blueprint, derived_blueprint -def use_blueprint_to_create_devbox(sdk: RunloopSDK, blueprint): +def use_blueprint_to_create_devbox(blueprint: Blueprint): """Create and use a devbox from a blueprint.""" print("\n=== Creating Devbox from Blueprint ===") @@ -157,7 +158,7 @@ def list_blueprints(sdk: RunloopSDK): print(f" - {info.name} ({bp.id}): {info.status}") -def cleanup_blueprints(sdk: RunloopSDK, blueprints): +def cleanup_blueprints(blueprints: list[Blueprint]): """Delete blueprints to clean up.""" print("\n=== Cleaning Up Blueprints ===") @@ -176,7 +177,7 @@ def main(): sdk = RunloopSDK() print("Initialized Runloop SDK\n") - created_blueprints = [] + created_blueprints: list[Blueprint] = [] try: # Create simple blueprint @@ -192,7 +193,7 @@ def main(): created_blueprints.extend([base_bp, derived_bp]) # Use a blueprint to create a devbox - use_blueprint_to_create_devbox(sdk, simple_bp) + use_blueprint_to_create_devbox(simple_bp) # List all blueprints list_blueprints(sdk) @@ -200,7 +201,7 @@ def main(): finally: # Cleanup all created blueprints if created_blueprints: - cleanup_blueprints(sdk, created_blueprints) + cleanup_blueprints(created_blueprints) print("\nBlueprint example completed!") diff --git a/examples/storage_example.py b/examples/storage_example.py index 09f9dd6b5..1cc8abc6a 100644 --- a/examples/storage_example.py +++ b/examples/storage_example.py @@ -14,6 +14,7 @@ from pathlib import Path from runloop_api_client import RunloopSDK +from runloop_api_client.sdk import StorageObject def demonstrate_text_upload(sdk: RunloopSDK): @@ -90,7 +91,8 @@ def demonstrate_file_upload(sdk: RunloopSDK): info = obj.refresh() print(f"Object name: {info.name}") print(f"Content type: {info.content_type}") - print(f"Metadata: {info.metadata}") + # TODO: Add metadata to the object + # print(f"Metadata: {info.metadata}") return obj finally: @@ -110,7 +112,7 @@ def demonstrate_manual_upload(sdk: RunloopSDK): ) print(f"Created storage object: {obj.id}") - print(f"Upload URL: {obj.upload_url[:50]}...") + print(f"Upload URL: {obj.upload_url[:50] if obj.upload_url is not None else 'None'}...") # Step 2: Upload content to the presigned URL content = b"This content was uploaded manually using the upload flow." @@ -242,7 +244,7 @@ def list_storage_objects(sdk: RunloopSDK): print(f" - {info.name} ({obj.id}): {info.content_type}") -def cleanup_storage_objects(sdk: RunloopSDK, objects): +def cleanup_storage_objects(objects: list[StorageObject]): """Delete storage objects to clean up.""" print("\n=== Cleaning Up Storage Objects ===") @@ -261,7 +263,7 @@ def main(): sdk = RunloopSDK() print("Initialized Runloop SDK\n") - created_objects = [] + created_objects: list[StorageObject] = [] try: # Demonstrate different upload methods @@ -290,7 +292,7 @@ def main(): finally: # Cleanup all created objects if created_objects: - cleanup_storage_objects(sdk, created_objects) + cleanup_storage_objects(created_objects) print("\nStorage object example completed!") diff --git a/examples/streaming_output.py b/examples/streaming_output.py index b479e4426..bf5984b60 100644 --- a/examples/streaming_output.py +++ b/examples/streaming_output.py @@ -7,7 +7,7 @@ - Streaming stderr - Streaming combined output - Processing output line-by-line -- Async streaming callbacks +- Using synchronous callbacks (async callbacks not supported) """ import os @@ -76,7 +76,7 @@ def demonstrate_combined_streaming(sdk: RunloopSDK): print(f"Created devbox: {devbox.id}\n") # Track all output - all_output = [] + all_output: list[tuple[str, str]] = [] def capture_all(line: str): timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] @@ -159,7 +159,7 @@ def demonstrate_long_running_stream(sdk: RunloopSDK): with sdk.devbox.create(name="streaming-longrun-devbox") as devbox: print(f"Created devbox: {devbox.id}\n") - progress_items = [] + progress_items: list[str] = [] def track_progress(line: str): line = line.rstrip() @@ -188,46 +188,35 @@ def track_progress(line: str): async def demonstrate_async_streaming(): - """Demonstrate async streaming with async callbacks.""" - print("\n=== Async Streaming ===") + """Demonstrate async devbox with synchronous callbacks. + + Note: Callbacks must be synchronous functions, not async. + Use thread-safe queues if you need to process output asynchronously. + """ + print("\n=== Async Devbox with Synchronous Callbacks ===") sdk = AsyncRunloopSDK() - async with sdk.devbox.create(name="async-streaming-devbox") as devbox: + async with await sdk.devbox.create(name="async-streaming-devbox") as devbox: print(f"Created devbox: {devbox.id}\n") - # Async callback with async operations - output_queue = asyncio.Queue() - - async def async_capture(line: str): - # Simulate async processing (e.g., writing to a database) - await asyncio.sleep(0.01) - await output_queue.put(line.rstrip()) - print(f"[ASYNC] {line.rstrip()}") - - # Start processing task - async def process_queue(): - processed = [] - while True: - try: - line = await asyncio.wait_for(output_queue.get(), timeout=2.0) - processed.append(line) - except asyncio.TimeoutError: - break - return processed - - processor = asyncio.create_task(process_queue()) - - # Execute with async streaming - print("Streaming with async callbacks:") + # Synchronous callback (callbacks must be sync, not async) + output_lines: list[str] = [] + + def capture_output(line: str): + # Callback must be synchronous + output_lines.append(line.rstrip()) + print(f"[CAPTURED] {line.rstrip()}") + + # Execute with synchronous callback + print("Streaming with synchronous callbacks:") await devbox.cmd.exec( - 'for i in 1 2 3 4 5; do echo "Async line $i"; sleep 0.2; done', - stdout=async_capture, + 'for i in 1 2 3 4 5; do echo "Line $i"; sleep 0.2; done', + stdout=capture_output, ) - # Wait for queue processing - processed = await processor - print(f"\nProcessed {len(processed)} lines asynchronously") + print(f"\nCaptured {len(output_lines)} lines") + print(f"Output: {output_lines}") def main(): From 823db5c58cfb09b387e06682ad0c267b6eb1fbda Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 13 Nov 2025 15:40:31 -0800 Subject: [PATCH 28/56] cleaned up underscore prefixes: renamed sync and async modules, and added protocol interface for devbox cmd/file/net --- src/runloop_api_client/sdk/__init__.py | 27 +- .../sdk/{_async.py => async_.py} | 0 src/runloop_api_client/sdk/async_blueprint.py | 2 +- src/runloop_api_client/sdk/async_devbox.py | 7 +- src/runloop_api_client/sdk/async_snapshot.py | 2 +- src/runloop_api_client/sdk/blueprint.py | 2 +- src/runloop_api_client/sdk/devbox.py | 7 +- src/runloop_api_client/sdk/protocols.py | 301 ++++++++++++++++++ src/runloop_api_client/sdk/snapshot.py | 2 +- .../sdk/{_sync.py => sync.py} | 0 10 files changed, 328 insertions(+), 22 deletions(-) rename src/runloop_api_client/sdk/{_async.py => async_.py} (100%) create mode 100644 src/runloop_api_client/sdk/protocols.py rename src/runloop_api_client/sdk/{_sync.py => sync.py} (100%) diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py index a1b73fc00..8c8ebef3d 100644 --- a/src/runloop_api_client/sdk/__init__.py +++ b/src/runloop_api_client/sdk/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations -from ._sync import RunloopSDK, DevboxClient, SnapshotClient, BlueprintClient, StorageObjectClient -from ._async import ( +from .sync import RunloopSDK, DevboxClient, SnapshotClient, BlueprintClient, StorageObjectClient +from .async_ import ( AsyncRunloopSDK, AsyncDevboxClient, AsyncSnapshotClient, @@ -22,26 +22,29 @@ from .async_execution_result import AsyncExecutionResult __all__ = [ + # Main SDK entry points "RunloopSDK", "AsyncRunloopSDK", - "Devbox", + # Management interfaces "DevboxClient", - "Execution", - "ExecutionResult", - "Blueprint", + "AsyncDevboxClient", "BlueprintClient", - "Snapshot", + "AsyncBlueprintClient", "SnapshotClient", - "StorageObject", + "AsyncSnapshotClient", "StorageObjectClient", + "AsyncStorageObjectClient", + # Resource classes + "Devbox", "AsyncDevbox", - "AsyncDevboxClient", + "Execution", "AsyncExecution", + "ExecutionResult", "AsyncExecutionResult", + "Blueprint", "AsyncBlueprint", - "AsyncBlueprintClient", + "Snapshot", "AsyncSnapshot", - "AsyncSnapshotClient", + "StorageObject", "AsyncStorageObject", - "AsyncStorageObjectClient", ] diff --git a/src/runloop_api_client/sdk/_async.py b/src/runloop_api_client/sdk/async_.py similarity index 100% rename from src/runloop_api_client/sdk/_async.py rename to src/runloop_api_client/sdk/async_.py diff --git a/src/runloop_api_client/sdk/async_blueprint.py b/src/runloop_api_client/sdk/async_blueprint.py index 237a469bd..ddab866f5 100644 --- a/src/runloop_api_client/sdk/async_blueprint.py +++ b/src/runloop_api_client/sdk/async_blueprint.py @@ -104,7 +104,7 @@ async def create_devbox( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> "AsyncDevbox": - from ._async import AsyncDevboxClient + from .async_ import AsyncDevboxClient devbox_client = AsyncDevboxClient(self._client) return await devbox_client.create_from_blueprint_id( diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index 9f3ae3be6..96f7096cf 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -14,6 +14,7 @@ from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, FileTypes, omit, not_given from .._client import AsyncRunloop from ._helpers import LogCallback +from .protocols import AsyncFileInterface, AsyncCommandInterface, AsyncNetworkInterface from .._streaming import AsyncStream from ..lib.polling import PollingConfig from .async_execution import AsyncExecution, _AsyncStreamingGroup @@ -221,15 +222,15 @@ async def close(self) -> None: await self.shutdown() @property - def cmd(self) -> "_AsyncCommandInterface": + def cmd(self) -> AsyncCommandInterface: return _AsyncCommandInterface(self) @property - def file(self) -> "_AsyncFileInterface": + def file(self) -> AsyncFileInterface: return _AsyncFileInterface(self) @property - def net(self) -> "_AsyncNetworkInterface": + def net(self) -> AsyncNetworkInterface: return _AsyncNetworkInterface(self) # ------------------------------------------------------------------ # diff --git a/src/runloop_api_client/sdk/async_snapshot.py b/src/runloop_api_client/sdk/async_snapshot.py index 4fc0e75cf..de625db0f 100644 --- a/src/runloop_api_client/sdk/async_snapshot.py +++ b/src/runloop_api_client/sdk/async_snapshot.py @@ -132,7 +132,7 @@ async def create_devbox( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> "AsyncDevbox": - from ._async import AsyncDevboxClient + from .async_ import AsyncDevboxClient devbox_client = AsyncDevboxClient(self._client) return await devbox_client.create_from_snapshot( diff --git a/src/runloop_api_client/sdk/blueprint.py b/src/runloop_api_client/sdk/blueprint.py index 140c411c5..4884d3aa5 100644 --- a/src/runloop_api_client/sdk/blueprint.py +++ b/src/runloop_api_client/sdk/blueprint.py @@ -104,7 +104,7 @@ def create_devbox( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> "Devbox": - from ._sync import DevboxClient + from .sync import DevboxClient devbox_client = DevboxClient(self._client) return devbox_client.create_from_blueprint_id( diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index e6419ccc3..45dea588e 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -15,6 +15,7 @@ from .._client import Runloop from ._helpers import LogCallback from .execution import Execution, _StreamingGroup +from .protocols import FileInterface, CommandInterface, NetworkInterface from .._streaming import Stream from ..lib.polling import PollingConfig from .execution_result import ExecutionResult @@ -313,15 +314,15 @@ def close(self) -> None: self.shutdown() @property - def cmd(self) -> "_CommandInterface": + def cmd(self) -> CommandInterface: return _CommandInterface(self) @property - def file(self) -> "_FileInterface": + def file(self) -> FileInterface: return _FileInterface(self) @property - def net(self) -> "_NetworkInterface": + def net(self) -> NetworkInterface: return _NetworkInterface(self) # --------------------------------------------------------------------- # diff --git a/src/runloop_api_client/sdk/protocols.py b/src/runloop_api_client/sdk/protocols.py new file mode 100644 index 000000000..dcb7205c5 --- /dev/null +++ b/src/runloop_api_client/sdk/protocols.py @@ -0,0 +1,301 @@ +"""Public protocol interfaces for SDK components. + +This module defines Protocol interfaces that provide clean type hints for SDK +interface classes without exposing private implementation details in documentation. +""" + +from __future__ import annotations + +from typing import Callable, Optional, Protocol +from typing_extensions import runtime_checkable + +from ..types import DevboxTunnelView, DevboxExecutionDetailView, DevboxCreateSSHKeyResponse +from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, FileTypes +from .execution import Execution +from ..lib.polling import PollingConfig +from .async_execution import AsyncExecution +from .execution_result import ExecutionResult +from .async_execution_result import AsyncExecutionResult + + +@runtime_checkable +class CommandInterface(Protocol): + """Interface for executing commands on a devbox. + + Accessed via `devbox.cmd` property. Provides `exec()` for synchronous execution + and `exec_async()` for asynchronous process management. + + Important: All streaming callbacks (stdout, stderr, output) must be synchronous + functions, not async functions. + """ + + def exec( + self, + command: str, + *, + shell_name: Optional[str] | Omit = ..., + stdout: Optional[Callable[[str], None]] = None, + stderr: Optional[Callable[[str], None]] = None, + output: Optional[Callable[[str], None]] = None, + polling_config: PollingConfig | None = None, + attach_stdin: bool | Omit = ..., + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> "ExecutionResult": ... + + def exec_async( + self, + command: str, + *, + shell_name: Optional[str] | Omit = ..., + stdout: Optional[Callable[[str], None]] = None, + stderr: Optional[Callable[[str], None]] = None, + output: Optional[Callable[[str], None]] = None, + attach_stdin: bool | Omit = ..., + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> "Execution": ... + + +@runtime_checkable +class FileInterface(Protocol): + """Interface for file operations on a devbox. + + Accessed via `devbox.file` property. Provides methods for reading, writing, + uploading, and downloading files. + """ + + def read( + self, + path: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> str: ... + + def write( + self, + path: str, + contents: str | bytes, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> DevboxExecutionDetailView: ... + + def download( + self, + path: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> bytes: ... + + def upload( + self, + path: str, + file: FileTypes, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> object: ... + + +@runtime_checkable +class NetworkInterface(Protocol): + """Interface for network operations on a devbox. + + Accessed via `devbox.net` property. Provides methods for managing SSH keys + and network tunnels. + """ + + def create_ssh_key( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> DevboxCreateSSHKeyResponse: ... + + def create_tunnel( + self, + *, + port: int, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> DevboxTunnelView: ... + + def remove_tunnel( + self, + *, + port: int, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> object: ... + + +@runtime_checkable +class AsyncCommandInterface(Protocol): + """Async interface for executing commands on a devbox. + + Accessed via `devbox.cmd` property. Provides `exec()` and `exec_async()` for + command execution with async/await support. + + Important: All streaming callbacks (stdout, stderr, output) must be synchronous + functions, not async functions. The devbox operations are async, but the callbacks + themselves are called synchronously. + """ + + async def exec( + self, + command: str, + *, + shell_name: Optional[str] | Omit = ..., + stdout: Optional[Callable[[str], None]] = None, + stderr: Optional[Callable[[str], None]] = None, + output: Optional[Callable[[str], None]] = None, + polling_config: PollingConfig | None = None, + attach_stdin: bool | Omit = ..., + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> "AsyncExecutionResult": ... + + async def exec_async( + self, + command: str, + *, + shell_name: Optional[str] | Omit = ..., + stdout: Optional[Callable[[str], None]] = None, + stderr: Optional[Callable[[str], None]] = None, + output: Optional[Callable[[str], None]] = None, + attach_stdin: bool | Omit = ..., + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> "AsyncExecution": ... + + +@runtime_checkable +class AsyncFileInterface(Protocol): + """Async interface for file operations on a devbox. + + Accessed via `devbox.file` property. Provides async methods for reading, writing, + uploading, and downloading files. + """ + + async def read( + self, + path: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> str: ... + + async def write( + self, + path: str, + contents: str | bytes, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> DevboxExecutionDetailView: ... + + async def download( + self, + path: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> bytes: ... + + async def upload( + self, + path: str, + file: FileTypes, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> object: ... + + +@runtime_checkable +class AsyncNetworkInterface(Protocol): + """Async interface for network operations on a devbox. + + Accessed via `devbox.net` property. Provides async methods for managing SSH keys + and network tunnels. + """ + + async def create_ssh_key( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> DevboxCreateSSHKeyResponse: ... + + async def create_tunnel( + self, + *, + port: int, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> DevboxTunnelView: ... + + async def remove_tunnel( + self, + *, + port: int, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | Timeout | None | NotGiven = ..., + idempotency_key: str | None = None, + ) -> object: ... diff --git a/src/runloop_api_client/sdk/snapshot.py b/src/runloop_api_client/sdk/snapshot.py index b9434ef52..d2223ba05 100644 --- a/src/runloop_api_client/sdk/snapshot.py +++ b/src/runloop_api_client/sdk/snapshot.py @@ -132,7 +132,7 @@ def create_devbox( timeout: float | Timeout | None | NotGiven = not_given, idempotency_key: str | None = None, ) -> "Devbox": - from ._sync import DevboxClient + from .sync import DevboxClient devbox_client = DevboxClient(self._client) return devbox_client.create_from_snapshot( diff --git a/src/runloop_api_client/sdk/_sync.py b/src/runloop_api_client/sdk/sync.py similarity index 100% rename from src/runloop_api_client/sdk/_sync.py rename to src/runloop_api_client/sdk/sync.py From c3a1de1f6e5e56a2f3229d0d7b071f27386d6f49 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 13 Nov 2025 16:04:53 -0800 Subject: [PATCH 29/56] update docs to correct await async devboxes and not use async callbacks (not supported) --- README-SDK.md | 22 +++++++++++++--------- src/runloop_api_client/sdk/_helpers.py | 1 + 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/README-SDK.md b/README-SDK.md index 4541f806b..5362bddc2 100644 --- a/README-SDK.md +++ b/README-SDK.md @@ -48,11 +48,11 @@ from runloop_api_client import AsyncRunloopSDK async def main(): sdk = AsyncRunloopSDK() - async with sdk.devbox.create(name="async-devbox") as devbox: + async with await sdk.devbox.create(name="async-devbox") as devbox: result = await devbox.cmd.exec("pwd") print(await result.stdout()) - async def capture(line: str) -> None: + def capture(line: str) -> None: print(">>", line) await devbox.cmd.exec("ls", stdout=capture) @@ -237,11 +237,15 @@ result = devbox.cmd.exec( print("exit code:", result.exit_code) ``` -Async example: +**Note on Callbacks:** All callbacks (`stdout`, `stderr`, `output`) must be synchronous functions. Even when using `AsyncDevbox`, callbacks cannot be async functions. If you need to perform async operations with the output, use thread-safe queues and process them separately. + +Async example (note that the callback itself is still synchronous): ```python -async def capture(line: str) -> None: - await log_queue.put(line) +def capture(line: str) -> None: + # Callbacks must be synchronous + # Use thread-safe data structures if needed + log_queue.put_nowait(line) await devbox.cmd.exec( "tail -f /var/log/app.log", @@ -345,7 +349,7 @@ with sdk.devbox.create(name="temp-devbox") as devbox: # devbox is automatically shutdown when exiting the context # Asynchronous -async with sdk.devbox.create(name="temp-devbox") as devbox: +async with await sdk.devbox.create(name="temp-devbox") as devbox: result = await devbox.cmd.exec("echo 'Hello'") print(await result.stdout()) # devbox is automatically shutdown when exiting the context @@ -665,12 +669,12 @@ async def main(): sdk = AsyncRunloopSDK() # All the same operations, but with await - async with sdk.devbox.create(name="async-devbox") as devbox: + async with await sdk.devbox.create(name="async-devbox") as devbox: result = await devbox.cmd.exec("pwd") print(await result.stdout()) - # Async streaming - async def capture(line: str) -> None: + # Streaming (note: callbacks must be synchronous) + def capture(line: str) -> None: print(">>", line) await devbox.cmd.exec("ls", stdout=capture) diff --git a/src/runloop_api_client/sdk/_helpers.py b/src/runloop_api_client/sdk/_helpers.py index 1e459f6e4..cb72c4646 100644 --- a/src/runloop_api_client/sdk/_helpers.py +++ b/src/runloop_api_client/sdk/_helpers.py @@ -5,6 +5,7 @@ from typing import Dict, Union, Literal, Callable from pathlib import Path +# Callback for streaming output. Must be synchronous even in async contexts. LogCallback = Callable[[str], None] ContentType = Literal["unspecified", "text", "binary", "gzip", "tar", "tgz"] From 967898292dc73c1419923a3e15bfbcc98f95cf53 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 13 Nov 2025 16:05:09 -0800 Subject: [PATCH 30/56] added module-level docstrings --- src/runloop_api_client/sdk/__init__.py | 4 ++++ src/runloop_api_client/sdk/_helpers.py | 1 + src/runloop_api_client/sdk/async_.py | 1 + src/runloop_api_client/sdk/async_blueprint.py | 1 + src/runloop_api_client/sdk/async_devbox.py | 1 + src/runloop_api_client/sdk/async_execution.py | 1 + src/runloop_api_client/sdk/async_execution_result.py | 1 + src/runloop_api_client/sdk/async_snapshot.py | 1 + src/runloop_api_client/sdk/async_storage_object.py | 1 + src/runloop_api_client/sdk/blueprint.py | 1 + src/runloop_api_client/sdk/devbox.py | 1 + src/runloop_api_client/sdk/execution.py | 1 + src/runloop_api_client/sdk/execution_result.py | 1 + src/runloop_api_client/sdk/snapshot.py | 1 + src/runloop_api_client/sdk/storage_object.py | 1 + src/runloop_api_client/sdk/sync.py | 1 + 16 files changed, 19 insertions(+) diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py index 8c8ebef3d..b2caf7da7 100644 --- a/src/runloop_api_client/sdk/__init__.py +++ b/src/runloop_api_client/sdk/__init__.py @@ -1,3 +1,7 @@ +"""Runloop SDK - Object-oriented Python interface for Runloop. + +Provides both sync (`RunloopSDK`) and async (`AsyncRunloopSDK`) interfaces. +""" from __future__ import annotations from .sync import RunloopSDK, DevboxClient, SnapshotClient, BlueprintClient, StorageObjectClient diff --git a/src/runloop_api_client/sdk/_helpers.py b/src/runloop_api_client/sdk/_helpers.py index cb72c4646..d7fddd496 100644 --- a/src/runloop_api_client/sdk/_helpers.py +++ b/src/runloop_api_client/sdk/_helpers.py @@ -1,3 +1,4 @@ +"""SDK helper types and utility functions.""" from __future__ import annotations import io diff --git a/src/runloop_api_client/sdk/async_.py b/src/runloop_api_client/sdk/async_.py index 4dcbc087c..f445fee14 100644 --- a/src/runloop_api_client/sdk/async_.py +++ b/src/runloop_api_client/sdk/async_.py @@ -1,3 +1,4 @@ +"""Asynchronous SDK entry points and management interfaces.""" from __future__ import annotations from typing import Dict, Mapping, Iterable, Optional diff --git a/src/runloop_api_client/sdk/async_blueprint.py b/src/runloop_api_client/sdk/async_blueprint.py index ddab866f5..bdc556744 100644 --- a/src/runloop_api_client/sdk/async_blueprint.py +++ b/src/runloop_api_client/sdk/async_blueprint.py @@ -1,3 +1,4 @@ +"""Blueprint resource class for asynchronous operations.""" from __future__ import annotations from typing import TYPE_CHECKING, Dict, Iterable, Optional diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index 96f7096cf..b3f3793d7 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -1,3 +1,4 @@ +"""Asynchronous devbox resource class.""" from __future__ import annotations import asyncio diff --git a/src/runloop_api_client/sdk/async_execution.py b/src/runloop_api_client/sdk/async_execution.py index bf7b0b45e..d074ca42a 100644 --- a/src/runloop_api_client/sdk/async_execution.py +++ b/src/runloop_api_client/sdk/async_execution.py @@ -1,3 +1,4 @@ +"""Async execution management for async commands.""" from __future__ import annotations import asyncio diff --git a/src/runloop_api_client/sdk/async_execution_result.py b/src/runloop_api_client/sdk/async_execution_result.py index f7b2e742f..84dcb726e 100644 --- a/src/runloop_api_client/sdk/async_execution_result.py +++ b/src/runloop_api_client/sdk/async_execution_result.py @@ -1,3 +1,4 @@ +"""Async execution result wrapper for completed commands.""" from __future__ import annotations from .._client import AsyncRunloop diff --git a/src/runloop_api_client/sdk/async_snapshot.py b/src/runloop_api_client/sdk/async_snapshot.py index de625db0f..6d22899ff 100644 --- a/src/runloop_api_client/sdk/async_snapshot.py +++ b/src/runloop_api_client/sdk/async_snapshot.py @@ -1,3 +1,4 @@ +"""Snapshot resource class for asynchronous operations.""" from __future__ import annotations from typing import TYPE_CHECKING, Dict, Iterable, Optional diff --git a/src/runloop_api_client/sdk/async_storage_object.py b/src/runloop_api_client/sdk/async_storage_object.py index 08b3bcfb1..1bff5f7c6 100644 --- a/src/runloop_api_client/sdk/async_storage_object.py +++ b/src/runloop_api_client/sdk/async_storage_object.py @@ -1,3 +1,4 @@ +"""Storage object resource class for asynchronous operations.""" from __future__ import annotations from typing_extensions import override diff --git a/src/runloop_api_client/sdk/blueprint.py b/src/runloop_api_client/sdk/blueprint.py index 4884d3aa5..5af7c22b3 100644 --- a/src/runloop_api_client/sdk/blueprint.py +++ b/src/runloop_api_client/sdk/blueprint.py @@ -1,3 +1,4 @@ +"""Blueprint resource class for synchronous operations.""" from __future__ import annotations from typing import TYPE_CHECKING, Dict, Iterable, Optional diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index 45dea588e..c712f65c9 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -1,3 +1,4 @@ +"""Synchronous devbox resource class.""" from __future__ import annotations import logging diff --git a/src/runloop_api_client/sdk/execution.py b/src/runloop_api_client/sdk/execution.py index 31eba32d3..cb07f201b 100644 --- a/src/runloop_api_client/sdk/execution.py +++ b/src/runloop_api_client/sdk/execution.py @@ -1,3 +1,4 @@ +"""Execution management for async commands.""" from __future__ import annotations import logging diff --git a/src/runloop_api_client/sdk/execution_result.py b/src/runloop_api_client/sdk/execution_result.py index 8b18686a4..e835bc111 100644 --- a/src/runloop_api_client/sdk/execution_result.py +++ b/src/runloop_api_client/sdk/execution_result.py @@ -1,3 +1,4 @@ +"""Execution result wrapper for completed commands.""" from __future__ import annotations from .._client import Runloop diff --git a/src/runloop_api_client/sdk/snapshot.py b/src/runloop_api_client/sdk/snapshot.py index d2223ba05..cc835c683 100644 --- a/src/runloop_api_client/sdk/snapshot.py +++ b/src/runloop_api_client/sdk/snapshot.py @@ -1,3 +1,4 @@ +"""Snapshot resource class for synchronous operations.""" from __future__ import annotations from typing import TYPE_CHECKING, Dict, Iterable, Optional diff --git a/src/runloop_api_client/sdk/storage_object.py b/src/runloop_api_client/sdk/storage_object.py index 95103dec3..55a723af7 100644 --- a/src/runloop_api_client/sdk/storage_object.py +++ b/src/runloop_api_client/sdk/storage_object.py @@ -1,3 +1,4 @@ +"""Storage object resource class for synchronous operations.""" from __future__ import annotations from typing_extensions import override diff --git a/src/runloop_api_client/sdk/sync.py b/src/runloop_api_client/sdk/sync.py index ac36303d6..ce4deb8e0 100644 --- a/src/runloop_api_client/sdk/sync.py +++ b/src/runloop_api_client/sdk/sync.py @@ -1,3 +1,4 @@ +"""Synchronous SDK entry points and management interfaces.""" from __future__ import annotations from typing import Dict, Mapping, Iterable, Optional From d9d8d3c4c91f5af4a503f0d082844b84caacb88a Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 13 Nov 2025 16:05:28 -0800 Subject: [PATCH 31/56] formatting changes --- src/runloop_api_client/sdk/__init__.py | 1 + src/runloop_api_client/sdk/_helpers.py | 1 + src/runloop_api_client/sdk/async_.py | 1 + src/runloop_api_client/sdk/async_blueprint.py | 1 + src/runloop_api_client/sdk/async_devbox.py | 1 + src/runloop_api_client/sdk/async_execution.py | 1 + src/runloop_api_client/sdk/async_execution_result.py | 1 + src/runloop_api_client/sdk/async_snapshot.py | 1 + src/runloop_api_client/sdk/async_storage_object.py | 1 + src/runloop_api_client/sdk/blueprint.py | 1 + src/runloop_api_client/sdk/devbox.py | 1 + src/runloop_api_client/sdk/execution.py | 1 + src/runloop_api_client/sdk/execution_result.py | 1 + src/runloop_api_client/sdk/snapshot.py | 1 + src/runloop_api_client/sdk/storage_object.py | 1 + src/runloop_api_client/sdk/sync.py | 1 + 16 files changed, 16 insertions(+) diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py index b2caf7da7..175fe5aa5 100644 --- a/src/runloop_api_client/sdk/__init__.py +++ b/src/runloop_api_client/sdk/__init__.py @@ -2,6 +2,7 @@ Provides both sync (`RunloopSDK`) and async (`AsyncRunloopSDK`) interfaces. """ + from __future__ import annotations from .sync import RunloopSDK, DevboxClient, SnapshotClient, BlueprintClient, StorageObjectClient diff --git a/src/runloop_api_client/sdk/_helpers.py b/src/runloop_api_client/sdk/_helpers.py index d7fddd496..6f2ec8a69 100644 --- a/src/runloop_api_client/sdk/_helpers.py +++ b/src/runloop_api_client/sdk/_helpers.py @@ -1,4 +1,5 @@ """SDK helper types and utility functions.""" + from __future__ import annotations import io diff --git a/src/runloop_api_client/sdk/async_.py b/src/runloop_api_client/sdk/async_.py index f445fee14..4ada89ecc 100644 --- a/src/runloop_api_client/sdk/async_.py +++ b/src/runloop_api_client/sdk/async_.py @@ -1,4 +1,5 @@ """Asynchronous SDK entry points and management interfaces.""" + from __future__ import annotations from typing import Dict, Mapping, Iterable, Optional diff --git a/src/runloop_api_client/sdk/async_blueprint.py b/src/runloop_api_client/sdk/async_blueprint.py index bdc556744..bd96ae814 100644 --- a/src/runloop_api_client/sdk/async_blueprint.py +++ b/src/runloop_api_client/sdk/async_blueprint.py @@ -1,4 +1,5 @@ """Blueprint resource class for asynchronous operations.""" + from __future__ import annotations from typing import TYPE_CHECKING, Dict, Iterable, Optional diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index b3f3793d7..b08377f91 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -1,4 +1,5 @@ """Asynchronous devbox resource class.""" + from __future__ import annotations import asyncio diff --git a/src/runloop_api_client/sdk/async_execution.py b/src/runloop_api_client/sdk/async_execution.py index d074ca42a..bc2ed01da 100644 --- a/src/runloop_api_client/sdk/async_execution.py +++ b/src/runloop_api_client/sdk/async_execution.py @@ -1,4 +1,5 @@ """Async execution management for async commands.""" + from __future__ import annotations import asyncio diff --git a/src/runloop_api_client/sdk/async_execution_result.py b/src/runloop_api_client/sdk/async_execution_result.py index 84dcb726e..c3a27a1f4 100644 --- a/src/runloop_api_client/sdk/async_execution_result.py +++ b/src/runloop_api_client/sdk/async_execution_result.py @@ -1,4 +1,5 @@ """Async execution result wrapper for completed commands.""" + from __future__ import annotations from .._client import AsyncRunloop diff --git a/src/runloop_api_client/sdk/async_snapshot.py b/src/runloop_api_client/sdk/async_snapshot.py index 6d22899ff..cab8291af 100644 --- a/src/runloop_api_client/sdk/async_snapshot.py +++ b/src/runloop_api_client/sdk/async_snapshot.py @@ -1,4 +1,5 @@ """Snapshot resource class for asynchronous operations.""" + from __future__ import annotations from typing import TYPE_CHECKING, Dict, Iterable, Optional diff --git a/src/runloop_api_client/sdk/async_storage_object.py b/src/runloop_api_client/sdk/async_storage_object.py index 1bff5f7c6..d10b9ab27 100644 --- a/src/runloop_api_client/sdk/async_storage_object.py +++ b/src/runloop_api_client/sdk/async_storage_object.py @@ -1,4 +1,5 @@ """Storage object resource class for asynchronous operations.""" + from __future__ import annotations from typing_extensions import override diff --git a/src/runloop_api_client/sdk/blueprint.py b/src/runloop_api_client/sdk/blueprint.py index 5af7c22b3..8d9fdc29b 100644 --- a/src/runloop_api_client/sdk/blueprint.py +++ b/src/runloop_api_client/sdk/blueprint.py @@ -1,4 +1,5 @@ """Blueprint resource class for synchronous operations.""" + from __future__ import annotations from typing import TYPE_CHECKING, Dict, Iterable, Optional diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index c712f65c9..07240713d 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -1,4 +1,5 @@ """Synchronous devbox resource class.""" + from __future__ import annotations import logging diff --git a/src/runloop_api_client/sdk/execution.py b/src/runloop_api_client/sdk/execution.py index cb07f201b..c5b897355 100644 --- a/src/runloop_api_client/sdk/execution.py +++ b/src/runloop_api_client/sdk/execution.py @@ -1,4 +1,5 @@ """Execution management for async commands.""" + from __future__ import annotations import logging diff --git a/src/runloop_api_client/sdk/execution_result.py b/src/runloop_api_client/sdk/execution_result.py index e835bc111..f0340826a 100644 --- a/src/runloop_api_client/sdk/execution_result.py +++ b/src/runloop_api_client/sdk/execution_result.py @@ -1,4 +1,5 @@ """Execution result wrapper for completed commands.""" + from __future__ import annotations from .._client import Runloop diff --git a/src/runloop_api_client/sdk/snapshot.py b/src/runloop_api_client/sdk/snapshot.py index cc835c683..705191eb1 100644 --- a/src/runloop_api_client/sdk/snapshot.py +++ b/src/runloop_api_client/sdk/snapshot.py @@ -1,4 +1,5 @@ """Snapshot resource class for synchronous operations.""" + from __future__ import annotations from typing import TYPE_CHECKING, Dict, Iterable, Optional diff --git a/src/runloop_api_client/sdk/storage_object.py b/src/runloop_api_client/sdk/storage_object.py index 55a723af7..0941d0893 100644 --- a/src/runloop_api_client/sdk/storage_object.py +++ b/src/runloop_api_client/sdk/storage_object.py @@ -1,4 +1,5 @@ """Storage object resource class for synchronous operations.""" + from __future__ import annotations from typing_extensions import override diff --git a/src/runloop_api_client/sdk/sync.py b/src/runloop_api_client/sdk/sync.py index ce4deb8e0..ea47dfcc7 100644 --- a/src/runloop_api_client/sdk/sync.py +++ b/src/runloop_api_client/sdk/sync.py @@ -1,4 +1,5 @@ """Synchronous SDK entry points and management interfaces.""" + from __future__ import annotations from typing import Dict, Mapping, Iterable, Optional From 11965bae2f40a9e763bcf3d1314e072323423216 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 13 Nov 2025 16:48:14 -0800 Subject: [PATCH 32/56] fixed unit test imports --- tests/sdk/test_async_clients.py | 2 +- tests/sdk/test_clients.py | 2 +- tests/sdk/test_storage_object.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/sdk/test_async_clients.py b/tests/sdk/test_async_clients.py index 6924f974f..f4185ab3a 100644 --- a/tests/sdk/test_async_clients.py +++ b/tests/sdk/test_async_clients.py @@ -17,7 +17,7 @@ create_mock_httpx_response, ) from runloop_api_client.sdk import AsyncDevbox, AsyncSnapshot, AsyncBlueprint, AsyncStorageObject -from runloop_api_client.sdk._async import ( +from runloop_api_client.sdk.async_ import ( AsyncRunloopSDK, AsyncDevboxClient, AsyncSnapshotClient, diff --git a/tests/sdk/test_clients.py b/tests/sdk/test_clients.py index 4453bb5c4..2c5450375 100644 --- a/tests/sdk/test_clients.py +++ b/tests/sdk/test_clients.py @@ -14,7 +14,7 @@ create_mock_httpx_response, ) from runloop_api_client.sdk import Devbox, Snapshot, Blueprint, StorageObject -from runloop_api_client.sdk._sync import ( +from runloop_api_client.sdk.sync import ( RunloopSDK, DevboxClient, SnapshotClient, diff --git a/tests/sdk/test_storage_object.py b/tests/sdk/test_storage_object.py index 6720256d5..ff0f8ce9d 100644 --- a/tests/sdk/test_storage_object.py +++ b/tests/sdk/test_storage_object.py @@ -10,7 +10,7 @@ from tests.sdk.conftest import MockObjectView, create_mock_httpx_response from runloop_api_client.sdk import StorageObject -from runloop_api_client.sdk._sync import StorageObjectClient +from runloop_api_client.sdk.sync import StorageObjectClient class TestStorageObject: From 8932d2353c8033754d4e421e6be49e788e31a0dd Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 13 Nov 2025 16:48:33 -0800 Subject: [PATCH 33/56] fixed expected status for snapshot delete smoketests --- tests/smoketests/sdk/test_async_snapshot.py | 2 +- tests/smoketests/sdk/test_snapshot.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/smoketests/sdk/test_async_snapshot.py b/tests/smoketests/sdk/test_async_snapshot.py index 869f9ffc9..66fcb7ee3 100644 --- a/tests/smoketests/sdk/test_async_snapshot.py +++ b/tests/smoketests/sdk/test_async_snapshot.py @@ -128,7 +128,7 @@ async def test_snapshot_delete(self, async_sdk_client: AsyncRunloopSDK) -> None: # Verify it's deleted by checking the status info = await snapshot.get_info() # After deletion, the snapshot should have a status indicating it's deleted - assert info.status == "error" + assert info.status == "deleted" finally: await devbox.shutdown() diff --git a/tests/smoketests/sdk/test_snapshot.py b/tests/smoketests/sdk/test_snapshot.py index d619e0bf8..9df1dd215 100644 --- a/tests/smoketests/sdk/test_snapshot.py +++ b/tests/smoketests/sdk/test_snapshot.py @@ -126,7 +126,7 @@ def test_snapshot_delete(self, sdk_client: RunloopSDK) -> None: # Verify it's deleted by checking the status info = snapshot.get_info() # After deletion, the snapshot should have a status indicating it's deleted - assert info.status == "error" + assert info.status == "deleted" print(info.status) print(info.error_message) print(info.snapshot) From 1ea0b2d332e2ab9e5347c1179f36c9d2d663a4ef Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 13 Nov 2025 18:10:01 -0800 Subject: [PATCH 34/56] Add coverage files to .gitignore and remove .coverage from tracking --- .coverage | Bin 53248 -> 0 bytes .gitignore | 11 ++++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) delete mode 100644 .coverage diff --git a/.coverage b/.coverage deleted file mode 100644 index f7c8e08d10b5f418a23f7e9f5016df5d7f765b04..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI4eQXow8Nlys$BsYl-n1dJLYn5*7D7fo>aoH`N<-MFT8%C+zA6e_&-NubV*8Bm z3<&}}msY5qrb(?-RQAE7{V{3U22uq~g(ZWDO#*GSe+|S(_R+4@x-Ki~_-KM}&wJ;y z9l%vpTSCpAlYQ^q`|;fK{GR81-+OJJSFc*3=(5nSrs9$=EMV#wmSvU-0>d!%@aEuc zZyq>s*(Z>)CCB9s>zU4NFLGoZ6L5czA)C2A(h>L}H`o7GAntw1|6`BjjX(izNB{{S z0VMGMO(44};0ZQ1vd`_(rEpBvQ&L1uS$pp5-(KFkX1TDYciD>Nf|V!C^9itZbO^md zO5H9b<&@B`#AHE9M3so7D~SO?AC&FdX-$sOi4O8$s-qP<&Gz(2RHD#Imj^&ZGNr_& z)QB)7kF=W=@>kR4Vcp6AA+j=%pf!Yf`Q~jxO753aav~yYmKO7rXq&HXPsZm7*4MLp zWz!}p6@iz3nl+G7H08;D5EM~UQL247B_$$*veqt01q&l8=sB!g)~3{Wm#zviC1IMG z&=g%!6M{S}N7A|+y`zEc6q>yOyi#jG*0a>0XRVg`Yn9L`Hoim4eD!?eeo!u<+E|MX z>7}8usdFM74+HWo>3EVl2U=Jc*41(?%eB?jys@9!H+S%5(cWq9w2@PfTCLIvWoue4 zIKN#ewrn@eNT}@sH3A}Fj^7>pT0P4^RLaBJ))?GxiBeiu&2tf)FNRbPRPq3wh z-MiHc6pBX-E4o%V@J6N2*+#b~*wn;6=QYD9@2z~|nHEB}C+{$f+C_0$($Xn= zAo-+0(r!^4CG9?vF9CBpJnmp`Q#o_;^~6yApf>Aqd4dfMY}V=#bX=nEnrTMRoMIQa zWg__W>L(?AP`F)pnhlD#*0_V+4doQ4Du^Mvckg+|VXC~h(WD=TL7%>z(^RH8MU}rP zk92I4VlbK{B_#!27RF#}1`ue)s05Xzu$tD*_@$?2c+$D2sIMiUcy6M6?5MyQN|ZLOwG z_T)l``8Ck#?EVD@@wk+T=I1Lq4O>!S?5CZRUdI|lprOzTol=7?dW%e3Xugz=ShoDm zBru^;<5JY>?$jwI61NY^w~4c6qi;yMtSfO@pjAv|%)r_%X+k6=Lm|wiZLQ8v3Ipmb zF+}%tm0U}2<+3fTCpdpTn@#6CqczTm8cgbO$yPgm>X;TJw-grRr4I&NxZ5d>6Uwdx z2R*f}KnFL=gJFw;T`PaD<7=Y}e3ncy@WKrVAOR$R1dsp{Kmter2_OL^fCP{L5}0`e zoUDWO(DlEA{DmR^h8=E500|%gB!C2v01`j~NB{{S0VIF~kih4XK)~T_CiX8p90p-2Ua=AdklGx{P%NdAS#UnkN^@u0!RP}AOR$R1dsp{Kmter2^0tf zoXxELF@VG8Z1Uzm1)$&m*ZB@JUsZqNc0#(SK15Z;>N9JVCsUfbdXtgooj=ZumQq1eLp% z2f1Ce!U5u%D@7^Nv5H#DR>fLTNhrD~ zfC`o*yBJRHs@Pr-41rWr728YJ|E{V8Q6VOsPr0(q6{ED*|BgQJw7232 zFRuUb|Nn44MFL0w2_OL^fCP{L5OIWNb(%hYjC)R3#>b?!~>Ft?xkh+oY;!7t(O=fA_Z zlO{gLzstWm1IIn}s!Uh`ia`&(x6Kwsx*=H$^_{rK3) zYdxLg2d?K{oM>r$#Kn5Q;@OaS{M@F?eYu-`qdhlrZyN^BIp1k<}`%SYJ(cGFh z297ToaE%^2_sWKiAAZalg#SL%;Jweizc(5AMQBZc|0$f#gv+tP z7&v1XIpg4UW3qI2=6X&(lQD*lTsI~RV?!<{fA`XrF=PI}f;*(Mzuh;M$&FlqSXxz8 zLkPq6K0x(y_%;}?zhh(!WBrtIvt;+@FOO!jvg~{J!U4p2VC$OfR{f1&(xJs1+$R=5k k0{H!ZSu|LH1dsp{Kmter2_OL^fCP{L5 Date: Thu, 13 Nov 2025 18:10:34 -0800 Subject: [PATCH 35/56] increased timeout for snapshot tests --- tests/smoketests/sdk/test_async_devbox.py | 3 ++- tests/smoketests/sdk/test_devbox.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/smoketests/sdk/test_async_devbox.py b/tests/smoketests/sdk/test_async_devbox.py index 1a001fbe8..12144963f 100644 --- a/tests/smoketests/sdk/test_async_devbox.py +++ b/tests/smoketests/sdk/test_async_devbox.py @@ -16,6 +16,7 @@ THIRTY_SECOND_TIMEOUT = 30 TWO_MINUTE_TIMEOUT = 120 +FOUR_MINUTE_TIMEOUT = 240 @pytest.fixture(scope="module") @@ -521,7 +522,7 @@ async def test_get_devbox_by_id(self, async_sdk_client: AsyncRunloopSDK) -> None class TestAsyncDevboxSnapshots: """Test snapshot operations on async devboxes.""" - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) async def test_snapshot_disk(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test creating a snapshot from devbox (synchronous wait).""" devbox = await async_sdk_client.devbox.create( diff --git a/tests/smoketests/sdk/test_devbox.py b/tests/smoketests/sdk/test_devbox.py index 1ae2f0e05..87ab8de49 100644 --- a/tests/smoketests/sdk/test_devbox.py +++ b/tests/smoketests/sdk/test_devbox.py @@ -16,6 +16,7 @@ THIRTY_SECOND_TIMEOUT = 30 TWO_MINUTE_TIMEOUT = 120 +FOUR_MINUTE_TIMEOUT = 240 @pytest.fixture(scope="module") @@ -522,7 +523,7 @@ def test_get_devbox_by_id(self, sdk_client: RunloopSDK) -> None: class TestDevboxSnapshots: """Test snapshot operations on devboxes.""" - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) def test_snapshot_disk(self, sdk_client: RunloopSDK) -> None: """Test creating a snapshot from devbox (synchronous).""" devbox = sdk_client.devbox.create( From 8013876088e01f4f1c4374669c265afdfd47b859 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Thu, 13 Nov 2025 18:25:37 -0800 Subject: [PATCH 36/56] clean up default value for max_retries --- src/runloop_api_client/sdk/sync.py | 32 ++++++++++-------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/src/runloop_api_client/sdk/sync.py b/src/runloop_api_client/sdk/sync.py index ea47dfcc7..80a12f9a8 100644 --- a/src/runloop_api_client/sdk/sync.py +++ b/src/runloop_api_client/sdk/sync.py @@ -10,7 +10,7 @@ from .devbox import Devbox from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given -from .._client import Runloop +from .._client import Runloop, DEFAULT_MAX_RETRIES from ._helpers import ContentType, detect_content_type from .snapshot import Snapshot from .blueprint import Blueprint @@ -431,30 +431,20 @@ def __init__( bearer_token: str | None = None, base_url: str | httpx.URL | None = None, timeout: float | Timeout | None | NotGiven = not_given, - max_retries: int | None = None, + max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, http_client: httpx.Client | None = None, ) -> None: - if max_retries is None: - self.api = Runloop( - bearer_token=bearer_token, - base_url=base_url, - timeout=timeout, - default_headers=default_headers, - default_query=default_query, - http_client=http_client, - ) - else: - self.api = Runloop( - bearer_token=bearer_token, - base_url=base_url, - timeout=timeout, - max_retries=max_retries, - default_headers=default_headers, - default_query=default_query, - http_client=http_client, - ) + self.api = Runloop( + bearer_token=bearer_token, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + default_headers=default_headers, + default_query=default_query, + http_client=http_client, + ) self.devbox = DevboxClient(self.api) self.blueprint = BlueprintClient(self.api) From cd323ad3e7b8d62fedd4fedb72d0512a31d21871 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Fri, 14 Nov 2025 16:34:15 -0800 Subject: [PATCH 37/56] remove examples (for now). will upload verified examples later --- examples/async_devbox.py | 187 -------------------- examples/basic_devbox.py | 127 -------------- examples/blueprint_example.py | 221 ------------------------ examples/storage_example.py | 312 ---------------------------------- examples/streaming_output.py | 268 ----------------------------- 5 files changed, 1115 deletions(-) delete mode 100644 examples/async_devbox.py delete mode 100644 examples/basic_devbox.py delete mode 100644 examples/blueprint_example.py delete mode 100644 examples/storage_example.py delete mode 100644 examples/streaming_output.py diff --git a/examples/async_devbox.py b/examples/async_devbox.py deleted file mode 100644 index dab9312f0..000000000 --- a/examples/async_devbox.py +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env -S uv run python -""" -Async Runloop SDK Example - Concurrent Devbox Operations - -This example demonstrates the asynchronous capabilities of the Runloop SDK: -- Creating and managing devboxes asynchronously -- Concurrent command execution across multiple devboxes -- Async file operations -- Async command streaming -""" - -import os -import asyncio - -from runloop_api_client import AsyncRunloopSDK - - -async def demonstrate_basic_async(): - """Demonstrate basic async devbox operations.""" - print("=== Basic Async Operations ===") - - sdk = AsyncRunloopSDK() - - # Create a devbox with async context manager - async with await sdk.devbox.create(name="async-example-devbox") as devbox: - print(f"Created devbox: {devbox.id}") - - # Execute command asynchronously - result = await devbox.cmd.exec("echo 'Hello from async devbox!'") - output = await result.stdout() - print(f"Command output: {output.strip()}") - - # File operations - await devbox.file.write( - path="/home/user/async_test.txt", - contents="Hello from async operations!\n", - ) - content = await devbox.file.read(path="/home/user/async_test.txt") - print(f"File content: {content.strip()}") - - print("Devbox automatically shutdown\n") - - -async def demonstrate_concurrent_commands(): - """Execute multiple commands concurrently on the same devbox.""" - print("=== Concurrent Command Execution ===") - - sdk = AsyncRunloopSDK() - - async with await sdk.devbox.create(name="concurrent-commands-devbox") as devbox: - print(f"Created devbox: {devbox.id}") - - # Execute multiple commands concurrently - async def run_command(cmd: str, label: str): - print(f"Starting: {label}") - result = await devbox.cmd.exec(cmd) - output = await result.stdout() - print(f"{label} completed: {output.strip()}") - return output - - # Run multiple commands in parallel - results = await asyncio.gather( - run_command("echo 'Task 1' && sleep 1", "Task 1"), - run_command("echo 'Task 2' && sleep 1", "Task 2"), - run_command("echo 'Task 3' && sleep 1", "Task 3"), - ) - - print(f"All {len(results)} tasks completed\n") - - -async def demonstrate_multiple_devboxes(): - """Create and manage multiple devboxes concurrently.""" - print("=== Managing Multiple Devboxes ===") - - sdk = AsyncRunloopSDK() - - async def create_and_use_devbox(name: str, number: int): - """Create a devbox, run a command, and return the result.""" - async with await sdk.devbox.create(name=name) as devbox: - print(f"Devbox {number} ({devbox.id}): Created") - - # Run a command - result = await devbox.cmd.exec(f"echo 'Hello from devbox {number}'") - output = await result.stdout() - print(f"Devbox {number}: {output.strip()}") - - return output - - # Create and use multiple devboxes concurrently - results = await asyncio.gather( - create_and_use_devbox("multi-devbox-1", 1), - create_and_use_devbox("multi-devbox-2", 2), - create_and_use_devbox("multi-devbox-3", 3), - ) - - print(f"All {len(results)} devboxes completed and shutdown\n") - - -async def demonstrate_async_streaming(): - """Demonstrate real-time command output streaming with synchronous callbacks.""" - print("=== Async Command Streaming ===") - - sdk = AsyncRunloopSDK() - - async with await sdk.devbox.create(name="streaming-devbox") as devbox: - print(f"Created devbox: {devbox.id}") - - # Synchronous callback to capture output (callbacks must be sync, not async) - output_lines: list[str] = [] - - def capture_output(line: str): - print(f"[STREAM] {line.strip()}") - output_lines.append(line) - - # Execute command with streaming output - print("\nStreaming command output:") - await devbox.cmd.exec( - 'for i in 1 2 3 4 5; do echo "Line $i"; sleep 0.2; done', - stdout=capture_output, - ) - - print(f"\nCaptured {len(output_lines)} lines of output\n") - - -async def demonstrate_async_execution(): - """Demonstrate async execution management.""" - print("=== Async Execution Management ===") - - sdk = AsyncRunloopSDK() - - async with await sdk.devbox.create(name="async-exec-devbox") as devbox: - print(f"Created devbox: {devbox.id}") - - # Start an async execution - execution = await devbox.cmd.exec_async("echo 'Starting...'; sleep 2; echo 'Finished!'") - print(f"Started execution: {execution.execution_id}") - - # Poll execution state - state = await execution.get_state() - print(f"Initial status: {state.status}") - - # Wait for completion - print("Waiting for completion...") - result = await execution.result() - print(f"Exit code: {result.exit_code}") - output = await result.stdout() - print(f"Output:\n{output}") - - # Start another execution and kill it - print("\nStarting long-running process...") - long_execution = await devbox.cmd.exec_async("sleep 30") - print(f"Execution ID: {long_execution.execution_id}") - - # Wait a bit then kill it - await asyncio.sleep(1) - print("Killing execution...") - await long_execution.kill() - print("Execution killed\n") - - -async def main(): - """Run all async demonstrations.""" - print("Initialized Async Runloop SDK\n") - - # Run demonstrations - await demonstrate_basic_async() - await demonstrate_concurrent_commands() - await demonstrate_multiple_devboxes() - await demonstrate_async_streaming() - await demonstrate_async_execution() - - print("All async demonstrations completed!") - - -if __name__ == "__main__": - # Ensure API key is set - if not os.getenv("RUNLOOP_API_KEY"): - print("Error: RUNLOOP_API_KEY environment variable is not set") - print("Please set it to your Runloop API key:") - print(" export RUNLOOP_API_KEY=your-api-key") - exit(1) - - try: - asyncio.run(main()) - except Exception as e: - print(f"\nError: {e}") - raise diff --git a/examples/basic_devbox.py b/examples/basic_devbox.py deleted file mode 100644 index d035f36c6..000000000 --- a/examples/basic_devbox.py +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env -S uv run python -""" -Basic Runloop SDK Example - Devbox Operations - -This example demonstrates the core functionality of the Runloop SDK: -- Creating and managing devboxes -- Executing commands synchronously and asynchronously -- File operations (read, write, upload, download) -- Devbox lifecycle management -""" - -import os -from pathlib import Path - -from runloop_api_client import RunloopSDK - - -def main(): - # Initialize the SDK (uses RUNLOOP_API_KEY environment variable by default) - sdk = RunloopSDK() - print("Initialized Runloop SDK") - - # Create a devbox with automatic cleanup using context manager - print("\n=== Creating Devbox ===") - with sdk.devbox.create(name="basic-example-devbox") as devbox: - print(f"Created devbox: {devbox.id}") - - # Get devbox information - info = devbox.get_info() - print(f"Devbox status: {info.status}") - print(f"Devbox name: {info.name}") - - # Execute a simple command - print("\n=== Executing Commands ===") - result = devbox.cmd.exec("echo 'Hello from Runloop!'") - print(f"Command output: {result.stdout().strip()}") - print(f"Exit code: {result.exit_code}") - print(f"Success: {result.success}") - - # Execute a command that generates output - result = devbox.cmd.exec("ls -la /home/user") - print(f"\nDirectory listing:\n{result.stdout()}") - - # Execute a command with error - result = devbox.cmd.exec("ls /nonexistent") - if result.failed: - print(f"\nCommand failed with exit code {result.exit_code}") - print(f"Error output: {result.stderr()}") - - # File operations - print("\n=== File Operations ===") - - # Write a file - file_path = "/home/user/test.txt" - content = "Hello, Runloop!\nThis is a test file.\n" - devbox.file.write(path=file_path, contents=content) - print(f"Wrote file: {file_path}") - - # Read the file back - read_content = devbox.file.read(path=file_path) - print(f"Read file content:\n{read_content}") - - # Create a local file to upload - local_file = Path("temp_upload.txt") - local_file.write_text("This file will be uploaded to the devbox.\n") - - try: - # Upload a file - upload_path = "/home/user/uploaded.txt" - devbox.file.upload(path=upload_path, file=local_file) - print(f"\nUploaded file to: {upload_path}") - - # Verify the upload by reading the file - uploaded_content = devbox.file.read(path=upload_path) - print(f"Uploaded file content: {uploaded_content.strip()}") - - # Download a file - download_data = devbox.file.download(path=upload_path) - local_download = Path("temp_download.txt") - local_download.write_bytes(download_data) - print(f"Downloaded file to: {local_download}") - print(f"Downloaded content: {local_download.read_text().strip()}") - finally: - # Cleanup local files - local_file.unlink(missing_ok=True) - if Path("temp_download.txt").exists(): - Path("temp_download.txt").unlink() - - # Asynchronous command execution - print("\n=== Asynchronous Command Execution ===") - - # Start a long-running command asynchronously - execution = devbox.cmd.exec_async("sleep 3 && echo 'Done sleeping!'") - print(f"Started async execution: {execution.execution_id}") - - # Check the execution state - state = execution.get_state() - print(f"Execution status: {state.status}") - - # Wait for completion and get the result - print("Waiting for execution to complete...") - result = execution.result() - print(f"Execution completed with exit code: {result.exit_code}") - print(f"Output: {result.stdout().strip()}") - - # Keep devbox alive (extends timeout) - print("\n=== Devbox Lifecycle ===") - devbox.keep_alive() - print("Extended devbox timeout") - - print("\n=== Devbox Cleanup ===") - print("Devbox automatically shutdown when exiting context manager") - - -if __name__ == "__main__": - # Ensure API key is set - if not os.getenv("RUNLOOP_API_KEY"): - print("Error: RUNLOOP_API_KEY environment variable is not set") - print("Please set it to your Runloop API key:") - print(" export RUNLOOP_API_KEY=your-api-key") - exit(1) - - try: - main() - except Exception as e: - print(f"\nError: {e}") - raise diff --git a/examples/blueprint_example.py b/examples/blueprint_example.py deleted file mode 100644 index d76a822ae..000000000 --- a/examples/blueprint_example.py +++ /dev/null @@ -1,221 +0,0 @@ -#!/usr/bin/env -S uv run python -""" -Runloop SDK Example - Blueprint Workflows - -This example demonstrates blueprint creation and management: -- Creating blueprints with Dockerfiles -- Creating blueprints with system setup commands -- Using blueprints to create devboxes -- Viewing blueprint build logs -- Blueprint lifecycle management -""" - -import os - -from runloop_api_client import RunloopSDK -from runloop_api_client.sdk import Blueprint - - -def create_simple_blueprint(sdk: RunloopSDK): - """Create a simple blueprint with a Dockerfile.""" - print("=== Creating Simple Blueprint ===") - - dockerfile = """FROM ubuntu:22.04 - -RUN apt-get update && apt-get install -y \\ - python3 \\ - python3-pip \\ - curl \\ - git - -WORKDIR /home/user -""" - - blueprint = sdk.blueprint.create( - name="simple-python-blueprint", - dockerfile=dockerfile, - ) - - print(f"Created blueprint: {blueprint.id}") - - # Get blueprint info - info = blueprint.get_info() - print(f"Blueprint name: {info.name}") - print(f"Blueprint status: {info.status}") - - return blueprint - - -def create_blueprint_with_setup(sdk: RunloopSDK): - """Create a blueprint with system setup commands.""" - print("\n=== Creating Blueprint with System Setup ===") - - dockerfile = """FROM ubuntu:22.04 - -RUN apt-get update && apt-get install -y \\ - python3 \\ - python3-pip - -WORKDIR /home/user -""" - - blueprint = sdk.blueprint.create( - name="ml-environment-blueprint", - dockerfile=dockerfile, - system_setup_commands=[ - "pip3 install numpy pandas scikit-learn", - "pip3 install matplotlib seaborn", - "echo 'ML environment ready!'", - ], - ) - - print(f"Created blueprint: {blueprint.id}") - - # View build logs - print("\nRetrieving build logs...") - logs = blueprint.logs() - if logs.logs: - print("Build log entries:") - for i, log_entry in enumerate(logs.logs[:5], 1): - print(f" {i}. {log_entry.message[:80]}...") - if len(logs.logs) > 5: - print(f" ... and {len(logs.logs) - 5} more log entries") - - return blueprint - - -def create_blueprint_from_base(sdk: RunloopSDK): - """Create a blueprint based on an existing blueprint.""" - print("\n=== Creating Blueprint from Base ===") - - # First create a base blueprint - base_blueprint = sdk.blueprint.create( - name="base-nodejs-blueprint", - dockerfile="""FROM ubuntu:22.04 - -RUN apt-get update && apt-get install -y \\ - curl \\ - && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \\ - && apt-get install -y nodejs - -WORKDIR /home/user -""", - ) - - print(f"Created base blueprint: {base_blueprint.id}") - - # Create a derived blueprint - derived_blueprint = sdk.blueprint.create( - name="nodejs-with-tools-blueprint", - base_blueprint_id=base_blueprint.id, - system_setup_commands=[ - "npm install -g typescript ts-node", - "npm install -g eslint prettier", - "echo 'Node.js with tools ready!'", - ], - ) - - print(f"Created derived blueprint: {derived_blueprint.id}") - - return base_blueprint, derived_blueprint - - -def use_blueprint_to_create_devbox(blueprint: Blueprint): - """Create and use a devbox from a blueprint.""" - print("\n=== Creating Devbox from Blueprint ===") - - # Create devbox from blueprint - devbox = blueprint.create_devbox(name="devbox-from-blueprint") - - print(f"Created devbox: {devbox.id}") - - try: - # Verify the devbox has the expected environment - result = devbox.cmd.exec("python3 --version") - print(f"Python version: {result.stdout().strip()}") - - result = devbox.cmd.exec("which pip3") - print(f"pip3 location: {result.stdout().strip()}") - - # Run a simple Python command - result = devbox.cmd.exec("python3 -c 'import sys; print(sys.version)'") - print(f"Python sys.version: {result.stdout().strip()}") - finally: - # Cleanup - devbox.shutdown() - print("Devbox shutdown") - - -def list_blueprints(sdk: RunloopSDK): - """List all available blueprints.""" - print("\n=== Listing Blueprints ===") - - blueprints = sdk.blueprint.list(limit=5) - - print(f"Found {len(blueprints)} blueprints:") - for bp in blueprints: - info = bp.get_info() - print(f" - {info.name} ({bp.id}): {info.status}") - - -def cleanup_blueprints(blueprints: list[Blueprint]): - """Delete blueprints to clean up.""" - print("\n=== Cleaning Up Blueprints ===") - - for blueprint in blueprints: - try: - info = blueprint.get_info() - print(f"Deleting blueprint: {info.name} ({blueprint.id})") - blueprint.delete() - print(f" Deleted: {blueprint.id}") - except Exception as e: - print(f" Error deleting {blueprint.id}: {e}") - - -def main(): - # Initialize the SDK - sdk = RunloopSDK() - print("Initialized Runloop SDK\n") - - created_blueprints: list[Blueprint] = [] - - try: - # Create simple blueprint - simple_bp = create_simple_blueprint(sdk) - created_blueprints.append(simple_bp) - - # Create blueprint with setup commands - ml_bp = create_blueprint_with_setup(sdk) - created_blueprints.append(ml_bp) - - # Create blueprint from base - base_bp, derived_bp = create_blueprint_from_base(sdk) - created_blueprints.extend([base_bp, derived_bp]) - - # Use a blueprint to create a devbox - use_blueprint_to_create_devbox(simple_bp) - - # List all blueprints - list_blueprints(sdk) - - finally: - # Cleanup all created blueprints - if created_blueprints: - cleanup_blueprints(created_blueprints) - - print("\nBlueprint example completed!") - - -if __name__ == "__main__": - # Ensure API key is set - if not os.getenv("RUNLOOP_API_KEY"): - print("Error: RUNLOOP_API_KEY environment variable is not set") - print("Please set it to your Runloop API key:") - print(" export RUNLOOP_API_KEY=your-api-key") - exit(1) - - try: - main() - except Exception as e: - print(f"\nError: {e}") - raise diff --git a/examples/storage_example.py b/examples/storage_example.py deleted file mode 100644 index 1cc8abc6a..000000000 --- a/examples/storage_example.py +++ /dev/null @@ -1,312 +0,0 @@ -#!/usr/bin/env -S uv run python -""" -Runloop SDK Example - Storage Object Operations - -This example demonstrates storage object management: -- Creating storage objects -- Uploading content (text, bytes, files) -- Downloading content -- Mounting storage objects to devboxes -- Storage object lifecycle management -""" - -import os -from pathlib import Path - -from runloop_api_client import RunloopSDK -from runloop_api_client.sdk import StorageObject - - -def demonstrate_text_upload(sdk: RunloopSDK): - """Upload text content directly.""" - print("=== Text Content Upload ===") - - content = """Hello from Runloop! -This is a test file created by the SDK. -It contains multiple lines of text. -""" - - obj = sdk.storage_object.upload_from_text( - content, - name="test-text-file.txt", - metadata={"source": "example", "type": "text"}, - ) - - print(f"Uploaded text object: {obj.id}") - - # Verify by downloading - downloaded_text = obj.download_as_text() - print(f"Downloaded content:\n{downloaded_text}") - - return obj - - -def demonstrate_bytes_upload(sdk: RunloopSDK): - """Upload binary content.""" - print("\n=== Binary Content Upload ===") - - # Create some binary data - binary_data = b"\x89PNG\r\n\x1a\n" + b"Fake PNG header" + b"\x00" * 100 - - obj = sdk.storage_object.upload_from_bytes( - binary_data, - name="test-binary.bin", - content_type="binary", - metadata={"source": "example", "type": "binary"}, - ) - - print(f"Uploaded binary object: {obj.id}") - print(f"Content length: {len(binary_data)} bytes") - - # Verify by downloading - downloaded_bytes = obj.download_as_bytes() - print(f"Downloaded {len(downloaded_bytes)} bytes") - print(f"Content matches: {binary_data == downloaded_bytes}") - - return obj - - -def demonstrate_file_upload(sdk: RunloopSDK): - """Upload a file from the filesystem.""" - print("\n=== File Upload ===") - - # Create a temporary file - temp_file = Path("temp_example_file.txt") - temp_file.write_text("""This is a file from the filesystem. -It will be uploaded to Runloop storage. -Line 3 -Line 4 -""") - - try: - obj = sdk.storage_object.upload_from_file( - temp_file, - name="uploaded-file.txt", - metadata={"source": "filesystem", "original": str(temp_file)}, - ) - - print(f"Uploaded file object: {obj.id}") - - # Get object info - info = obj.refresh() - print(f"Object name: {info.name}") - print(f"Content type: {info.content_type}") - # TODO: Add metadata to the object - # print(f"Metadata: {info.metadata}") - - return obj - finally: - # Cleanup temp file - temp_file.unlink(missing_ok=True) - - -def demonstrate_manual_upload(sdk: RunloopSDK): - """Demonstrate manual upload flow with create, upload, complete.""" - print("\n=== Manual Upload Flow ===") - - # Step 1: Create the storage object - obj = sdk.storage_object.create( - name="manual-upload.txt", - content_type="text", - metadata={"method": "manual"}, - ) - - print(f"Created storage object: {obj.id}") - print(f"Upload URL: {obj.upload_url[:50] if obj.upload_url is not None else 'None'}...") - - # Step 2: Upload content to the presigned URL - content = b"This content was uploaded manually using the upload flow." - obj.upload_content(content) - print("Content uploaded to presigned URL") - - # Step 3: Mark the upload as complete - obj.complete() - print("Upload marked as complete") - - # Verify - downloaded = obj.download_as_text() - print(f"Verified content: {downloaded[:50]}...") - - return obj - - -def demonstrate_storage_mounting(sdk: RunloopSDK): - """Mount a storage object to a devbox.""" - print("\n=== Mounting Storage Objects to Devbox ===") - - # Create a storage object with some data - obj = sdk.storage_object.upload_from_text( - "This file is mounted in the devbox!\n", - name="mounted-file.txt", - ) - print(f"Created storage object: {obj.id}") - - # Create a devbox with the storage object mounted - devbox = sdk.devbox.create( - name="storage-mount-devbox", - mounts=[ - { - "type": "object_mount", - "object_id": obj.id, - "object_path": "/home/user/mounted-data.txt", - } - ], - ) - - print(f"Created devbox: {devbox.id}") - - try: - # Verify the file is accessible in the devbox - result = devbox.cmd.exec("cat /home/user/mounted-data.txt") - print(f"Mounted file content: {result.stdout().strip()}") - - # Check file details - result = devbox.cmd.exec("ls -lh /home/user/mounted-data.txt") - print(f"File details: {result.stdout().strip()}") - - # Try to use the mounted file - result = devbox.cmd.exec("wc -l /home/user/mounted-data.txt") - print(f"Line count: {result.stdout().strip()}") - finally: - devbox.shutdown() - print("Devbox shutdown") - - return obj - - -def demonstrate_archive_mounting(sdk: RunloopSDK): - """Create and mount an archive that gets extracted.""" - print("\n=== Mounting Archive (Extraction) ===") - - # Create a temporary directory with files - import io - import tarfile - - # Create a tar.gz archive in memory - tar_buffer = io.BytesIO() - with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar: - # Add some files - for i in range(3): - content = f"File {i + 1} content\n".encode() - info = tarfile.TarInfo(name=f"project/file{i + 1}.txt") - info.size = len(content) - tar.addfile(info, io.BytesIO(content)) - - tar_data = tar_buffer.getvalue() - print(f"Created archive with {len(tar_data)} bytes") - - # Upload the archive - archive_obj = sdk.storage_object.upload_from_bytes( - tar_data, - name="project-archive.tar.gz", - content_type="tar", - ) - print(f"Uploaded archive: {archive_obj.id}") - - # Create devbox with archive mounted (it will be extracted) - devbox = sdk.devbox.create( - name="archive-mount-devbox", - mounts=[ - { - "type": "object_mount", - "object_id": archive_obj.id, - "object_path": "/home/user/project", - } - ], - ) - - print(f"Created devbox: {devbox.id}") - - try: - # List the extracted contents - result = devbox.cmd.exec("ls -la /home/user/project/") - print(f"Extracted archive contents:\n{result.stdout()}") - - # Read one of the files - result = devbox.cmd.exec("cat /home/user/project/file1.txt") - print(f"File1 content: {result.stdout().strip()}") - finally: - devbox.shutdown() - print("Devbox shutdown") - - return archive_obj - - -def list_storage_objects(sdk: RunloopSDK): - """List all storage objects.""" - print("\n=== Listing Storage Objects ===") - - objects = sdk.storage_object.list(limit=10) - - print(f"Found {len(objects)} storage objects:") - for obj in objects: - info = obj.refresh() - print(f" - {info.name} ({obj.id}): {info.content_type}") - - -def cleanup_storage_objects(objects: list[StorageObject]): - """Delete storage objects to clean up.""" - print("\n=== Cleaning Up Storage Objects ===") - - for obj in objects: - try: - info = obj.refresh() - print(f"Deleting: {info.name} ({obj.id})") - obj.delete() - print(f" Deleted: {obj.id}") - except Exception as e: - print(f" Error deleting {obj.id}: {e}") - - -def main(): - # Initialize the SDK - sdk = RunloopSDK() - print("Initialized Runloop SDK\n") - - created_objects: list[StorageObject] = [] - - try: - # Demonstrate different upload methods - text_obj = demonstrate_text_upload(sdk) - created_objects.append(text_obj) - - binary_obj = demonstrate_bytes_upload(sdk) - created_objects.append(binary_obj) - - file_obj = demonstrate_file_upload(sdk) - created_objects.append(file_obj) - - manual_obj = demonstrate_manual_upload(sdk) - created_objects.append(manual_obj) - - # Demonstrate mounting - mount_obj = demonstrate_storage_mounting(sdk) - created_objects.append(mount_obj) - - archive_obj = demonstrate_archive_mounting(sdk) - created_objects.append(archive_obj) - - # List all objects - list_storage_objects(sdk) - - finally: - # Cleanup all created objects - if created_objects: - cleanup_storage_objects(created_objects) - - print("\nStorage object example completed!") - - -if __name__ == "__main__": - # Ensure API key is set - if not os.getenv("RUNLOOP_API_KEY"): - print("Error: RUNLOOP_API_KEY environment variable is not set") - print("Please set it to your Runloop API key:") - print(" export RUNLOOP_API_KEY=your-api-key") - exit(1) - - try: - main() - except Exception as e: - print(f"\nError: {e}") - raise diff --git a/examples/streaming_output.py b/examples/streaming_output.py deleted file mode 100644 index bf5984b60..000000000 --- a/examples/streaming_output.py +++ /dev/null @@ -1,268 +0,0 @@ -#!/usr/bin/env -S uv run python -""" -Runloop SDK Example - Real-time Command Output Streaming - -This example demonstrates streaming command output in real-time: -- Streaming stdout -- Streaming stderr -- Streaming combined output -- Processing output line-by-line -- Using synchronous callbacks (async callbacks not supported) -""" - -import os -import asyncio -from datetime import datetime - -from runloop_api_client import RunloopSDK, AsyncRunloopSDK - - -def demonstrate_basic_streaming(sdk: RunloopSDK): - """Demonstrate basic stdout streaming.""" - print("=== Basic Stdout Streaming ===") - - with sdk.devbox.create(name="streaming-basic-devbox") as devbox: - print(f"Created devbox: {devbox.id}\n") - - # Simple callback to print output - def print_output(line: str): - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - print(f"[{timestamp}] {line.rstrip()}") - - # Execute command with streaming - print("Streaming command output:") - result = devbox.cmd.exec( - 'for i in 1 2 3 4 5; do echo "Processing item $i"; sleep 0.5; done', - stdout=print_output, - ) - - print(f"\nCommand completed with exit code: {result.exit_code}") - - -def demonstrate_stderr_streaming(sdk: RunloopSDK): - """Demonstrate stderr streaming separately.""" - print("\n=== Separate Stdout and Stderr Streaming ===") - - with sdk.devbox.create(name="streaming-stderr-devbox") as devbox: - print(f"Created devbox: {devbox.id}\n") - - def handle_stdout(line: str): - print(f"[STDOUT] {line.rstrip()}") - - def handle_stderr(line: str): - print(f"[STDERR] {line.rstrip()}") - - # Command that writes to both stdout and stderr - print("Streaming stdout and stderr separately:") - result = devbox.cmd.exec( - """ - echo "This goes to stdout" - echo "This goes to stderr" >&2 - echo "Back to stdout" - echo "More stderr" >&2 - """, - stdout=handle_stdout, - stderr=handle_stderr, - ) - - print(f"\nCommand completed with exit code: {result.exit_code}") - - -def demonstrate_combined_streaming(sdk: RunloopSDK): - """Demonstrate combined output streaming.""" - print("\n=== Combined Output Streaming ===") - - with sdk.devbox.create(name="streaming-combined-devbox") as devbox: - print(f"Created devbox: {devbox.id}\n") - - # Track all output - all_output: list[tuple[str, str]] = [] - - def capture_all(line: str): - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - all_output.append((timestamp, line.rstrip())) - print(f"[{timestamp}] {line.rstrip()}") - - # Use the 'output' parameter to capture both stdout and stderr - print("Streaming combined output:") - result = devbox.cmd.exec( - """ - echo "Line 1" - echo "Error 1" >&2 - echo "Line 2" - echo "Error 2" >&2 - echo "Line 3" - """, - output=capture_all, - ) - - print(f"\nCommand completed with exit code: {result.exit_code}") - print(f"Captured {len(all_output)} lines of output") - - -def demonstrate_output_processing(sdk: RunloopSDK): - """Demonstrate processing streaming output.""" - print("\n=== Processing Streaming Output ===") - - with sdk.devbox.create(name="streaming-processing-devbox") as devbox: - print(f"Created devbox: {devbox.id}\n") - - # Process and analyze output - stats = { - "total_lines": 0, - "error_lines": 0, - "warning_lines": 0, - "info_lines": 0, - } - - def analyze_output(line: str): - stats["total_lines"] += 1 - line_lower = line.lower() - - if "error" in line_lower: - stats["error_lines"] += 1 - print(f"❌ ERROR: {line.rstrip()}") - elif "warning" in line_lower: - stats["warning_lines"] += 1 - print(f"⚠️ WARNING: {line.rstrip()}") - else: - stats["info_lines"] += 1 - print(f"ℹ️ INFO: {line.rstrip()}") - - # Execute a script that produces different types of output - print("Analyzing output in real-time:") - result = devbox.cmd.exec( - """ - echo "Starting process..." - echo "Warning: Low memory" - echo "Processing data..." - echo "Error: Connection timeout" - echo "Retrying..." - echo "Warning: Slow response" - echo "Success: Operation complete" - """, - stdout=analyze_output, - ) - - print(f"\nCommand completed with exit code: {result.exit_code}") - print(f"\nOutput Statistics:") - print(f" Total lines: {stats['total_lines']}") - print(f" Errors: {stats['error_lines']}") - print(f" Warnings: {stats['warning_lines']}") - print(f" Info: {stats['info_lines']}") - - -def demonstrate_long_running_stream(sdk: RunloopSDK): - """Demonstrate streaming output from a long-running command.""" - print("\n=== Long-running Command Streaming ===") - - with sdk.devbox.create(name="streaming-longrun-devbox") as devbox: - print(f"Created devbox: {devbox.id}\n") - - progress_items: list[str] = [] - - def track_progress(line: str): - line = line.rstrip() - if "Progress:" in line: - progress_items.append(line) - # Extract percentage if present - print(f"📊 {line}") - else: - print(f" {line}") - - print("Streaming output from long-running task:") - result = devbox.cmd.exec( - """ - echo "Starting long-running task..." - for i in 1 2 3 4 5 6 7 8 9 10; do - echo "Progress: $((i * 10))% complete" - sleep 0.3 - done - echo "Task completed successfully!" - """, - stdout=track_progress, - ) - - print(f"\nCommand completed with exit code: {result.exit_code}") - print(f"Tracked {len(progress_items)} progress updates") - - -async def demonstrate_async_streaming(): - """Demonstrate async devbox with synchronous callbacks. - - Note: Callbacks must be synchronous functions, not async. - Use thread-safe queues if you need to process output asynchronously. - """ - print("\n=== Async Devbox with Synchronous Callbacks ===") - - sdk = AsyncRunloopSDK() - - async with await sdk.devbox.create(name="async-streaming-devbox") as devbox: - print(f"Created devbox: {devbox.id}\n") - - # Synchronous callback (callbacks must be sync, not async) - output_lines: list[str] = [] - - def capture_output(line: str): - # Callback must be synchronous - output_lines.append(line.rstrip()) - print(f"[CAPTURED] {line.rstrip()}") - - # Execute with synchronous callback - print("Streaming with synchronous callbacks:") - await devbox.cmd.exec( - 'for i in 1 2 3 4 5; do echo "Line $i"; sleep 0.2; done', - stdout=capture_output, - ) - - print(f"\nCaptured {len(output_lines)} lines") - print(f"Output: {output_lines}") - - -def main(): - # Initialize the SDK - sdk = RunloopSDK() - print("Initialized Runloop SDK\n") - - # Run synchronous streaming demonstrations - demonstrate_basic_streaming(sdk) - demonstrate_stderr_streaming(sdk) - demonstrate_combined_streaming(sdk) - demonstrate_output_processing(sdk) - demonstrate_long_running_stream(sdk) - - print("\nSynchronous streaming examples completed!") - - -async def async_main(): - """Run async streaming demonstrations.""" - print("\n" + "=" * 60) - print("Running Async Examples") - print("=" * 60 + "\n") - - await demonstrate_async_streaming() - - print("\nAsync streaming examples completed!") - - -if __name__ == "__main__": - # Ensure API key is set - if not os.getenv("RUNLOOP_API_KEY"): - print("Error: RUNLOOP_API_KEY environment variable is not set") - print("Please set it to your Runloop API key:") - print(" export RUNLOOP_API_KEY=your-api-key") - exit(1) - - try: - # Run synchronous examples - main() - - # Run async examples - asyncio.run(async_main()) - - print("\n" + "=" * 60) - print("All streaming examples completed successfully!") - print("=" * 60) - except Exception as e: - print(f"\nError: {e}") - raise From b715dd9a7c7ffd87c24d7c169691a2ca77d6928d Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Fri, 14 Nov 2025 21:10:12 -0800 Subject: [PATCH 38/56] python sdk manual types (and small fixes/cleanups) --- src/runloop_api_client/sdk/_helpers.py | 43 +- src/runloop_api_client/sdk/_types.py | 144 +++++++ src/runloop_api_client/sdk/async_.py | 275 +++---------- src/runloop_api_client/sdk/async_blueprint.py | 88 +--- src/runloop_api_client/sdk/async_devbox.py | 378 ++++-------------- src/runloop_api_client/sdk/async_execution.py | 54 ++- .../sdk/async_execution_result.py | 30 +- src/runloop_api_client/sdk/async_snapshot.py | 117 +----- .../sdk/async_storage_object.py | 106 +---- src/runloop_api_client/sdk/blueprint.py | 88 +--- src/runloop_api_client/sdk/devbox.py | 367 ++++------------- src/runloop_api_client/sdk/execution.py | 51 +-- .../sdk/execution_result.py | 31 +- src/runloop_api_client/sdk/protocols.py | 185 ++------- src/runloop_api_client/sdk/snapshot.py | 117 +----- src/runloop_api_client/sdk/storage_object.py | 103 +---- src/runloop_api_client/sdk/sync.py | 245 +++--------- .../types/devbox_create_params.py | 34 +- .../types/object_create_params.py | 9 +- 19 files changed, 690 insertions(+), 1775 deletions(-) create mode 100644 src/runloop_api_client/sdk/_types.py diff --git a/src/runloop_api_client/sdk/_helpers.py b/src/runloop_api_client/sdk/_helpers.py index 6f2ec8a69..45d6682f2 100644 --- a/src/runloop_api_client/sdk/_helpers.py +++ b/src/runloop_api_client/sdk/_helpers.py @@ -2,16 +2,10 @@ from __future__ import annotations -import io -import os -from typing import Dict, Union, Literal, Callable +from typing import Any, Dict, Type, Mapping, TypeVar from pathlib import Path -# Callback for streaming output. Must be synchronous even in async contexts. -LogCallback = Callable[[str], None] - -ContentType = Literal["unspecified", "text", "binary", "gzip", "tar", "tgz"] -UploadData = Union[str, bytes, bytearray, Path, os.PathLike[str], io.IOBase] +from ..types.object_create_params import ContentType _CONTENT_TYPE_MAP: Dict[str, ContentType] = { ".txt": "text", @@ -39,22 +33,17 @@ def detect_content_type(name: str) -> ContentType: return _CONTENT_TYPE_MAP.get(ext, "unspecified") -def read_upload_data(data: UploadData) -> bytes: - if isinstance(data, bytes): - return data - if isinstance(data, bytearray): - return bytes(data) - if isinstance(data, (Path, os.PathLike)): - return Path(data).read_bytes() - if isinstance(data, str): - return data.encode("utf-8") - if isinstance(data, io.TextIOBase): - return data.read().encode("utf-8") - if isinstance(data, io.BufferedIOBase) or isinstance(data, io.RawIOBase): - return data.read() - if hasattr(data, "read"): - result = data.read() - if isinstance(result, str): - return result.encode("utf-8") - return result - raise TypeError("Unsupported upload data type. Provide str, bytes, path, or file-like object.") +T = TypeVar("T") + + +def filter_params(params: Mapping[str, Any], type_filter: Type[T]) -> T: + """Filter params dict to only include keys defined in the given TypedDict type. + + Args: + params: Dictionary or TypedDict of parameters to filter + type_filter: TypedDict class to filter against + + Returns: + Filtered dictionary matching the TypedDict structure + """ + return {k: v for k, v in params.items() if k in type_filter.__annotations__} # type: ignore[return-value] diff --git a/src/runloop_api_client/sdk/_types.py b/src/runloop_api_client/sdk/_types.py new file mode 100644 index 000000000..72ede0da1 --- /dev/null +++ b/src/runloop_api_client/sdk/_types.py @@ -0,0 +1,144 @@ +from typing import Union, Callable, Optional +from typing_extensions import TypedDict + +from runloop_api_client.types.devboxes import DiskSnapshotUpdateParams + +from .._types import Body, Query, Headers, Timeout, NotGiven +from ..lib.polling import PollingConfig +from ..types.devbox_list_params import DevboxListParams +from ..types.object_list_params import ObjectListParams +from ..types.devbox_create_params import DevboxCreateParams, DevboxBaseCreateParams +from ..types.object_create_params import ObjectCreateParams +from ..types.blueprint_list_params import BlueprintListParams +from ..types.object_download_params import ObjectDownloadParams +from ..types.blueprint_create_params import BlueprintCreateParams +from ..types.devbox_upload_file_params import DevboxUploadFileParams +from ..types.devbox_create_tunnel_params import DevboxCreateTunnelParams +from ..types.devbox_download_file_params import DevboxDownloadFileParams +from ..types.devbox_execute_async_params import DevboxExecuteAsyncParams +from ..types.devbox_remove_tunnel_params import DevboxRemoveTunnelParams +from ..types.devbox_snapshot_disk_params import DevboxSnapshotDiskParams +from ..types.devbox_read_file_contents_params import DevboxReadFileContentsParams +from ..types.devbox_write_file_contents_params import DevboxWriteFileContentsParams +from ..types.devboxes.disk_snapshot_list_params import DiskSnapshotListParams + +LogCallback = Callable[[str], None] + + +class ExecuteStreamingCallbacks(TypedDict, total=False): + stdout: Optional[LogCallback] + """Callback invoked for each stdout log line""" + + stderr: Optional[LogCallback] + """Callback invoked for each stderr log line""" + + output: Optional[LogCallback] + """Callback invoked for all log lines (both stdout and stderr)""" + + +class RequestOptions(TypedDict, total=False): + extra_headers: Optional[Headers] + """Send extra headers""" + + extra_query: Optional[Query] + """Add additional query parameters to the request""" + + extra_body: Optional[Body] + """Add additional JSON properties to the request""" + + timeout: Union[float, Timeout, NotGiven, None] + """Override the client-level default timeout for this request, in seconds""" + + +class LongRequestOptions(RequestOptions, total=False): + idempotency_key: Optional[str] + """Specify a custom idempotency key for this request""" + + +class PollingRequestOptions(RequestOptions, total=False): + polling_config: Optional[PollingConfig] + """Configuration for polling behavior""" + + +class LongPollingRequestOptions(LongRequestOptions, PollingRequestOptions): + pass + + +class SDKDevboxCreateParams(DevboxCreateParams, LongPollingRequestOptions): + pass + + +class SDKDevboxExtraCreateParams(DevboxBaseCreateParams, LongPollingRequestOptions): + pass + + +class SDKDevboxExecuteParams(DevboxExecuteAsyncParams, ExecuteStreamingCallbacks, LongPollingRequestOptions): + pass + + +class SDKDevboxExecuteAsyncParams(DevboxExecuteAsyncParams, ExecuteStreamingCallbacks, LongRequestOptions): + pass + + +class SDKDevboxListParams(DevboxListParams, RequestOptions): + pass + + +class SDKDevboxReadFileContentsParams(DevboxReadFileContentsParams, LongRequestOptions): + pass + + +class SDKDevboxWriteFileContentsParams(DevboxWriteFileContentsParams, LongRequestOptions): + pass + + +class SDKDevboxDownloadFileParams(DevboxDownloadFileParams, LongRequestOptions): + pass + + +class SDKDevboxUploadFileParams(DevboxUploadFileParams, LongRequestOptions): + pass + + +class SDKDevboxCreateTunnelParams(DevboxCreateTunnelParams, LongRequestOptions): + pass + + +class SDKDevboxRemoveTunnelParams(DevboxRemoveTunnelParams, LongRequestOptions): + pass + + +class SDKDevboxSnapshotDiskAsyncParams(DevboxSnapshotDiskParams, LongRequestOptions): + pass + + +class SDKDevboxSnapshotDiskParams(DevboxSnapshotDiskParams, LongPollingRequestOptions): + pass + + +class SDKDiskSnapshotListParams(DiskSnapshotListParams, RequestOptions): + pass + + +class SDKDiskSnapshotUpdateParams(DiskSnapshotUpdateParams, LongRequestOptions): + pass + + +class SDKBlueprintCreateParams(BlueprintCreateParams, LongPollingRequestOptions): + pass + + +class SDKBlueprintListParams(BlueprintListParams, RequestOptions): + pass + + +class SDKObjectListParams(ObjectListParams, RequestOptions): + pass + + +class SDKObjectCreateParams(ObjectCreateParams, LongRequestOptions): + pass + + +class SDKObjectDownloadParams(ObjectDownloadParams, RequestOptions): + pass diff --git a/src/runloop_api_client/sdk/async_.py b/src/runloop_api_client/sdk/async_.py index 4ada89ecc..e40a20220 100644 --- a/src/runloop_api_client/sdk/async_.py +++ b/src/runloop_api_client/sdk/async_.py @@ -2,29 +2,31 @@ from __future__ import annotations -from typing import Dict, Mapping, Iterable, Optional +from typing import Dict, Mapping, Optional from pathlib import Path from typing_extensions import Unpack import httpx -from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given -from .._client import AsyncRunloop -from ._helpers import ContentType, detect_content_type -from ..lib.polling import PollingConfig +from ._types import ( + LongRequestOptions, + SDKDevboxListParams, + SDKObjectListParams, + SDKDevboxCreateParams, + SDKObjectCreateParams, + SDKBlueprintListParams, + SDKBlueprintCreateParams, + SDKDiskSnapshotListParams, + SDKDevboxExtraCreateParams, +) +from .._types import Timeout, NotGiven, not_given +from .._client import DEFAULT_MAX_RETRIES, AsyncRunloop +from ._helpers import detect_content_type from .async_devbox import AsyncDevbox from .async_snapshot import AsyncSnapshot from .async_blueprint import AsyncBlueprint from .async_storage_object import AsyncStorageObject -from ..types.devbox_list_params import DevboxListParams -from ..types.object_list_params import ObjectListParams -from ..types.shared_params.mount import Mount -from ..types.devbox_create_params import DevboxCreateParams -from ..types.blueprint_list_params import BlueprintListParams -from ..types.blueprint_create_params import BlueprintCreateParams -from ..types.shared_params.launch_parameters import LaunchParameters -from ..types.devboxes.disk_snapshot_list_params import DiskSnapshotListParams -from ..types.shared_params.code_mount_parameters import CodeMountParameters +from ..types.object_create_params import ContentType class AsyncDevboxClient: @@ -35,22 +37,9 @@ def __init__(self, client: AsyncRunloop) -> None: async def create( self, - *, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, - **params: Unpack[DevboxCreateParams], + **params: Unpack[SDKDevboxCreateParams], ) -> AsyncDevbox: devbox_view = await self._client.devboxes.create_and_await_running( - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, **params, ) return AsyncDevbox(self._client, devbox_view.id) @@ -58,126 +47,33 @@ async def create( async def create_from_blueprint_id( self, blueprint_id: str, - *, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - entrypoint: Optional[str] | Omit = omit, - environment_variables: Optional[Dict[str, str]] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - mounts: Optional[Iterable[Mount]] | Omit = omit, - name: Optional[str] | Omit = omit, - repo_connection_id: Optional[str] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExtraCreateParams], ) -> AsyncDevbox: devbox_view = await self._client.devboxes.create_and_await_running( blueprint_id=blueprint_id, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - mounts=mounts, - name=name, - repo_connection_id=repo_connection_id, - secrets=secrets, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) return AsyncDevbox(self._client, devbox_view.id) async def create_from_blueprint_name( self, blueprint_name: str, - *, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - entrypoint: Optional[str] | Omit = omit, - environment_variables: Optional[Dict[str, str]] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - mounts: Optional[Iterable[Mount]] | Omit = omit, - name: Optional[str] | Omit = omit, - repo_connection_id: Optional[str] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExtraCreateParams], ) -> AsyncDevbox: devbox_view = await self._client.devboxes.create_and_await_running( blueprint_name=blueprint_name, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - mounts=mounts, - name=name, - repo_connection_id=repo_connection_id, - secrets=secrets, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) return AsyncDevbox(self._client, devbox_view.id) async def create_from_snapshot( self, snapshot_id: str, - *, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - entrypoint: Optional[str] | Omit = omit, - environment_variables: Optional[Dict[str, str]] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - mounts: Optional[Iterable[Mount]] | Omit = omit, - name: Optional[str] | Omit = omit, - repo_connection_id: Optional[str] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExtraCreateParams], ) -> AsyncDevbox: devbox_view = await self._client.devboxes.create_and_await_running( snapshot_id=snapshot_id, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - mounts=mounts, - name=name, - repo_connection_id=repo_connection_id, - secrets=secrets, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) return AsyncDevbox(self._client, devbox_view.id) @@ -186,18 +82,9 @@ def from_id(self, devbox_id: str) -> AsyncDevbox: async def list( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - **params: Unpack[DevboxListParams], + **params: Unpack[SDKDevboxListParams], ) -> list[AsyncDevbox]: page = await self._client.devboxes.list( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, **params, ) return [AsyncDevbox(self._client, item.id) for item in page.devboxes] @@ -211,18 +98,9 @@ def __init__(self, client: AsyncRunloop) -> None: async def list( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - **params: Unpack[DiskSnapshotListParams], + **params: Unpack[SDKDiskSnapshotListParams], ) -> list[AsyncSnapshot]: page = await self._client.devboxes.disk_snapshots.list( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, **params, ) return [AsyncSnapshot(self._client, item.id) for item in page.snapshots] @@ -239,22 +117,9 @@ def __init__(self, client: AsyncRunloop) -> None: async def create( self, - *, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, - **params: Unpack[BlueprintCreateParams], + **params: Unpack[SDKBlueprintCreateParams], ) -> AsyncBlueprint: blueprint = await self._client.blueprints.create_and_await_build_complete( - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, **params, ) return AsyncBlueprint(self._client, blueprint.id) @@ -264,18 +129,9 @@ def from_id(self, blueprint_id: str) -> AsyncBlueprint: async def list( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - **params: Unpack[BlueprintListParams], + **params: Unpack[SDKBlueprintListParams], ) -> list[AsyncBlueprint]: page = await self._client.blueprints.list( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, **params, ) return [AsyncBlueprint(self._client, item.id) for item in page.blueprints] @@ -289,13 +145,9 @@ def __init__(self, client: AsyncRunloop) -> None: async def create( self, - name: str, - *, - content_type: ContentType | None = None, - metadata: Optional[Dict[str, str]] = None, + **params: Unpack[SDKObjectCreateParams], ) -> AsyncStorageObject: - content_type = content_type or detect_content_type(name) - obj = await self._client.objects.create(name=name, content_type=content_type, metadata=metadata) + obj = await self._client.objects.create(**params) return AsyncStorageObject(self._client, obj.id, upload_url=obj.upload_url) def from_id(self, object_id: str) -> AsyncStorageObject: @@ -303,34 +155,33 @@ def from_id(self, object_id: str) -> AsyncStorageObject: async def list( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - **params: Unpack[ObjectListParams], + **params: Unpack[SDKObjectListParams], ) -> list[AsyncStorageObject]: page = await self._client.objects.list( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, **params, ) return [AsyncStorageObject(self._client, item.id, upload_url=item.upload_url) for item in page.objects] async def upload_from_file( self, - path: str | Path, + file_path: str | Path, name: str | None = None, *, - metadata: Optional[Dict[str, str]] = None, content_type: ContentType | None = None, + metadata: Optional[Dict[str, str]] = None, + **options: Unpack[LongRequestOptions], ) -> AsyncStorageObject: - file_path = Path(path) - object_name = name or file_path.name - obj = await self.create(object_name, content_type=content_type, metadata=metadata) - await obj.upload_content(file_path) + path = Path(file_path) + + try: + content = path.read_bytes() + except OSError as error: + raise OSError(f"Failed to read file {path}: {error}") from error + + name = name or path.name + content_type = content_type or detect_content_type(str(file_path)) + obj = await self.create(name=name, content_type=content_type, metadata=metadata, **options) + await obj.upload_content(content) await obj.complete() return obj @@ -340,8 +191,9 @@ async def upload_from_text( name: str, *, metadata: Optional[Dict[str, str]] = None, + **options: Unpack[LongRequestOptions], ) -> AsyncStorageObject: - obj = await self.create(name, content_type="text", metadata=metadata) + obj = await self.create(name=name, content_type="text", metadata=metadata, **options) await obj.upload_content(text) await obj.complete() return obj @@ -351,10 +203,11 @@ async def upload_from_bytes( data: bytes, name: str, *, + content_type: ContentType, metadata: Optional[Dict[str, str]] = None, - content_type: ContentType | None = None, + **options: Unpack[LongRequestOptions], ) -> AsyncStorageObject: - obj = await self.create(name, content_type=content_type or detect_content_type(name), metadata=metadata) + obj = await self.create(name=name, content_type=content_type, metadata=metadata, **options) await obj.upload_content(data) await obj.complete() return obj @@ -380,30 +233,20 @@ def __init__( bearer_token: str | None = None, base_url: str | httpx.URL | None = None, timeout: float | Timeout | None | NotGiven = not_given, - max_retries: int | None = None, + max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, http_client: httpx.AsyncClient | None = None, ) -> None: - if max_retries is None: - self.api = AsyncRunloop( - bearer_token=bearer_token, - base_url=base_url, - timeout=timeout, - default_headers=default_headers, - default_query=default_query, - http_client=http_client, - ) - else: - self.api = AsyncRunloop( - bearer_token=bearer_token, - base_url=base_url, - timeout=timeout, - max_retries=max_retries, - default_headers=default_headers, - default_query=default_query, - http_client=http_client, - ) + self.api = AsyncRunloop( + bearer_token=bearer_token, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + default_headers=default_headers, + default_query=default_query, + http_client=http_client, + ) self.devbox = AsyncDevboxClient(self.api) self.blueprint = AsyncBlueprintClient(self.api) diff --git a/src/runloop_api_client/sdk/async_blueprint.py b/src/runloop_api_client/sdk/async_blueprint.py index bd96ae814..5d8498738 100644 --- a/src/runloop_api_client/sdk/async_blueprint.py +++ b/src/runloop_api_client/sdk/async_blueprint.py @@ -2,19 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, Iterable, Optional -from typing_extensions import override +from typing_extensions import Unpack, override -if TYPE_CHECKING: - from .async_devbox import AsyncDevbox from ..types import BlueprintView -from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given +from ._types import RequestOptions, LongRequestOptions, SDKDevboxExtraCreateParams from .._client import AsyncRunloop -from ..lib.polling import PollingConfig -from ..types.shared_params.mount import Mount +from .async_devbox import AsyncDevbox from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView -from ..types.shared_params.launch_parameters import LaunchParameters -from ..types.shared_params.code_mount_parameters import CodeMountParameters class AsyncBlueprint: @@ -40,91 +34,37 @@ def id(self) -> str: async def get_info( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[RequestOptions], ) -> BlueprintView: return await self._client.blueprints.retrieve( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) async def logs( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[RequestOptions], ) -> BlueprintBuildLogsListView: return await self._client.blueprints.logs( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) async def delete( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[LongRequestOptions], ) -> object: return await self._client.blueprints.delete( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) async def create_devbox( self, - *, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - entrypoint: Optional[str] | Omit = omit, - environment_variables: Optional[Dict[str, str]] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - mounts: Optional[Iterable[Mount]] | Omit = omit, - name: Optional[str] | Omit = omit, - repo_connection_id: Optional[str] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExtraCreateParams], ) -> "AsyncDevbox": - from .async_ import AsyncDevboxClient - - devbox_client = AsyncDevboxClient(self._client) - return await devbox_client.create_from_blueprint_id( - self._id, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - mounts=mounts, - name=name, - repo_connection_id=repo_connection_id, - secrets=secrets, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + devbox_view = await self._client.devboxes.create_and_await_running( + blueprint_id=self._id, + **params, ) + return AsyncDevbox(self._client, devbox_view.id) diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index b08377f91..98b2c5520 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -5,7 +5,7 @@ import asyncio import logging from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence, Awaitable, cast -from typing_extensions import override +from typing_extensions import Unpack, override from ..types import ( DevboxView, @@ -13,14 +13,31 @@ DevboxExecutionDetailView, DevboxCreateSSHKeyResponse, ) -from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, FileTypes, omit, not_given +from ._types import ( + LogCallback, + RequestOptions, + LongRequestOptions, + PollingRequestOptions, + SDKDevboxExecuteParams, + ExecuteStreamingCallbacks, + SDKDevboxUploadFileParams, + SDKDevboxCreateTunnelParams, + SDKDevboxDownloadFileParams, + SDKDevboxExecuteAsyncParams, + SDKDevboxRemoveTunnelParams, + SDKDevboxSnapshotDiskParams, + SDKDevboxReadFileContentsParams, + SDKDevboxSnapshotDiskAsyncParams, + SDKDevboxWriteFileContentsParams, +) from .._client import AsyncRunloop -from ._helpers import LogCallback +from ._helpers import filter_params from .protocols import AsyncFileInterface, AsyncCommandInterface, AsyncNetworkInterface from .._streaming import AsyncStream from ..lib.polling import PollingConfig from .async_execution import AsyncExecution, _AsyncStreamingGroup from .async_execution_result import AsyncExecutionResult +from ..types.devbox_execute_async_params import DevboxExecuteAsyncParams from ..types.devboxes.execution_update_chunk import ExecutionUpdateChunk from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView @@ -59,18 +76,11 @@ def id(self) -> str: async def get_info( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[RequestOptions], ) -> DevboxView: return await self._client.devboxes.retrieve( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) async def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView: @@ -81,142 +91,59 @@ async def await_suspended(self, *, polling_config: PollingConfig | None = None) async def shutdown( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> DevboxView: return await self._client.devboxes.shutdown( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **options, ) async def suspend( self, - *, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> DevboxView: - await self._client.devboxes.suspend( - self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, - ) - return await self._client.devboxes.await_suspended( + return await self._client.devboxes.suspend( self._id, - polling_config=polling_config, + **options, ) async def resume( self, - *, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> DevboxView: - await self._client.devboxes.resume( - self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, - ) - return await self._client.devboxes.await_running( + return await self._client.devboxes.resume( self._id, - polling_config=polling_config, + **options, ) async def keep_alive( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> object: return await self._client.devboxes.keep_alive( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **options, ) async def snapshot_disk( self, - *, - commit_message: str | None | Omit = omit, - metadata: dict[str, str] | None | Omit = omit, - name: str | None | Omit = omit, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxSnapshotDiskParams], ) -> "AsyncSnapshot": snapshot_data = await self._client.devboxes.snapshot_disk_async( self._id, - commit_message=commit_message, - metadata=metadata, - name=name, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **filter_params(params, SDKDevboxSnapshotDiskAsyncParams), ) snapshot = self._snapshot_from_id(snapshot_data.id) - await snapshot.await_completed( - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) + await snapshot.await_completed(**filter_params(params, PollingRequestOptions)) return snapshot async def snapshot_disk_async( self, - *, - commit_message: str | None | Omit = omit, - metadata: dict[str, str] | None | Omit = omit, - name: str | None | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxSnapshotDiskAsyncParams], ) -> "AsyncSnapshot": snapshot_data = await self._client.devboxes.snapshot_disk_async( self._id, - commit_message=commit_message, - metadata=metadata, - name=name, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) return self._snapshot_from_id(snapshot_data.id) @@ -319,115 +246,62 @@ def __init__(self, devbox: AsyncDevbox) -> None: async def exec( self, - command: str, - *, - shell_name: Optional[str] | Omit = omit, - stdout: Optional[LogCallback] = None, - stderr: Optional[LogCallback] = None, - output: Optional[LogCallback] = None, - polling_config: PollingConfig | None = None, - attach_stdin: bool | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExecuteParams], ) -> AsyncExecutionResult: devbox = self._devbox client = devbox._client - if stdout or stderr or output: - execution: DevboxAsyncExecutionDetailView = await client.devboxes.execute_async( - devbox.id, - command=command, - shell_name=shell_name, - attach_stdin=attach_stdin, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, - ) - streaming_group = devbox._start_streaming( + execution: DevboxAsyncExecutionDetailView = await client.devboxes.execute_async( + devbox.id, + **filter_params(params, DevboxExecuteAsyncParams), + **filter_params(params, LongRequestOptions), + ) + streaming_group = devbox._start_streaming( + execution.execution_id, + **filter_params(params, ExecuteStreamingCallbacks), + ) + + async def command_coro() -> DevboxAsyncExecutionDetailView: + if execution.status == "completed": + return execution + return await client.devboxes.executions.await_completed( execution.execution_id, - stdout=stdout, - stderr=stderr, - output=output, + devbox_id=devbox.id, + polling_config=params.get("polling_config"), ) - async def command_coro() -> DevboxAsyncExecutionDetailView: - if execution.status == "completed": - return execution - return await client.devboxes.executions.await_completed( - execution.execution_id, - devbox_id=devbox.id, - polling_config=polling_config, - ) - - awaitables: list[Awaitable[DevboxAsyncExecutionDetailView | None]] = [command_coro()] - if streaming_group is not None: - awaitables.append(streaming_group.wait()) + awaitables: list[Awaitable[DevboxAsyncExecutionDetailView | None]] = [command_coro()] + if streaming_group is not None: + awaitables.append(streaming_group.wait()) - results = await asyncio.gather(*awaitables, return_exceptions=True) - command_result = results[0] + results = await asyncio.gather(*awaitables, return_exceptions=True) + command_result = results[0] - if isinstance(command_result, Exception): - if streaming_group is not None: - await streaming_group.cancel() - raise command_result + if isinstance(command_result, Exception): + if streaming_group is not None: + await streaming_group.cancel() + raise command_result - # Streaming finishes asynchronously via the shared gather call; nothing more to do here. - command_value = cast(DevboxAsyncExecutionDetailView, command_result) - return AsyncExecutionResult(client, devbox.id, command_value) - - final = await client.devboxes.execute_and_await_completion( - devbox.id, - command=command, - shell_name=shell_name, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, - ) - return AsyncExecutionResult(client, devbox.id, final) + # Streaming finishes asynchronously via the shared gather call; nothing more to do here. + command_value = cast(DevboxAsyncExecutionDetailView, command_result) + return AsyncExecutionResult(client, devbox.id, command_value) async def exec_async( self, - command: str, - *, - shell_name: Optional[str] | Omit = omit, - stdout: Optional[LogCallback] = None, - stderr: Optional[LogCallback] = None, - output: Optional[LogCallback] = None, - attach_stdin: bool | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExecuteAsyncParams], ) -> AsyncExecution: devbox = self._devbox client = devbox._client execution: DevboxAsyncExecutionDetailView = await client.devboxes.execute_async( devbox.id, - command=command, - shell_name=shell_name, - attach_stdin=attach_stdin, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **filter_params(params, DevboxExecuteAsyncParams), + **filter_params(params, LongRequestOptions), ) streaming_group = devbox._start_streaming( execution.execution_id, - stdout=stdout, - stderr=stderr, - output=output, + **filter_params(params, ExecuteStreamingCallbacks), ) return AsyncExecution(client, devbox.id, execution, streaming_group) @@ -439,92 +313,43 @@ def __init__(self, devbox: AsyncDevbox) -> None: async def read( self, - path: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxReadFileContentsParams], ) -> str: return await self._devbox._client.devboxes.read_file_contents( self._devbox.id, - file_path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) async def write( self, - path: str, - contents: str | bytes, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxWriteFileContentsParams], ) -> DevboxExecutionDetailView: + contents = params.get("contents") if isinstance(contents, bytes): - contents_str = contents.decode("utf-8") - else: - contents_str = contents + params = {**params, "contents": contents.decode("utf-8")} return await self._devbox._client.devboxes.write_file_contents( self._devbox.id, - file_path=path, - contents=contents_str, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) async def download( self, - path: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxDownloadFileParams], ) -> bytes: response = await self._devbox._client.devboxes.download_file( self._devbox.id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) return await response.read() async def upload( self, - path: str, - file: FileTypes, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxUploadFileParams], ) -> object: return await self._devbox._client.devboxes.upload_file( self._devbox.id, - path=path, - file=file, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) @@ -534,58 +359,27 @@ def __init__(self, devbox: AsyncDevbox) -> None: async def create_ssh_key( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> DevboxCreateSSHKeyResponse: return await self._devbox._client.devboxes.create_ssh_key( self._devbox.id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **options, ) async def create_tunnel( self, - *, - port: int, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxCreateTunnelParams], ) -> DevboxTunnelView: return await self._devbox._client.devboxes.create_tunnel( self._devbox.id, - port=port, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) async def remove_tunnel( self, - *, - port: int, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxRemoveTunnelParams], ) -> object: return await self._devbox._client.devboxes.remove_tunnel( self._devbox.id, - port=port, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) diff --git a/src/runloop_api_client/sdk/async_execution.py b/src/runloop_api_client/sdk/async_execution.py index bc2ed01da..7a534f5ca 100644 --- a/src/runloop_api_client/sdk/async_execution.py +++ b/src/runloop_api_client/sdk/async_execution.py @@ -5,9 +5,10 @@ import asyncio import logging from typing import Optional, Awaitable, cast +from typing_extensions import Unpack, override +from ._types import RequestOptions, LongRequestOptions from .._client import AsyncRunloop -from ..lib.polling import PollingConfig from .async_execution_result import AsyncExecutionResult from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView @@ -52,9 +53,13 @@ def __init__( self._client = client self._devbox_id = devbox_id self._execution_id = execution.execution_id - self._latest = execution + self._initial_result = execution self._streaming_group = streaming_group + @override + def __repr__(self) -> str: + return f"" + @property def execution_id(self) -> str: return self._execution_id @@ -63,56 +68,43 @@ def execution_id(self) -> str: def devbox_id(self) -> str: return self._devbox_id - async def result(self, *, polling_config: PollingConfig | None = None) -> AsyncExecutionResult: - async def command_coro() -> DevboxAsyncExecutionDetailView: - if self._latest.status == "completed": - return self._latest - return await self._client.devboxes.executions.await_completed( + async def result(self, **options: Unpack[LongRequestOptions]) -> AsyncExecutionResult: + # Wait for both command completion and streaming to finish + awaitables: list[Awaitable[DevboxAsyncExecutionDetailView | None]] = [ + self._client.devboxes.wait_for_command( self._execution_id, devbox_id=self._devbox_id, - polling_config=polling_config, + statuses=["completed"], + **options, ) - - awaitables: list[Awaitable[DevboxAsyncExecutionDetailView | None]] = [command_coro()] + ] if self._streaming_group is not None: awaitables.append(self._streaming_group.wait()) results = await asyncio.gather(*awaitables, return_exceptions=True) command_result = results[0] + # Extract command result (throw if it failed, ignore streaming errors) if isinstance(command_result, Exception): - if self._streaming_group is not None: - await self._streaming_group.cancel() raise command_result if self._streaming_group is not None: self._streaming_group = None - # Streaming completion is orchestrated via the gather call above. - command_value = cast(DevboxAsyncExecutionDetailView, command_result) - self._latest = command_value - return AsyncExecutionResult(self._client, self._devbox_id, command_value) + # Streaming errors are already logged in _AsyncStreamingGroup._log_results() + final = cast(DevboxAsyncExecutionDetailView, command_result) + return AsyncExecutionResult(self._client, self._devbox_id, final) - async def get_state(self) -> DevboxAsyncExecutionDetailView: - self._latest = await self._client.devboxes.executions.retrieve( + async def get_state(self, **options: Unpack[RequestOptions]) -> DevboxAsyncExecutionDetailView: + return await self._client.devboxes.executions.retrieve( self._execution_id, devbox_id=self._devbox_id, + **options, ) - return self._latest - async def kill(self, *, kill_process_group: bool | None = None) -> None: + async def kill(self, **options: Unpack[LongRequestOptions]) -> None: await self._client.devboxes.executions.kill( self._execution_id, devbox_id=self._devbox_id, - kill_process_group=kill_process_group, + **options, ) - await self._settle_streaming(cancel=True) - - async def _settle_streaming(self, *, cancel: bool) -> None: - if self._streaming_group is None: - return - if cancel: - await self._streaming_group.cancel() - else: - await self._streaming_group.wait() - self._streaming_group = None diff --git a/src/runloop_api_client/sdk/async_execution_result.py b/src/runloop_api_client/sdk/async_execution_result.py index c3a27a1f4..eadb3b676 100644 --- a/src/runloop_api_client/sdk/async_execution_result.py +++ b/src/runloop_api_client/sdk/async_execution_result.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing_extensions import Optional, override + from .._client import AsyncRunloop from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView @@ -15,11 +17,15 @@ def __init__( self, client: AsyncRunloop, devbox_id: str, - execution: DevboxAsyncExecutionDetailView, + result: DevboxAsyncExecutionDetailView, ) -> None: self._client = client self._devbox_id = devbox_id - self._execution = execution + self._result = result + + @override + def __repr__(self) -> str: + return f"" @property def devbox_id(self) -> str: @@ -27,11 +33,11 @@ def devbox_id(self) -> str: @property def execution_id(self) -> str: - return self._execution.execution_id + return self._result.execution_id @property def exit_code(self) -> int | None: - return self._execution.exit_status + return self._result.exit_status @property def success(self) -> bool: @@ -42,12 +48,18 @@ def failed(self) -> bool: exit_code = self.exit_code return exit_code is not None and exit_code != 0 - async def stdout(self) -> str: - return self._execution.stdout or "" + # TODO: add pagination support once we have it in the API + async def stdout(self, num_lines: Optional[int] = None) -> str: + if not num_lines or num_lines <= 0 or not self._result.stdout: + return "" + return self._result.stdout[-num_lines:] - async def stderr(self) -> str: - return self._execution.stderr or "" + # TODO: add pagination support once we have it in the API + async def stderr(self, num_lines: Optional[int] = None) -> str: + if not num_lines or num_lines <= 0 or not self._result.stderr: + return "" + return self._result.stderr[-num_lines:] @property def raw(self) -> DevboxAsyncExecutionDetailView: - return self._execution + return self._result diff --git a/src/runloop_api_client/sdk/async_snapshot.py b/src/runloop_api_client/sdk/async_snapshot.py index cab8291af..d21b06cda 100644 --- a/src/runloop_api_client/sdk/async_snapshot.py +++ b/src/runloop_api_client/sdk/async_snapshot.py @@ -2,18 +2,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, Iterable, Optional -from typing_extensions import override +from typing_extensions import Unpack, override -if TYPE_CHECKING: - from .async_devbox import AsyncDevbox -from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given +from ._types import ( + RequestOptions, + LongRequestOptions, + PollingRequestOptions, + SDKDevboxExtraCreateParams, + SDKDiskSnapshotUpdateParams, +) from .._client import AsyncRunloop -from ..lib.polling import PollingConfig -from ..types.shared_params.mount import Mount +from .async_devbox import AsyncDevbox from ..types.devbox_snapshot_view import DevboxSnapshotView -from ..types.shared_params.launch_parameters import LaunchParameters -from ..types.shared_params.code_mount_parameters import CodeMountParameters from ..types.devboxes.devbox_snapshot_async_status_view import DevboxSnapshotAsyncStatusView @@ -40,119 +40,46 @@ def id(self) -> str: async def get_info( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[RequestOptions], ) -> DevboxSnapshotAsyncStatusView: return await self._client.devboxes.disk_snapshots.query_status( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) async def update( self, - *, - commit_message: Optional[str] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - name: Optional[str] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDiskSnapshotUpdateParams], ) -> DevboxSnapshotView: return await self._client.devboxes.disk_snapshots.update( self._id, - commit_message=commit_message, - metadata=metadata, - name=name, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) async def delete( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> object: return await self._client.devboxes.disk_snapshots.delete( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **options, ) async def await_completed( self, - *, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[PollingRequestOptions], ) -> DevboxSnapshotAsyncStatusView: return await self._client.devboxes.disk_snapshots.await_completed( self._id, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) async def create_devbox( self, - *, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - entrypoint: Optional[str] | Omit = omit, - environment_variables: Optional[Dict[str, str]] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - mounts: Optional[Iterable[Mount]] | Omit = omit, - name: Optional[str] | Omit = omit, - repo_connection_id: Optional[str] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExtraCreateParams], ) -> "AsyncDevbox": - from .async_ import AsyncDevboxClient - - devbox_client = AsyncDevboxClient(self._client) - return await devbox_client.create_from_snapshot( - self._id, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - mounts=mounts, - name=name, - repo_connection_id=repo_connection_id, - secrets=secrets, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + devbox_view = await self._client.devboxes.create_and_await_running( + snapshot_id=self._id, + **params, ) + return AsyncDevbox(self._client, devbox_view.id) diff --git a/src/runloop_api_client/sdk/async_storage_object.py b/src/runloop_api_client/sdk/async_storage_object.py index d10b9ab27..e2bd443e6 100644 --- a/src/runloop_api_client/sdk/async_storage_object.py +++ b/src/runloop_api_client/sdk/async_storage_object.py @@ -2,13 +2,10 @@ from __future__ import annotations -from typing_extensions import override +from typing_extensions import Unpack, override -import httpx - -from .._types import Body, Query, Headers, Timeout, NotGiven, not_given +from ._types import RequestOptions, LongRequestOptions, SDKObjectDownloadParams from .._client import AsyncRunloop -from ._helpers import UploadData, read_upload_data from ..types.object_view import ObjectView from ..types.object_download_url_view import ObjectDownloadURLView @@ -37,133 +34,68 @@ def upload_url(self) -> str | None: async def refresh( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[RequestOptions], ) -> ObjectView: return await self._client.objects.retrieve( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) async def complete( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> ObjectView: result = await self._client.objects.complete( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **options, ) self._upload_url = None return result async def get_download_url( self, - *, - duration_seconds: int | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[SDKObjectDownloadParams], ) -> ObjectDownloadURLView: - if duration_seconds is None: - return await self._client.objects.download( - self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) return await self._client.objects.download( self._id, - duration_seconds=duration_seconds, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **params, ) async def download_as_bytes( self, - *, - duration_seconds: int | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[SDKObjectDownloadParams], ) -> bytes: url_view = await self.get_download_url( - duration_seconds=duration_seconds, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **params, ) - async with httpx.AsyncClient() as client: - response = await client.get(url_view.download_url) + response = await self._client._client.get(url_view.download_url) response.raise_for_status() return response.content async def download_as_text( self, - *, - duration_seconds: int | None = None, - encoding: str = "utf-8", - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[SDKObjectDownloadParams], ) -> str: url_view = await self.get_download_url( - duration_seconds=duration_seconds, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **params, ) - async with httpx.AsyncClient() as client: - response = await client.get(url_view.download_url) + response = await self._client._client.get(url_view.download_url) response.raise_for_status() - response.encoding = encoding + response.encoding = "utf-8" return response.text async def delete( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> ObjectView: return await self._client.objects.delete( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **options, ) - async def upload_content(self, data: UploadData) -> None: + async def upload_content(self, content: str | bytes) -> None: url = self._ensure_upload_url() - payload = read_upload_data(data) - async with httpx.AsyncClient() as client: - response = await client.put(url, content=payload) + response = await self._client._client.put(url, content=content) response.raise_for_status() def _ensure_upload_url(self) -> str: diff --git a/src/runloop_api_client/sdk/blueprint.py b/src/runloop_api_client/sdk/blueprint.py index 8d9fdc29b..eb58c151c 100644 --- a/src/runloop_api_client/sdk/blueprint.py +++ b/src/runloop_api_client/sdk/blueprint.py @@ -2,19 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, Iterable, Optional -from typing_extensions import override +from typing_extensions import Unpack, override -if TYPE_CHECKING: - from .devbox import Devbox from ..types import BlueprintView -from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given +from ._types import RequestOptions, LongRequestOptions, SDKDevboxExtraCreateParams +from .devbox import Devbox from .._client import Runloop -from ..lib.polling import PollingConfig -from ..types.shared_params.mount import Mount from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView -from ..types.shared_params.launch_parameters import LaunchParameters -from ..types.shared_params.code_mount_parameters import CodeMountParameters class Blueprint: @@ -40,91 +34,37 @@ def id(self) -> str: def get_info( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[RequestOptions], ) -> BlueprintView: return self._client.blueprints.retrieve( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) def logs( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[RequestOptions], ) -> BlueprintBuildLogsListView: return self._client.blueprints.logs( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) def delete( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[LongRequestOptions], ) -> object: return self._client.blueprints.delete( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) def create_devbox( self, - *, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - entrypoint: Optional[str] | Omit = omit, - environment_variables: Optional[Dict[str, str]] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - mounts: Optional[Iterable[Mount]] | Omit = omit, - name: Optional[str] | Omit = omit, - repo_connection_id: Optional[str] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExtraCreateParams], ) -> "Devbox": - from .sync import DevboxClient - - devbox_client = DevboxClient(self._client) - return devbox_client.create_from_blueprint_id( - self._id, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - mounts=mounts, - name=name, - repo_connection_id=repo_connection_id, - secrets=secrets, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + devbox_view = self._client.devboxes.create_and_await_running( + blueprint_id=self._id, + **params, ) + return Devbox(self._client, devbox_view.id) diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index 07240713d..9895c03db 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -4,8 +4,8 @@ import logging import threading -from typing import TYPE_CHECKING, Any, Dict, Callable, Optional, Sequence -from typing_extensions import override +from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence +from typing_extensions import Unpack, override from ..types import ( DevboxView, @@ -13,14 +13,32 @@ DevboxExecutionDetailView, DevboxCreateSSHKeyResponse, ) -from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, FileTypes, omit, not_given +from ._types import ( + LogCallback, + RequestOptions, + LongRequestOptions, + PollingRequestOptions, + SDKDevboxExecuteParams, + ExecuteStreamingCallbacks, + LongPollingRequestOptions, + SDKDevboxUploadFileParams, + SDKDevboxCreateTunnelParams, + SDKDevboxDownloadFileParams, + SDKDevboxExecuteAsyncParams, + SDKDevboxRemoveTunnelParams, + SDKDevboxSnapshotDiskParams, + SDKDevboxReadFileContentsParams, + SDKDevboxSnapshotDiskAsyncParams, + SDKDevboxWriteFileContentsParams, +) from .._client import Runloop -from ._helpers import LogCallback +from ._helpers import filter_params from .execution import Execution, _StreamingGroup from .protocols import FileInterface, CommandInterface, NetworkInterface from .._streaming import Stream from ..lib.polling import PollingConfig from .execution_result import ExecutionResult +from ..types.devbox_execute_async_params import DevboxExecuteAsyncParams from ..types.devboxes.execution_update_chunk import ExecutionUpdateChunk from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView @@ -71,11 +89,7 @@ def id(self) -> str: def get_info( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[RequestOptions], ) -> DevboxView: """Retrieve current devbox status and metadata. @@ -84,10 +98,7 @@ def get_info( """ return self._client.devboxes.retrieve( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView: @@ -118,12 +129,7 @@ def await_suspended(self, *, polling_config: PollingConfig | None = None) -> Dev def shutdown( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> DevboxView: """Shutdown the devbox, terminating all processes and releasing resources. @@ -132,22 +138,12 @@ def shutdown( """ return self._client.devboxes.shutdown( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **options, ) def suspend( self, - *, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongPollingRequestOptions], ) -> DevboxView: """Suspend the devbox, pausing execution while preserving state. @@ -162,23 +158,13 @@ def suspend( """ self._client.devboxes.suspend( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **filter_params(options, LongRequestOptions), ) - return self._client.devboxes.await_suspended(self._id, polling_config=polling_config) + return self._client.devboxes.await_suspended(self._id, polling_config=options.get("polling_config")) def resume( self, - *, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongPollingRequestOptions], ) -> DevboxView: """Resume a suspended devbox, restoring it to running state. @@ -192,22 +178,13 @@ def resume( """ self._client.devboxes.resume( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **filter_params(options, LongRequestOptions), ) - return self._client.devboxes.await_running(self._id, polling_config=polling_config) + return self._client.devboxes.await_running(self._id, polling_config=options.get("polling_config")) def keep_alive( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> object: """Extend the devbox timeout, preventing automatic shutdown. @@ -219,25 +196,12 @@ def keep_alive( """ return self._client.devboxes.keep_alive( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **options, ) def snapshot_disk( self, - *, - commit_message: Optional[str] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - name: Optional[str] | Omit = omit, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxSnapshotDiskParams], ) -> "Snapshot": """Create a disk snapshot of the devbox and wait for completion. @@ -255,36 +219,15 @@ def snapshot_disk( """ snapshot_data = self._client.devboxes.snapshot_disk_async( self._id, - commit_message=commit_message, - metadata=metadata, - name=name, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **filter_params(params, SDKDevboxSnapshotDiskAsyncParams), ) snapshot = self._snapshot_from_id(snapshot_data.id) - snapshot.await_completed( - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) + snapshot.await_completed(**filter_params(params, PollingRequestOptions)) return snapshot def snapshot_disk_async( self, - *, - commit_message: Optional[str] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - name: Optional[str] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxSnapshotDiskAsyncParams], ) -> "Snapshot": """Create a disk snapshot of the devbox asynchronously. @@ -301,14 +244,7 @@ def snapshot_disk_async( """ snapshot_data = self._client.devboxes.snapshot_disk_async( self._id, - commit_message=commit_message, - metadata=metadata, - name=name, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) return self._snapshot_from_id(snapshot_data.id) @@ -433,19 +369,7 @@ def __init__(self, devbox: Devbox) -> None: def exec( self, - command: str, - *, - shell_name: Optional[str] | Omit = omit, - stdout: Optional[LogCallback] = None, - stderr: Optional[LogCallback] = None, - output: Optional[LogCallback] = None, - polling_config: PollingConfig | None = None, - attach_stdin: bool | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExecuteParams], ) -> ExecutionResult: """Execute a command synchronously and wait for completion. @@ -469,69 +393,36 @@ def exec( devbox = self._devbox client = devbox._client - if stdout or stderr or output: - execution: DevboxAsyncExecutionDetailView = client.devboxes.execute_async( - devbox.id, - command=command, - shell_name=shell_name, - attach_stdin=attach_stdin, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, - ) - streaming_group = devbox._start_streaming( + execution: DevboxAsyncExecutionDetailView = client.devboxes.execute_async( + devbox.id, + **filter_params(params, DevboxExecuteAsyncParams), + **filter_params(params, LongRequestOptions), + ) + streaming_group = devbox._start_streaming( + execution.execution_id, + **filter_params(params, ExecuteStreamingCallbacks), + ) + final = execution + if execution.status == "completed": + final: DevboxAsyncExecutionDetailView = execution + else: + final = client.devboxes.executions.await_completed( execution.execution_id, - stdout=stdout, - stderr=stderr, - output=output, + devbox_id=devbox.id, + polling_config=params.get("polling_config"), ) - final = execution - if execution.status == "completed": - final: DevboxAsyncExecutionDetailView = execution - else: - final = client.devboxes.executions.await_completed( - execution.execution_id, - devbox_id=devbox.id, - polling_config=polling_config, - ) - if streaming_group is not None: - # Ensure log streaming has drained before returning the result. _stop_streaming() - # below will perform the final cleanup, but we still join here so callers only - # resume once all logs have been delivered. - streaming_group.join() + if streaming_group is not None: + # Ensure log streaming has drained before returning the result. _stop_streaming() + # below will perform the final cleanup, but we still join here so callers only + # resume once all logs have been delivered. + streaming_group.join() - return ExecutionResult(client, devbox.id, final) - - final = client.devboxes.execute_and_await_completion( - devbox.id, - command=command, - shell_name=shell_name, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, - ) return ExecutionResult(client, devbox.id, final) def exec_async( self, - command: str, - *, - shell_name: Optional[str] | Omit = omit, - stdout: Optional[LogCallback] = None, - stderr: Optional[LogCallback] = None, - output: Optional[LogCallback] = None, - attach_stdin: bool | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExecuteAsyncParams], ) -> Execution: """Execute a command asynchronously without waiting for completion. @@ -561,21 +452,13 @@ def exec_async( execution: DevboxAsyncExecutionDetailView = client.devboxes.execute_async( devbox.id, - command=command, - shell_name=shell_name, - attach_stdin=attach_stdin, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **filter_params(params, DevboxExecuteAsyncParams), + **filter_params(params, LongRequestOptions), ) streaming_group = devbox._start_streaming( execution.execution_id, - stdout=stdout, - stderr=stderr, - output=output, + **filter_params(params, ExecuteStreamingCallbacks), ) return Execution(client, devbox.id, execution, streaming_group) @@ -593,13 +476,7 @@ def __init__(self, devbox: Devbox) -> None: def read( self, - path: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxReadFileContentsParams], ) -> str: """Read a file from the devbox. @@ -615,64 +492,35 @@ def read( """ return self._devbox._client.devboxes.read_file_contents( self._devbox.id, - file_path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) def write( self, - path: str, - contents: str | bytes, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxWriteFileContentsParams], ) -> DevboxExecutionDetailView: """Write contents to a file in the devbox. Creates or overwrites the file at the specified path. Args: - path: Absolute path to the file in the devbox. - contents: File contents as string or bytes (bytes are decoded as UTF-8). + file_path: Absolute path to the file in the devbox. + contents: File contents as string. Returns: Execution details for the write operation. Example: - >>> devbox.file.write("/home/user/config.json", '{"key": "value"}') + >>> devbox.file.write(file_path="/home/user/config.json", contents='{"key": "value"}') """ - if isinstance(contents, bytes): - contents_str = contents.decode("utf-8") - else: - contents_str = contents - return self._devbox._client.devboxes.write_file_contents( self._devbox.id, - file_path=path, - contents=contents_str, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) def download( self, - path: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxDownloadFileParams], ) -> bytes: """Download a file from the devbox. @@ -689,31 +537,19 @@ def download( """ response = self._devbox._client.devboxes.download_file( self._devbox.id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) return response.read() def upload( self, - path: str, - file: FileTypes, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxUploadFileParams], ) -> object: """Upload a file to the devbox. Args: path: Destination path in the devbox. - file: File to upload (Path, file-like object, or bytes). + file: File to upload (Path-like object or bytes). Returns: Response object confirming the upload. @@ -724,13 +560,7 @@ def upload( """ return self._devbox._client.devboxes.upload_file( self._devbox.id, - path=path, - file=file, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) @@ -745,12 +575,7 @@ def __init__(self, devbox: Devbox) -> None: def create_ssh_key( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> DevboxCreateSSHKeyResponse: """Create an SSH key for remote access to the devbox. @@ -763,22 +588,12 @@ def create_ssh_key( """ return self._devbox._client.devboxes.create_ssh_key( self._devbox.id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **options, ) def create_tunnel( self, - *, - port: int, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxCreateTunnelParams], ) -> DevboxTunnelView: """Create a network tunnel to expose a devbox port publicly. @@ -794,23 +609,12 @@ def create_tunnel( """ return self._devbox._client.devboxes.create_tunnel( self._devbox.id, - port=port, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) def remove_tunnel( self, - *, - port: int, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxRemoveTunnelParams], ) -> object: """Remove a network tunnel, disabling public access to the port. @@ -825,10 +629,5 @@ def remove_tunnel( """ return self._devbox._client.devboxes.remove_tunnel( self._devbox.id, - port=port, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) diff --git a/src/runloop_api_client/sdk/execution.py b/src/runloop_api_client/sdk/execution.py index c5b897355..6b8e0bde6 100644 --- a/src/runloop_api_client/sdk/execution.py +++ b/src/runloop_api_client/sdk/execution.py @@ -5,9 +5,10 @@ import logging import threading from typing import Optional +from typing_extensions import Unpack, override +from ._types import RequestOptions, LongRequestOptions from .._client import Runloop -from ..lib.polling import PollingConfig from .execution_result import ExecutionResult from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView @@ -65,9 +66,13 @@ def __init__( self._client = client self._devbox_id = devbox_id self._execution_id = execution.execution_id - self._latest = execution + self._initial_result = execution self._streaming_group = streaming_group + @override + def __repr__(self) -> str: + return f"" + @property def execution_id(self) -> str: return self._execution_id @@ -76,53 +81,41 @@ def execution_id(self) -> str: def devbox_id(self) -> str: return self._devbox_id - def result(self, *, polling_config: PollingConfig | None = None) -> ExecutionResult: + def result(self, **options: Unpack[LongRequestOptions]) -> ExecutionResult: """ Wait for completion and return an :class:`ExecutionResult`. """ - if self._latest.status == "completed": - final = self._latest - else: - final = self._client.devboxes.executions.await_completed( - self._execution_id, - devbox_id=self._devbox_id, - polling_config=polling_config, - ) + # Wait for command completion + final = self._client.devboxes.wait_for_command( + self._execution_id, + devbox_id=self._devbox_id, + statuses=["completed"], + **options, + ) + # Wait for streaming to complete naturally (log but don't throw streaming errors) if self._streaming_group is not None: - # Block until streaming threads have drained so callers observe all log output - # before we hand back control. _stop_streaming() handles the tidy-up afterward. self._streaming_group.join() + self._streaming_group = None - self._stop_streaming() - - self._latest = final return ExecutionResult(self._client, self._devbox_id, final) - def get_state(self) -> DevboxAsyncExecutionDetailView: + def get_state(self, **options: Unpack[RequestOptions]) -> DevboxAsyncExecutionDetailView: """ Fetch the latest execution state. """ - self._latest = self._client.devboxes.executions.retrieve( + return self._client.devboxes.executions.retrieve( self._execution_id, devbox_id=self._devbox_id, + **options, ) - return self._latest - def kill(self, *, kill_process_group: bool | None = None) -> None: + def kill(self, **options: Unpack[LongRequestOptions]) -> None: """ Request termination of the running execution. """ self._client.devboxes.executions.kill( self._execution_id, devbox_id=self._devbox_id, - kill_process_group=kill_process_group, + **options, ) - self._stop_streaming() - - def _stop_streaming(self) -> None: - if self._streaming_group is None: - return - self._streaming_group.stop() - self._streaming_group.join() - self._streaming_group = None diff --git a/src/runloop_api_client/sdk/execution_result.py b/src/runloop_api_client/sdk/execution_result.py index f0340826a..fb4340d98 100644 --- a/src/runloop_api_client/sdk/execution_result.py +++ b/src/runloop_api_client/sdk/execution_result.py @@ -2,6 +2,9 @@ from __future__ import annotations +from typing import Optional +from typing_extensions import override + from .._client import Runloop from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView @@ -17,11 +20,15 @@ def __init__( self, client: Runloop, devbox_id: str, - execution: DevboxAsyncExecutionDetailView, + result: DevboxAsyncExecutionDetailView, ) -> None: self._client = client self._devbox_id = devbox_id - self._execution = execution + self._result = result + + @override + def __repr__(self) -> str: + return f"" @property def devbox_id(self) -> str: @@ -31,12 +38,12 @@ def devbox_id(self) -> str: @property def execution_id(self) -> str: """Underlying execution identifier.""" - return self._execution.execution_id + return self._result.execution_id @property def exit_code(self) -> int | None: """Process exit code, or ``None`` if unavailable.""" - return self._execution.exit_status + return self._result.exit_status @property def success(self) -> bool: @@ -49,15 +56,21 @@ def failed(self) -> bool: exit_code = self.exit_code return exit_code is not None and exit_code != 0 - def stdout(self) -> str: + # TODO: add pagination support once we have it in the API + def stdout(self, num_lines: Optional[int] = None) -> str: """Return captured standard output.""" - return self._execution.stdout or "" + if not num_lines or num_lines <= 0 or not self._result.stdout: + return "" + return self._result.stdout[-num_lines:] - def stderr(self) -> str: + # TODO: add pagination support once we have it in the API + def stderr(self, num_lines: Optional[int] = None) -> str: """Return captured standard error.""" - return self._execution.stderr or "" + if not num_lines or num_lines <= 0 or not self._result.stderr: + return "" + return self._result.stderr[-num_lines:] @property def raw(self) -> DevboxAsyncExecutionDetailView: """Access the underlying API response.""" - return self._execution + return self._result diff --git a/src/runloop_api_client/sdk/protocols.py b/src/runloop_api_client/sdk/protocols.py index dcb7205c5..f0b29bda1 100644 --- a/src/runloop_api_client/sdk/protocols.py +++ b/src/runloop_api_client/sdk/protocols.py @@ -6,13 +6,22 @@ from __future__ import annotations -from typing import Callable, Optional, Protocol -from typing_extensions import runtime_checkable +from typing import Protocol +from typing_extensions import Unpack, runtime_checkable from ..types import DevboxTunnelView, DevboxExecutionDetailView, DevboxCreateSSHKeyResponse -from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, FileTypes +from ._types import ( + LongRequestOptions, + SDKDevboxExecuteParams, + SDKDevboxUploadFileParams, + SDKDevboxCreateTunnelParams, + SDKDevboxDownloadFileParams, + SDKDevboxExecuteAsyncParams, + SDKDevboxRemoveTunnelParams, + SDKDevboxReadFileContentsParams, + SDKDevboxWriteFileContentsParams, +) from .execution import Execution -from ..lib.polling import PollingConfig from .async_execution import AsyncExecution from .execution_result import ExecutionResult from .async_execution_result import AsyncExecutionResult @@ -31,35 +40,12 @@ class CommandInterface(Protocol): def exec( self, - command: str, - *, - shell_name: Optional[str] | Omit = ..., - stdout: Optional[Callable[[str], None]] = None, - stderr: Optional[Callable[[str], None]] = None, - output: Optional[Callable[[str], None]] = None, - polling_config: PollingConfig | None = None, - attach_stdin: bool | Omit = ..., - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExecuteParams], ) -> "ExecutionResult": ... def exec_async( self, - command: str, - *, - shell_name: Optional[str] | Omit = ..., - stdout: Optional[Callable[[str], None]] = None, - stderr: Optional[Callable[[str], None]] = None, - output: Optional[Callable[[str], None]] = None, - attach_stdin: bool | Omit = ..., - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExecuteAsyncParams], ) -> "Execution": ... @@ -73,48 +59,22 @@ class FileInterface(Protocol): def read( self, - path: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxReadFileContentsParams], ) -> str: ... def write( self, - path: str, - contents: str | bytes, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxWriteFileContentsParams], ) -> DevboxExecutionDetailView: ... def download( self, - path: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxDownloadFileParams], ) -> bytes: ... def upload( self, - path: str, - file: FileTypes, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxUploadFileParams], ) -> object: ... @@ -128,34 +88,17 @@ class NetworkInterface(Protocol): def create_ssh_key( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[LongRequestOptions], ) -> DevboxCreateSSHKeyResponse: ... def create_tunnel( self, - *, - port: int, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxCreateTunnelParams], ) -> DevboxTunnelView: ... def remove_tunnel( self, - *, - port: int, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxRemoveTunnelParams], ) -> object: ... @@ -173,35 +116,12 @@ class AsyncCommandInterface(Protocol): async def exec( self, - command: str, - *, - shell_name: Optional[str] | Omit = ..., - stdout: Optional[Callable[[str], None]] = None, - stderr: Optional[Callable[[str], None]] = None, - output: Optional[Callable[[str], None]] = None, - polling_config: PollingConfig | None = None, - attach_stdin: bool | Omit = ..., - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExecuteParams], ) -> "AsyncExecutionResult": ... async def exec_async( self, - command: str, - *, - shell_name: Optional[str] | Omit = ..., - stdout: Optional[Callable[[str], None]] = None, - stderr: Optional[Callable[[str], None]] = None, - output: Optional[Callable[[str], None]] = None, - attach_stdin: bool | Omit = ..., - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExecuteAsyncParams], ) -> "AsyncExecution": ... @@ -215,48 +135,22 @@ class AsyncFileInterface(Protocol): async def read( self, - path: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxReadFileContentsParams], ) -> str: ... async def write( self, - path: str, - contents: str | bytes, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxWriteFileContentsParams], ) -> DevboxExecutionDetailView: ... async def download( self, - path: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxDownloadFileParams], ) -> bytes: ... async def upload( self, - path: str, - file: FileTypes, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxUploadFileParams], ) -> object: ... @@ -270,32 +164,15 @@ class AsyncNetworkInterface(Protocol): async def create_ssh_key( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[LongRequestOptions], ) -> DevboxCreateSSHKeyResponse: ... async def create_tunnel( self, - *, - port: int, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxCreateTunnelParams], ) -> DevboxTunnelView: ... async def remove_tunnel( self, - *, - port: int, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = ..., - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxRemoveTunnelParams], ) -> object: ... diff --git a/src/runloop_api_client/sdk/snapshot.py b/src/runloop_api_client/sdk/snapshot.py index 705191eb1..81ae563a7 100644 --- a/src/runloop_api_client/sdk/snapshot.py +++ b/src/runloop_api_client/sdk/snapshot.py @@ -2,18 +2,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, Iterable, Optional -from typing_extensions import override +from typing_extensions import Unpack, override -if TYPE_CHECKING: - from .devbox import Devbox -from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given +from ._types import ( + RequestOptions, + LongRequestOptions, + PollingRequestOptions, + SDKDevboxExtraCreateParams, + SDKDiskSnapshotUpdateParams, +) +from .devbox import Devbox from .._client import Runloop -from ..lib.polling import PollingConfig -from ..types.shared_params.mount import Mount from ..types.devbox_snapshot_view import DevboxSnapshotView -from ..types.shared_params.launch_parameters import LaunchParameters -from ..types.shared_params.code_mount_parameters import CodeMountParameters from ..types.devboxes.devbox_snapshot_async_status_view import DevboxSnapshotAsyncStatusView @@ -40,119 +40,46 @@ def id(self) -> str: def get_info( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[RequestOptions], ) -> DevboxSnapshotAsyncStatusView: return self._client.devboxes.disk_snapshots.query_status( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) def update( self, - *, - commit_message: Optional[str] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - name: Optional[str] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDiskSnapshotUpdateParams], ) -> DevboxSnapshotView: return self._client.devboxes.disk_snapshots.update( self._id, - commit_message=commit_message, - metadata=metadata, - name=name, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) def delete( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> object: return self._client.devboxes.disk_snapshots.delete( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **options, ) def await_completed( self, - *, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[PollingRequestOptions], ) -> DevboxSnapshotAsyncStatusView: return self._client.devboxes.disk_snapshots.await_completed( self._id, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) def create_devbox( self, - *, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - entrypoint: Optional[str] | Omit = omit, - environment_variables: Optional[Dict[str, str]] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - mounts: Optional[Iterable[Mount]] | Omit = omit, - name: Optional[str] | Omit = omit, - repo_connection_id: Optional[str] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExtraCreateParams], ) -> "Devbox": - from .sync import DevboxClient - - devbox_client = DevboxClient(self._client) - return devbox_client.create_from_snapshot( - self._id, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - mounts=mounts, - name=name, - repo_connection_id=repo_connection_id, - secrets=secrets, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + devbox_view = self._client.devboxes.create_and_await_running( + snapshot_id=self._id, + **params, ) + return Devbox(self._client, devbox_view.id) diff --git a/src/runloop_api_client/sdk/storage_object.py b/src/runloop_api_client/sdk/storage_object.py index 0941d0893..c4f3f954f 100644 --- a/src/runloop_api_client/sdk/storage_object.py +++ b/src/runloop_api_client/sdk/storage_object.py @@ -2,13 +2,10 @@ from __future__ import annotations -from typing_extensions import override +from typing_extensions import Unpack, override -import httpx - -from .._types import Body, Query, Headers, Timeout, NotGiven, not_given +from ._types import RequestOptions, LongRequestOptions, SDKObjectDownloadParams from .._client import Runloop -from ._helpers import UploadData, read_upload_data from ..types.object_view import ObjectView from ..types.object_download_url_view import ObjectDownloadURLView @@ -37,130 +34,68 @@ def upload_url(self) -> str | None: def refresh( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **options: Unpack[RequestOptions], ) -> ObjectView: return self._client.objects.retrieve( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **options, ) def complete( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> ObjectView: result = self._client.objects.complete( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **options, ) self._upload_url = None return result def get_download_url( self, - *, - duration_seconds: int | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[SDKObjectDownloadParams], ) -> ObjectDownloadURLView: - if duration_seconds is None: - return self._client.objects.download( - self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) return self._client.objects.download( self._id, - duration_seconds=duration_seconds, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **params, ) def download_as_bytes( self, - *, - duration_seconds: int | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[SDKObjectDownloadParams], ) -> bytes: url_view = self.get_download_url( - duration_seconds=duration_seconds, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **params, ) - response = httpx.get(url_view.download_url) + response = self._client._client.get(url_view.download_url) response.raise_for_status() return response.content def download_as_text( self, - *, - duration_seconds: int | None = None, - encoding: str = "utf-8", - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, + **params: Unpack[SDKObjectDownloadParams], ) -> str: url_view = self.get_download_url( - duration_seconds=duration_seconds, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, + **params, ) - response = httpx.get(url_view.download_url) + response = self._client._client.get(url_view.download_url) response.raise_for_status() - response.encoding = encoding + response.encoding = "utf-8" return response.text def delete( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **options: Unpack[LongRequestOptions], ) -> ObjectView: return self._client.objects.delete( self._id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **options, ) - def upload_content(self, data: UploadData) -> None: + def upload_content(self, content: str | bytes) -> None: url = self._ensure_upload_url() - payload = read_upload_data(data) - response = httpx.put(url, content=payload) + response = self._client._client.put(url, content=content) response.raise_for_status() def _ensure_upload_url(self) -> str: diff --git a/src/runloop_api_client/sdk/sync.py b/src/runloop_api_client/sdk/sync.py index 80a12f9a8..25e3b3ef2 100644 --- a/src/runloop_api_client/sdk/sync.py +++ b/src/runloop_api_client/sdk/sync.py @@ -2,29 +2,31 @@ from __future__ import annotations -from typing import Dict, Mapping, Iterable, Optional +from typing import Dict, Mapping, Optional from pathlib import Path from typing_extensions import Unpack import httpx +from ._types import ( + LongRequestOptions, + SDKDevboxListParams, + SDKObjectListParams, + SDKDevboxCreateParams, + SDKObjectCreateParams, + SDKBlueprintListParams, + SDKBlueprintCreateParams, + SDKDiskSnapshotListParams, + SDKDevboxExtraCreateParams, +) from .devbox import Devbox -from .._types import Body, Omit, Query, Headers, Timeout, NotGiven, omit, not_given -from .._client import Runloop, DEFAULT_MAX_RETRIES -from ._helpers import ContentType, detect_content_type +from .._types import Timeout, NotGiven, not_given +from .._client import DEFAULT_MAX_RETRIES, Runloop +from ._helpers import detect_content_type from .snapshot import Snapshot from .blueprint import Blueprint -from ..lib.polling import PollingConfig from .storage_object import StorageObject -from ..types.devbox_list_params import DevboxListParams -from ..types.object_list_params import ObjectListParams -from ..types.shared_params.mount import Mount -from ..types.devbox_create_params import DevboxCreateParams -from ..types.blueprint_list_params import BlueprintListParams -from ..types.blueprint_create_params import BlueprintCreateParams -from ..types.shared_params.launch_parameters import LaunchParameters -from ..types.devboxes.disk_snapshot_list_params import DiskSnapshotListParams -from ..types.shared_params.code_mount_parameters import CodeMountParameters +from ..types.object_create_params import ContentType class DevboxClient: @@ -44,22 +46,9 @@ def __init__(self, client: Runloop) -> None: def create( self, - *, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, - **params: Unpack[DevboxCreateParams], + **params: Unpack[SDKDevboxCreateParams], ) -> Devbox: devbox_view = self._client.devboxes.create_and_await_running( - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, **params, ) return Devbox(self._client, devbox_view.id) @@ -67,126 +56,33 @@ def create( def create_from_blueprint_id( self, blueprint_id: str, - *, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - entrypoint: Optional[str] | Omit = omit, - environment_variables: Optional[Dict[str, str]] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - mounts: Optional[Iterable[Mount]] | Omit = omit, - name: Optional[str] | Omit = omit, - repo_connection_id: Optional[str] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExtraCreateParams], ) -> Devbox: devbox_view = self._client.devboxes.create_and_await_running( blueprint_id=blueprint_id, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - mounts=mounts, - name=name, - repo_connection_id=repo_connection_id, - secrets=secrets, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) return Devbox(self._client, devbox_view.id) def create_from_blueprint_name( self, blueprint_name: str, - *, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - entrypoint: Optional[str] | Omit = omit, - environment_variables: Optional[Dict[str, str]] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - mounts: Optional[Iterable[Mount]] | Omit = omit, - name: Optional[str] | Omit = omit, - repo_connection_id: Optional[str] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExtraCreateParams], ) -> Devbox: devbox_view = self._client.devboxes.create_and_await_running( blueprint_name=blueprint_name, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - mounts=mounts, - name=name, - repo_connection_id=repo_connection_id, - secrets=secrets, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) return Devbox(self._client, devbox_view.id) def create_from_snapshot( self, snapshot_id: str, - *, - code_mounts: Optional[Iterable[CodeMountParameters]] | Omit = omit, - entrypoint: Optional[str] | Omit = omit, - environment_variables: Optional[Dict[str, str]] | Omit = omit, - file_mounts: Optional[Dict[str, str]] | Omit = omit, - launch_parameters: Optional[LaunchParameters] | Omit = omit, - metadata: Optional[Dict[str, str]] | Omit = omit, - mounts: Optional[Iterable[Mount]] | Omit = omit, - name: Optional[str] | Omit = omit, - repo_connection_id: Optional[str] | Omit = omit, - secrets: Optional[Dict[str, str]] | Omit = omit, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, + **params: Unpack[SDKDevboxExtraCreateParams], ) -> Devbox: devbox_view = self._client.devboxes.create_and_await_running( snapshot_id=snapshot_id, - code_mounts=code_mounts, - entrypoint=entrypoint, - environment_variables=environment_variables, - file_mounts=file_mounts, - launch_parameters=launch_parameters, - metadata=metadata, - mounts=mounts, - name=name, - repo_connection_id=repo_connection_id, - secrets=secrets, - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, + **params, ) return Devbox(self._client, devbox_view.id) @@ -196,18 +92,9 @@ def from_id(self, devbox_id: str) -> Devbox: def list( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - **params: Unpack[DevboxListParams], + **params: Unpack[SDKDevboxListParams], ) -> list[Devbox]: page = self._client.devboxes.list( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, **params, ) return [Devbox(self._client, item.id) for item in page.devboxes] @@ -230,18 +117,9 @@ def __init__(self, client: Runloop) -> None: def list( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - **params: Unpack[DiskSnapshotListParams], + **params: Unpack[SDKDiskSnapshotListParams], ) -> list[Snapshot]: page = self._client.devboxes.disk_snapshots.list( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, **params, ) return [Snapshot(self._client, item.id) for item in page.snapshots] @@ -267,22 +145,9 @@ def __init__(self, client: Runloop) -> None: def create( self, - *, - polling_config: PollingConfig | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - idempotency_key: str | None = None, - **params: Unpack[BlueprintCreateParams], + **params: Unpack[SDKBlueprintCreateParams], ) -> Blueprint: blueprint = self._client.blueprints.create_and_await_build_complete( - polling_config=polling_config, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - idempotency_key=idempotency_key, **params, ) return Blueprint(self._client, blueprint.id) @@ -292,18 +157,9 @@ def from_id(self, blueprint_id: str) -> Blueprint: def list( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - **params: Unpack[BlueprintListParams], + **params: Unpack[SDKBlueprintListParams], ) -> list[Blueprint]: page = self._client.blueprints.list( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, **params, ) return [Blueprint(self._client, item.id) for item in page.blueprints] @@ -327,13 +183,9 @@ def __init__(self, client: Runloop) -> None: def create( self, - name: str, - *, - content_type: ContentType | None = None, - metadata: Optional[Dict[str, str]] = None, + **params: Unpack[SDKObjectCreateParams], ) -> StorageObject: - content_type = content_type or detect_content_type(name) - obj = self._client.objects.create(name=name, content_type=content_type, metadata=metadata) + obj = self._client.objects.create(**params) return StorageObject(self._client, obj.id, upload_url=obj.upload_url) def from_id(self, object_id: str) -> StorageObject: @@ -341,34 +193,33 @@ def from_id(self, object_id: str) -> StorageObject: def list( self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - **params: Unpack[ObjectListParams], + **params: Unpack[SDKObjectListParams], ) -> list[StorageObject]: page = self._client.objects.list( - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, **params, ) return [StorageObject(self._client, item.id, upload_url=item.upload_url) for item in page.objects] def upload_from_file( self, - path: str | Path, + file_path: str | Path, name: str | None = None, *, - metadata: Optional[Dict[str, str]] = None, content_type: ContentType | None = None, + metadata: Optional[Dict[str, str]] = None, + **options: Unpack[LongRequestOptions], ) -> StorageObject: - file_path = Path(path) - object_name = name or file_path.name - obj = self.create(object_name, content_type=content_type, metadata=metadata) - obj.upload_content(file_path) + path = Path(file_path) + + try: + content = path.read_bytes() + except OSError as error: + raise OSError(f"Failed to read file {path}: {error}") from error + + name = name or path.name + content_type = content_type or detect_content_type(str(file_path)) + obj = self.create(name=name, content_type=content_type, metadata=metadata, **options) + obj.upload_content(content) obj.complete() return obj @@ -378,8 +229,9 @@ def upload_from_text( name: str, *, metadata: Optional[Dict[str, str]] = None, + **options: Unpack[LongRequestOptions], ) -> StorageObject: - obj = self.create(name, content_type="text", metadata=metadata) + obj = self.create(name=name, content_type="text", metadata=metadata, **options) obj.upload_content(text) obj.complete() return obj @@ -389,10 +241,11 @@ def upload_from_bytes( data: bytes, name: str, *, + content_type: ContentType, metadata: Optional[Dict[str, str]] = None, - content_type: ContentType | None = None, + **options: Unpack[LongRequestOptions], ) -> StorageObject: - obj = self.create(name, content_type=content_type or detect_content_type(name), metadata=metadata) + obj = self.create(name=name, content_type=content_type, metadata=metadata, **options) obj.upload_content(data) obj.complete() return obj diff --git a/src/runloop_api_client/types/devbox_create_params.py b/src/runloop_api_client/types/devbox_create_params.py index c93dcca81..6068e4d61 100644 --- a/src/runloop_api_client/types/devbox_create_params.py +++ b/src/runloop_api_client/types/devbox_create_params.py @@ -12,22 +12,7 @@ __all__ = ["DevboxCreateParams"] -class DevboxCreateParams(TypedDict, total=False): - blueprint_id: Optional[str] - """Blueprint ID to use for the Devbox. - - If none set, the Devbox will be created with the default Runloop Devbox image. - Only one of (Snapshot ID, Blueprint ID, Blueprint name) should be specified. - """ - - blueprint_name: Optional[str] - """Name of Blueprint to use for the Devbox. - - When set, this will load the latest successfully built Blueprint with the given - name. Only one of (Snapshot ID, Blueprint ID, Blueprint name) should be - specified. - """ - +class DevboxBaseCreateParams(TypedDict, total=False): code_mounts: Optional[Iterable[CodeMountParameters]] """A list of code mounts to be included in the Devbox.""" @@ -67,6 +52,23 @@ class DevboxCreateParams(TypedDict, total=False): 'DB_PASS' to the value of secret 'DATABASE_PASSWORD'. """ + +class DevboxCreateParams(DevboxBaseCreateParams, total=False): + blueprint_id: Optional[str] + """Blueprint ID to use for the Devbox. + + If none set, the Devbox will be created with the default Runloop Devbox image. + Only one of (Snapshot ID, Blueprint ID, Blueprint name) should be specified. + """ + + blueprint_name: Optional[str] + """Name of Blueprint to use for the Devbox. + + When set, this will load the latest successfully built Blueprint with the given + name. Only one of (Snapshot ID, Blueprint ID, Blueprint name) should be + specified. + """ + snapshot_id: Optional[str] """Snapshot ID to use for the Devbox. diff --git a/src/runloop_api_client/types/object_create_params.py b/src/runloop_api_client/types/object_create_params.py index fe554c188..99b77bcd0 100644 --- a/src/runloop_api_client/types/object_create_params.py +++ b/src/runloop_api_client/types/object_create_params.py @@ -2,14 +2,17 @@ from __future__ import annotations -from typing import Dict, Optional -from typing_extensions import Literal, Required, TypedDict +from typing import Dict, Literal, Optional +from typing_extensions import Required, TypedDict __all__ = ["ObjectCreateParams"] +ContentType = Literal["unspecified", "text", "binary", "gzip", "tar", "tgz"] + + class ObjectCreateParams(TypedDict, total=False): - content_type: Required[Literal["unspecified", "text", "binary", "gzip", "tar", "tgz"]] + content_type: Required[ContentType] """The content type of the Object.""" name: Required[str] From 463e876468028063c4309815d77edb8ad5c7e830 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 17 Nov 2025 09:49:37 -0800 Subject: [PATCH 39/56] fixed execution result stdout/stderr --- .../sdk/async_execution_result.py | 26 ++++++++++++++----- .../sdk/execution_result.py | 26 ++++++++++++++----- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/runloop_api_client/sdk/async_execution_result.py b/src/runloop_api_client/sdk/async_execution_result.py index eadb3b676..75bffe3ec 100644 --- a/src/runloop_api_client/sdk/async_execution_result.py +++ b/src/runloop_api_client/sdk/async_execution_result.py @@ -50,16 +50,30 @@ def failed(self) -> bool: # TODO: add pagination support once we have it in the API async def stdout(self, num_lines: Optional[int] = None) -> str: - if not num_lines or num_lines <= 0 or not self._result.stdout: - return "" - return self._result.stdout[-num_lines:] + text = self._result.stdout or "" + return _tail_lines(text, num_lines) # TODO: add pagination support once we have it in the API async def stderr(self, num_lines: Optional[int] = None) -> str: - if not num_lines or num_lines <= 0 or not self._result.stderr: - return "" - return self._result.stderr[-num_lines:] + text = self._result.stderr or "" + return _tail_lines(text, num_lines) @property def raw(self) -> DevboxAsyncExecutionDetailView: return self._result + + +def _tail_lines(text: str, num_lines: Optional[int]) -> str: + if not text: + return "" + if num_lines is None or num_lines <= 0: + return text + + lines = text.splitlines() + if not lines: + return text + + clipped = "\n".join(lines[-num_lines:]) + if text.endswith("\n"): + clipped += "\n" + return clipped diff --git a/src/runloop_api_client/sdk/execution_result.py b/src/runloop_api_client/sdk/execution_result.py index fb4340d98..a7dc6547b 100644 --- a/src/runloop_api_client/sdk/execution_result.py +++ b/src/runloop_api_client/sdk/execution_result.py @@ -59,18 +59,32 @@ def failed(self) -> bool: # TODO: add pagination support once we have it in the API def stdout(self, num_lines: Optional[int] = None) -> str: """Return captured standard output.""" - if not num_lines or num_lines <= 0 or not self._result.stdout: - return "" - return self._result.stdout[-num_lines:] + text = self._result.stdout or "" + return _tail_lines(text, num_lines) # TODO: add pagination support once we have it in the API def stderr(self, num_lines: Optional[int] = None) -> str: """Return captured standard error.""" - if not num_lines or num_lines <= 0 or not self._result.stderr: - return "" - return self._result.stderr[-num_lines:] + text = self._result.stderr or "" + return _tail_lines(text, num_lines) @property def raw(self) -> DevboxAsyncExecutionDetailView: """Access the underlying API response.""" return self._result + + +def _tail_lines(text: str, num_lines: Optional[int]) -> str: + if not text: + return "" + if num_lines is None or num_lines <= 0: + return text + + lines = text.splitlines() + if not lines: + return text + + clipped = "\n".join(lines[-num_lines:]) + if text.endswith("\n"): + clipped += "\n" + return clipped From 5b2eadf5ccea97293cea619ac02bad0adc29ffa2 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 17 Nov 2025 09:50:23 -0800 Subject: [PATCH 40/56] unit tests --- tests/sdk/async_devbox/test_core.py | 38 ++++--- tests/sdk/async_devbox/test_interfaces.py | 29 ++--- tests/sdk/devbox/test_core.py | 18 +-- tests/sdk/devbox/test_edge_cases.py | 16 +-- tests/sdk/devbox/test_interfaces.py | 46 ++++---- tests/sdk/test_async_clients.py | 101 ++++++++++------- tests/sdk/test_async_execution.py | 132 +++++++++------------- tests/sdk/test_async_execution_result.py | 2 + tests/sdk/test_async_storage_object.py | 102 ++++------------- tests/sdk/test_clients.py | 93 ++++++++------- tests/sdk/test_execution.py | 105 ++++++++--------- tests/sdk/test_execution_result.py | 2 + tests/sdk/test_helpers.py | 30 +++++ tests/sdk/test_storage_object.py | 128 ++++++++------------- 14 files changed, 408 insertions(+), 434 deletions(-) create mode 100644 tests/sdk/test_helpers.py diff --git a/tests/sdk/async_devbox/test_core.py b/tests/sdk/async_devbox/test_core.py index 46c47f8ee..5af02a3e3 100644 --- a/tests/sdk/async_devbox/test_core.py +++ b/tests/sdk/async_devbox/test_core.py @@ -13,7 +13,6 @@ from tests.sdk.conftest import MockDevboxView from runloop_api_client.sdk import AsyncDevbox -from runloop_api_client._types import NotGiven from runloop_api_client.lib.polling import PollingConfig from runloop_api_client.sdk.async_devbox import ( _AsyncFileInterface, @@ -44,7 +43,7 @@ async def test_context_manager_enter_exit(self, mock_async_client: AsyncMock, de assert devbox.id == "dev_123" call_kwargs = mock_async_client.devboxes.shutdown.call_args[1] - assert isinstance(call_kwargs["timeout"], NotGiven) + assert "timeout" not in call_kwargs @pytest.mark.asyncio async def test_context_manager_exception_handling(self, mock_async_client: AsyncMock) -> None: @@ -137,8 +136,7 @@ async def test_shutdown(self, mock_async_client: AsyncMock, devbox_view: MockDev @pytest.mark.asyncio async def test_suspend(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: """Test suspend method.""" - mock_async_client.devboxes.suspend = AsyncMock(return_value=None) - mock_async_client.devboxes.await_suspended = AsyncMock(return_value=devbox_view) + mock_async_client.devboxes.suspend = AsyncMock(return_value=devbox_view) polling_config = PollingConfig(timeout_seconds=60.0) devbox = AsyncDevbox(mock_async_client, "dev_123") @@ -154,22 +152,18 @@ async def test_suspend(self, mock_async_client: AsyncMock, devbox_view: MockDevb assert result == devbox_view mock_async_client.devboxes.suspend.assert_called_once_with( "dev_123", + polling_config=polling_config, extra_headers={"X-Custom": "value"}, extra_query={"param": "value"}, extra_body={"key": "value"}, timeout=30.0, idempotency_key="key-123", ) - mock_async_client.devboxes.await_suspended.assert_called_once_with( - "dev_123", - polling_config=polling_config, - ) @pytest.mark.asyncio async def test_resume(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: """Test resume method.""" - mock_async_client.devboxes.resume = AsyncMock(return_value=None) - mock_async_client.devboxes.await_running = AsyncMock(return_value=devbox_view) + mock_async_client.devboxes.resume = AsyncMock(return_value=devbox_view) polling_config = PollingConfig(timeout_seconds=60.0) devbox = AsyncDevbox(mock_async_client, "dev_123") @@ -185,16 +179,13 @@ async def test_resume(self, mock_async_client: AsyncMock, devbox_view: MockDevbo assert result == devbox_view mock_async_client.devboxes.resume.assert_called_once_with( "dev_123", + polling_config=polling_config, extra_headers={"X-Custom": "value"}, extra_query={"param": "value"}, extra_body={"key": "value"}, timeout=30.0, idempotency_key="key-123", ) - mock_async_client.devboxes.await_running.assert_called_once_with( - "dev_123", - polling_config=polling_config, - ) @pytest.mark.asyncio async def test_keep_alive(self, mock_async_client: AsyncMock) -> None: @@ -240,7 +231,17 @@ async def test_snapshot_disk(self, mock_async_client: AsyncMock) -> None: assert snapshot.id == "snap_123" mock_async_client.devboxes.snapshot_disk_async.assert_called_once() + call_kwargs = mock_async_client.devboxes.snapshot_disk_async.call_args[1] + assert "commit_message" not in call_kwargs + assert call_kwargs["metadata"] == {"key": "value"} + assert call_kwargs["name"] == "test-snapshot" + assert call_kwargs["extra_headers"] == {"X-Custom": "value"} + assert "polling_config" not in call_kwargs + assert "timeout" not in call_kwargs mock_async_client.devboxes.disk_snapshots.await_completed.assert_called_once() + call_kwargs2 = mock_async_client.devboxes.disk_snapshots.await_completed.call_args[1] + assert call_kwargs2["polling_config"] == polling_config + assert "timeout" not in call_kwargs2 @pytest.mark.asyncio async def test_snapshot_disk_async(self, mock_async_client: AsyncMock) -> None: @@ -257,6 +258,13 @@ async def test_snapshot_disk_async(self, mock_async_client: AsyncMock) -> None: assert snapshot.id == "snap_123" mock_async_client.devboxes.snapshot_disk_async.assert_called_once() + call_kwargs = mock_async_client.devboxes.snapshot_disk_async.call_args[1] + assert "commit_message" not in call_kwargs + assert call_kwargs["metadata"] == {"key": "value"} + assert call_kwargs["name"] == "test-snapshot" + assert call_kwargs["extra_headers"] == {"X-Custom": "value"} + assert "polling_config" not in call_kwargs + assert "timeout" not in call_kwargs @pytest.mark.asyncio async def test_close(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: @@ -267,6 +275,8 @@ async def test_close(self, mock_async_client: AsyncMock, devbox_view: MockDevbox await devbox.close() mock_async_client.devboxes.shutdown.assert_called_once() + call_kwargs = mock_async_client.devboxes.shutdown.call_args[1] + assert "timeout" not in call_kwargs def test_cmd_property(self, mock_async_client: AsyncMock) -> None: """Test cmd property returns AsyncCommandInterface.""" diff --git a/tests/sdk/async_devbox/test_interfaces.py b/tests/sdk/async_devbox/test_interfaces.py index 32154657d..672638fcf 100644 --- a/tests/sdk/async_devbox/test_interfaces.py +++ b/tests/sdk/async_devbox/test_interfaces.py @@ -14,7 +14,6 @@ from tests.sdk.conftest import MockExecutionView from runloop_api_client.sdk import AsyncDevbox -from runloop_api_client._types import Omit, NotGiven class TestAsyncCommandInterface: @@ -25,17 +24,19 @@ async def test_exec_without_callbacks( self, mock_async_client: AsyncMock, execution_view: MockExecutionView ) -> None: """Test exec without streaming callbacks.""" - mock_async_client.devboxes.execute_and_await_completion = AsyncMock(return_value=execution_view) + mock_async_client.devboxes.execute_async = AsyncMock(return_value=execution_view) + mock_async_client.devboxes.executions.await_completed = AsyncMock(return_value=execution_view) devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.cmd.exec("echo hello") + result = await devbox.cmd.exec(command="echo hello") assert result.exit_code == 0 - assert await result.stdout() == "output" - call_kwargs = mock_async_client.devboxes.execute_and_await_completion.call_args[1] + assert await result.stdout(num_lines=10) == "output" + call_kwargs = mock_async_client.devboxes.execute_async.call_args[1] assert call_kwargs["command"] == "echo hello" - assert isinstance(call_kwargs["shell_name"], Omit) - assert isinstance(call_kwargs["timeout"], NotGiven) + assert "polling_config" not in call_kwargs + assert "timeout" not in call_kwargs + mock_async_client.devboxes.executions.await_completed.assert_not_called() @pytest.mark.asyncio async def test_exec_with_stdout_callback(self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock) -> None: @@ -61,7 +62,7 @@ async def test_exec_with_stdout_callback(self, mock_async_client: AsyncMock, moc stdout_calls: list[str] = [] devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.cmd.exec("echo hello", stdout=stdout_calls.append) + result = await devbox.cmd.exec(command="echo hello", stdout=stdout_calls.append) assert result.exit_code == 0 mock_async_client.devboxes.execute_async.assert_called_once() @@ -81,7 +82,7 @@ async def test_exec_async_returns_execution( mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) devbox = AsyncDevbox(mock_async_client, "dev_123") - execution = await devbox.cmd.exec_async("long-running command") + execution = await devbox.cmd.exec_async(command="long-running command") assert execution.execution_id == "exec_123" assert execution.devbox_id == "dev_123" @@ -97,7 +98,7 @@ async def test_read(self, mock_async_client: AsyncMock) -> None: mock_async_client.devboxes.read_file_contents = AsyncMock(return_value="file content") devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.file.read("/path/to/file") + result = await devbox.file.read(file_path="/path/to/file") assert result == "file content" mock_async_client.devboxes.read_file_contents.assert_called_once() @@ -109,7 +110,7 @@ async def test_write_string(self, mock_async_client: AsyncMock) -> None: mock_async_client.devboxes.write_file_contents = AsyncMock(return_value=execution_detail) devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.file.write("/path/to/file", "content") + result = await devbox.file.write(file_path="/path/to/file", contents="content") assert result == execution_detail mock_async_client.devboxes.write_file_contents.assert_called_once() @@ -121,7 +122,7 @@ async def test_write_bytes(self, mock_async_client: AsyncMock) -> None: mock_async_client.devboxes.write_file_contents = AsyncMock(return_value=execution_detail) devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.file.write("/path/to/file", b"content") + result = await devbox.file.write(file_path="/path/to/file", contents="content") assert result == execution_detail mock_async_client.devboxes.write_file_contents.assert_called_once() @@ -134,7 +135,7 @@ async def test_download(self, mock_async_client: AsyncMock) -> None: mock_async_client.devboxes.download_file = AsyncMock(return_value=mock_response) devbox = AsyncDevbox(mock_async_client, "dev_123") - result = await devbox.file.download("/path/to/file") + result = await devbox.file.download(path="/path/to/file") assert result == b"file content" mock_async_client.devboxes.download_file.assert_called_once() @@ -150,7 +151,7 @@ async def test_upload(self, mock_async_client: AsyncMock, tmp_path: Path) -> Non temp_file = tmp_path / "test_file.txt" temp_file.write_text("test content") - result = await devbox.file.upload("/remote/path", temp_file) + result = await devbox.file.upload(path="/remote/path", file=temp_file) assert result == execution_detail mock_async_client.devboxes.upload_file.assert_called_once() diff --git a/tests/sdk/devbox/test_core.py b/tests/sdk/devbox/test_core.py index 86254f834..4bebd823c 100644 --- a/tests/sdk/devbox/test_core.py +++ b/tests/sdk/devbox/test_core.py @@ -15,7 +15,7 @@ MockDevboxView, ) from runloop_api_client.sdk import Devbox -from runloop_api_client._types import NotGiven, omit +from runloop_api_client._types import omit from runloop_api_client.sdk.devbox import ( _FileInterface, _CommandInterface, @@ -45,7 +45,7 @@ def test_context_manager_enter_exit(self, mock_client: Mock, devbox_view: MockDe assert devbox.id == "dev_123" call_kwargs = mock_client.devboxes.shutdown.call_args[1] - assert isinstance(call_kwargs["timeout"], NotGiven) + assert "timeout" not in call_kwargs def test_context_manager_exception_handling(self, mock_client: Mock) -> None: """Test context manager handles exceptions during shutdown.""" @@ -232,14 +232,15 @@ def test_snapshot_disk(self, mock_client: Mock) -> None: assert snapshot.id == "snap_123" call_kwargs = mock_client.devboxes.snapshot_disk_async.call_args[1] - assert call_kwargs["commit_message"] is omit or call_kwargs["commit_message"] is None + assert "commit_message" not in call_kwargs or call_kwargs["commit_message"] in (omit, None) assert call_kwargs["metadata"] == {"key": "value"} assert call_kwargs["name"] == "test-snapshot" assert call_kwargs["extra_headers"] == {"X-Custom": "value"} - assert isinstance(call_kwargs["timeout"], NotGiven) + assert "polling_config" not in call_kwargs + assert "timeout" not in call_kwargs call_kwargs2 = mock_client.devboxes.disk_snapshots.await_completed.call_args[1] assert call_kwargs2["polling_config"] == polling_config - assert isinstance(call_kwargs2["timeout"], NotGiven) + assert "timeout" not in call_kwargs2 def test_snapshot_disk_async(self, mock_client: Mock) -> None: """Test snapshot_disk_async returns immediately.""" @@ -255,11 +256,12 @@ def test_snapshot_disk_async(self, mock_client: Mock) -> None: assert snapshot.id == "snap_123" call_kwargs = mock_client.devboxes.snapshot_disk_async.call_args[1] - assert call_kwargs["commit_message"] is omit or call_kwargs["commit_message"] is None + assert "commit_message" not in call_kwargs or call_kwargs["commit_message"] in (omit, None) assert call_kwargs["metadata"] == {"key": "value"} assert call_kwargs["name"] == "test-snapshot" assert call_kwargs["extra_headers"] == {"X-Custom": "value"} - assert isinstance(call_kwargs["timeout"], NotGiven) + assert "polling_config" not in call_kwargs + assert "timeout" not in call_kwargs # Verify async method does not wait for completion if hasattr(mock_client.devboxes.disk_snapshots, "await_completed"): assert not mock_client.devboxes.disk_snapshots.await_completed.called @@ -272,7 +274,7 @@ def test_close(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: devbox.close() call_kwargs = mock_client.devboxes.shutdown.call_args[1] - assert isinstance(call_kwargs["timeout"], NotGiven) + assert "timeout" not in call_kwargs def test_cmd_property(self, mock_client: Mock) -> None: """Test cmd property returns CommandInterface.""" diff --git a/tests/sdk/devbox/test_edge_cases.py b/tests/sdk/devbox/test_edge_cases.py index 0f54a30d8..ff2491f66 100644 --- a/tests/sdk/devbox/test_edge_cases.py +++ b/tests/sdk/devbox/test_edge_cases.py @@ -9,7 +9,7 @@ import threading from types import SimpleNamespace from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import Mock import httpx import pytest @@ -136,11 +136,13 @@ def test_path_handling(self, mock_client: Mock, tmp_path: Path) -> None: temp_file = tmp_path / "test_file.txt" temp_file.write_text("test") - with patch("httpx.put") as mock_put: - mock_response = create_mock_httpx_response() - mock_put.return_value = mock_response + http_client = Mock() + mock_response = create_mock_httpx_response() + http_client.put.return_value = mock_response + mock_client._client = http_client - obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") - obj.upload_content(temp_file) # Path object works + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + obj.upload_content(temp_file.read_text()) + obj.upload_content(temp_file.read_bytes()) - mock_put.assert_called_once() + assert http_client.put.call_count == 2 diff --git a/tests/sdk/devbox/test_interfaces.py b/tests/sdk/devbox/test_interfaces.py index 06bb32ee4..f1c3bc59c 100644 --- a/tests/sdk/devbox/test_interfaces.py +++ b/tests/sdk/devbox/test_interfaces.py @@ -14,7 +14,6 @@ from tests.sdk.conftest import MockExecutionView from runloop_api_client.sdk import Devbox -from runloop_api_client._types import Omit, NotGiven class TestCommandInterface: @@ -22,18 +21,19 @@ class TestCommandInterface: def test_exec_without_callbacks(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test exec without streaming callbacks.""" - mock_client.devboxes.execute_and_await_completion.return_value = execution_view + mock_client.devboxes.execute_async.return_value = execution_view + mock_client.devboxes.executions.await_completed.return_value = execution_view devbox = Devbox(mock_client, "dev_123") - result = devbox.cmd.exec("echo hello") + result = devbox.cmd.exec(command="echo hello") assert result.exit_code == 0 - assert result.stdout() == "output" - call_kwargs = mock_client.devboxes.execute_and_await_completion.call_args[1] + assert result.stdout(num_lines=10) == "output" + call_kwargs = mock_client.devboxes.execute_async.call_args[1] assert call_kwargs["command"] == "echo hello" - assert isinstance(call_kwargs["shell_name"], Omit) - assert call_kwargs["polling_config"] is None - assert isinstance(call_kwargs["timeout"], NotGiven) + assert "polling_config" not in call_kwargs + assert "timeout" not in call_kwargs + mock_client.devboxes.executions.await_completed.assert_not_called() def test_exec_with_stdout_callback(self, mock_client: Mock, mock_stream: Mock) -> None: """Test exec with stdout callback.""" @@ -58,7 +58,7 @@ def test_exec_with_stdout_callback(self, mock_client: Mock, mock_stream: Mock) - stdout_calls: list[str] = [] devbox = Devbox(mock_client, "dev_123") - result = devbox.cmd.exec("echo hello", stdout=stdout_calls.append) + result = devbox.cmd.exec(command="echo hello", stdout=stdout_calls.append) assert result.exit_code == 0 mock_client.devboxes.execute_async.assert_called_once() @@ -87,7 +87,7 @@ def test_exec_with_stderr_callback(self, mock_client: Mock, mock_stream: Mock) - stderr_calls: list[str] = [] devbox = Devbox(mock_client, "dev_123") - result = devbox.cmd.exec("echo hello", stderr=stderr_calls.append) + result = devbox.cmd.exec(command="echo hello", stderr=stderr_calls.append) assert result.exit_code == 0 mock_client.devboxes.execute_async.assert_called_once() @@ -116,7 +116,7 @@ def test_exec_with_output_callback(self, mock_client: Mock, mock_stream: Mock) - output_calls: list[str] = [] devbox = Devbox(mock_client, "dev_123") - result = devbox.cmd.exec("echo hello", output=output_calls.append) + result = devbox.cmd.exec(command="echo hello", output=output_calls.append) assert result.exit_code == 0 mock_client.devboxes.execute_async.assert_called_once() @@ -148,7 +148,7 @@ def test_exec_with_all_callbacks(self, mock_client: Mock, mock_stream: Mock) -> devbox = Devbox(mock_client, "dev_123") result = devbox.cmd.exec( - "echo hello", + command="echo hello", stdout=stdout_calls.append, stderr=stderr_calls.append, output=output_calls.append, @@ -169,7 +169,7 @@ def test_exec_async_returns_execution(self, mock_client: Mock, mock_stream: Mock mock_client.devboxes.executions.stream_stdout_updates.return_value = mock_stream devbox = Devbox(mock_client, "dev_123") - execution = devbox.cmd.exec_async("long-running command") + execution = devbox.cmd.exec_async(command="long-running command") assert execution.execution_id == "exec_123" assert execution.devbox_id == "dev_123" @@ -184,12 +184,12 @@ def test_read(self, mock_client: Mock) -> None: mock_client.devboxes.read_file_contents.return_value = "file content" devbox = Devbox(mock_client, "dev_123") - result = devbox.file.read("/path/to/file") + result = devbox.file.read(file_path="/path/to/file") assert result == "file content" call_kwargs = mock_client.devboxes.read_file_contents.call_args[1] assert call_kwargs["file_path"] == "/path/to/file" - assert isinstance(call_kwargs["timeout"], NotGiven) + assert "timeout" not in call_kwargs def test_write_string(self, mock_client: Mock) -> None: """Test file write with string.""" @@ -197,13 +197,13 @@ def test_write_string(self, mock_client: Mock) -> None: mock_client.devboxes.write_file_contents.return_value = execution_detail devbox = Devbox(mock_client, "dev_123") - result = devbox.file.write("/path/to/file", "content") + result = devbox.file.write(file_path="/path/to/file", contents="content") assert result == execution_detail call_kwargs = mock_client.devboxes.write_file_contents.call_args[1] assert call_kwargs["file_path"] == "/path/to/file" assert call_kwargs["contents"] == "content" - assert isinstance(call_kwargs["timeout"], NotGiven) + assert "timeout" not in call_kwargs def test_write_bytes(self, mock_client: Mock) -> None: """Test file write with bytes.""" @@ -211,13 +211,13 @@ def test_write_bytes(self, mock_client: Mock) -> None: mock_client.devboxes.write_file_contents.return_value = execution_detail devbox = Devbox(mock_client, "dev_123") - result = devbox.file.write("/path/to/file", b"content") + result = devbox.file.write(file_path="/path/to/file", contents="content") assert result == execution_detail call_kwargs = mock_client.devboxes.write_file_contents.call_args[1] assert call_kwargs["file_path"] == "/path/to/file" assert call_kwargs["contents"] == "content" - assert isinstance(call_kwargs["timeout"], NotGiven) + assert "timeout" not in call_kwargs def test_download(self, mock_client: Mock) -> None: """Test file download.""" @@ -226,12 +226,12 @@ def test_download(self, mock_client: Mock) -> None: mock_client.devboxes.download_file.return_value = mock_response devbox = Devbox(mock_client, "dev_123") - result = devbox.file.download("/path/to/file") + result = devbox.file.download(path="/path/to/file") assert result == b"file content" call_kwargs = mock_client.devboxes.download_file.call_args[1] assert call_kwargs["path"] == "/path/to/file" - assert isinstance(call_kwargs["timeout"], NotGiven) + assert "timeout" not in call_kwargs def test_upload(self, mock_client: Mock, tmp_path: Path) -> None: """Test file upload.""" @@ -243,13 +243,13 @@ def test_upload(self, mock_client: Mock, tmp_path: Path) -> None: temp_file = tmp_path / "test_file.txt" temp_file.write_text("test content") - result = devbox.file.upload("/remote/path", temp_file) + result = devbox.file.upload(path="/remote/path", file=temp_file) assert result == execution_detail call_kwargs = mock_client.devboxes.upload_file.call_args[1] assert call_kwargs["path"] == "/remote/path" assert call_kwargs["file"] is not None # File object from temp_path - assert isinstance(call_kwargs["timeout"], NotGiven) + assert "timeout" not in call_kwargs class TestNetworkInterface: diff --git a/tests/sdk/test_async_clients.py b/tests/sdk/test_async_clients.py index f4185ab3a..2a2f191e2 100644 --- a/tests/sdk/test_async_clients.py +++ b/tests/sdk/test_async_clients.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from pathlib import Path -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest @@ -13,7 +13,6 @@ MockObjectView, MockSnapshotView, MockBlueprintView, - create_mock_httpx_client, create_mock_httpx_response, ) from runloop_api_client.sdk import AsyncDevbox, AsyncSnapshot, AsyncBlueprint, AsyncStorageObject @@ -205,12 +204,16 @@ async def test_create(self, mock_async_client: AsyncMock, object_view: MockObjec mock_async_client.objects.create = AsyncMock(return_value=object_view) client = AsyncStorageObjectClient(mock_async_client) - obj = await client.create("test.txt", content_type="text", metadata={"key": "value"}) + obj = await client.create(name="test.txt", content_type="text", metadata={"key": "value"}) assert isinstance(obj, AsyncStorageObject) assert obj.id == "obj_123" assert obj.upload_url == "https://upload.example.com/obj_123" - mock_async_client.objects.create.assert_called_once() + mock_async_client.objects.create.assert_awaited_once_with( + name="test.txt", + content_type="text", + metadata={"key": "value"}, + ) @pytest.mark.asyncio async def test_create_auto_detect_content_type( @@ -220,11 +223,11 @@ async def test_create_auto_detect_content_type( mock_async_client.objects.create = AsyncMock(return_value=object_view) client = AsyncStorageObjectClient(mock_async_client) - obj = await client.create("test.txt") + obj = await client.create(name="test.txt") assert isinstance(obj, AsyncStorageObject) call_kwargs = mock_async_client.objects.create.call_args[1] - assert call_kwargs["content_type"] == "text" + assert "content_type" not in call_kwargs def test_from_id(self, mock_async_client: AsyncMock) -> None: """Test from_id method.""" @@ -254,7 +257,7 @@ async def test_list(self, mock_async_client: AsyncMock, object_view: MockObjectV assert len(objects) == 1 assert isinstance(objects[0], AsyncStorageObject) assert objects[0].id == "obj_123" - mock_async_client.objects.list.assert_called_once() + mock_async_client.objects.list.assert_awaited_once() @pytest.mark.asyncio async def test_upload_from_file( @@ -267,18 +270,19 @@ async def test_upload_from_file( temp_file = tmp_path / "test_file.txt" temp_file.write_text("test content") - with patch("httpx.AsyncClient") as mock_client_class: - mock_response = create_mock_httpx_response() - mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) - mock_client_class.return_value = mock_http_client + http_client = AsyncMock() + mock_response = create_mock_httpx_response() + http_client.put = AsyncMock(return_value=mock_response) + mock_async_client._client = http_client - client = AsyncStorageObjectClient(mock_async_client) - obj = await client.upload_from_file(temp_file, name="test.txt") + client = AsyncStorageObjectClient(mock_async_client) + obj = await client.upload_from_file(temp_file, name="test.txt") - assert isinstance(obj, AsyncStorageObject) - assert obj.id == "obj_123" - mock_async_client.objects.create.assert_called_once() - mock_async_client.objects.complete.assert_called_once() + assert isinstance(obj, AsyncStorageObject) + assert obj.id == "obj_123" + mock_async_client.objects.create.assert_awaited_once() + mock_async_client.objects.complete.assert_awaited_once() + http_client.put.assert_awaited_once_with(object_view.upload_url, content=b"test content") @pytest.mark.asyncio async def test_upload_from_text(self, mock_async_client: AsyncMock, object_view: MockObjectView) -> None: @@ -286,20 +290,23 @@ async def test_upload_from_text(self, mock_async_client: AsyncMock, object_view: mock_async_client.objects.create = AsyncMock(return_value=object_view) mock_async_client.objects.complete = AsyncMock(return_value=object_view) - with patch("httpx.AsyncClient") as mock_client_class: - mock_response = create_mock_httpx_response() - mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) - mock_client_class.return_value = mock_http_client + http_client = AsyncMock() + mock_response = create_mock_httpx_response() + http_client.put = AsyncMock(return_value=mock_response) + mock_async_client._client = http_client - client = AsyncStorageObjectClient(mock_async_client) - obj = await client.upload_from_text("test content", "test.txt", metadata={"key": "value"}) + client = AsyncStorageObjectClient(mock_async_client) + obj = await client.upload_from_text("test content", "test.txt", metadata={"key": "value"}) - assert isinstance(obj, AsyncStorageObject) - assert obj.id == "obj_123" - mock_async_client.objects.create.assert_called_once() - call_kwargs = mock_async_client.objects.create.call_args[1] - assert call_kwargs["content_type"] == "text" - mock_async_client.objects.complete.assert_called_once() + assert isinstance(obj, AsyncStorageObject) + assert obj.id == "obj_123" + mock_async_client.objects.create.assert_awaited_once_with( + name="test.txt", + content_type="text", + metadata={"key": "value"}, + ) + http_client.put.assert_awaited_once_with(object_view.upload_url, content="test content") + mock_async_client.objects.complete.assert_awaited_once() @pytest.mark.asyncio async def test_upload_from_bytes(self, mock_async_client: AsyncMock, object_view: MockObjectView) -> None: @@ -307,20 +314,32 @@ async def test_upload_from_bytes(self, mock_async_client: AsyncMock, object_view mock_async_client.objects.create = AsyncMock(return_value=object_view) mock_async_client.objects.complete = AsyncMock(return_value=object_view) - with patch("httpx.AsyncClient") as mock_client_class: - mock_response = create_mock_httpx_response() - mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) - mock_client_class.return_value = mock_http_client + http_client = AsyncMock() + mock_response = create_mock_httpx_response() + http_client.put = AsyncMock(return_value=mock_response) + mock_async_client._client = http_client + + client = AsyncStorageObjectClient(mock_async_client) + obj = await client.upload_from_bytes(b"test content", "test.bin", content_type="binary") + + assert isinstance(obj, AsyncStorageObject) + assert obj.id == "obj_123" + mock_async_client.objects.create.assert_awaited_once_with( + name="test.bin", + content_type="binary", + metadata=None, + ) + http_client.put.assert_awaited_once_with(object_view.upload_url, content=b"test content") + mock_async_client.objects.complete.assert_awaited_once() - client = AsyncStorageObjectClient(mock_async_client) - obj = await client.upload_from_bytes(b"test content", "test.bin", content_type="binary") + @pytest.mark.asyncio + async def test_upload_from_file_missing_path(self, mock_async_client: AsyncMock, tmp_path: Path) -> None: + """upload_from_file should raise when file cannot be read.""" + client = AsyncStorageObjectClient(mock_async_client) + missing_file = tmp_path / "missing.txt" - assert isinstance(obj, AsyncStorageObject) - assert obj.id == "obj_123" - mock_async_client.objects.create.assert_called_once() - call_kwargs = mock_async_client.objects.create.call_args[1] - assert call_kwargs["content_type"] == "binary" - mock_async_client.objects.complete.assert_called_once() + with pytest.raises(OSError, match="Failed to read file"): + await client.upload_from_file(missing_file) class TestAsyncRunloopSDK: diff --git a/tests/sdk/test_async_execution.py b/tests/sdk/test_async_execution.py index 9453ebf60..6ce89a6cb 100644 --- a/tests/sdk/test_async_execution.py +++ b/tests/sdk/test_async_execution.py @@ -93,7 +93,7 @@ def test_init(self, mock_async_client: AsyncMock, execution_view: MockExecutionV execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert execution.execution_id == "exec_123" assert execution.devbox_id == "dev_123" - assert execution._latest == execution_view + assert execution._initial_result == execution_view @pytest.mark.asyncio async def test_init_with_streaming_group( @@ -121,19 +121,28 @@ def test_properties(self, mock_async_client: AsyncMock, execution_view: MockExec assert execution.execution_id == "exec_123" assert execution.devbox_id == "dev_123" + def test_repr(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: + """Test AsyncExecution repr formatting.""" + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] + assert repr(execution) == "" + @pytest.mark.asyncio async def test_result_already_completed( self, mock_async_client: AsyncMock, execution_view: MockExecutionView ) -> None: """Test result when execution is already completed.""" + mock_async_client.devboxes.wait_for_command = AsyncMock(return_value=execution_view) + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] result = await execution.result() assert result.exit_code == 0 - assert await result.stdout() == "output" - # Verify await_completed is not called when already completed - if hasattr(mock_async_client.devboxes.executions, "await_completed"): - assert not mock_async_client.devboxes.executions.await_completed.called + assert await result.stdout(num_lines=10) == "output" + mock_async_client.devboxes.wait_for_command.assert_awaited_once_with( + "exec_123", + devbox_id="dev_123", + statuses=["completed"], + ) @pytest.mark.asyncio async def test_result_needs_polling(self, mock_async_client: AsyncMock) -> None: @@ -152,14 +161,18 @@ async def test_result_needs_polling(self, mock_async_client: AsyncMock) -> None: stderr="", ) - mock_async_client.devboxes.executions.await_completed = AsyncMock(return_value=completed_execution) + mock_async_client.devboxes.wait_for_command = AsyncMock(return_value=completed_execution) execution = AsyncExecution(mock_async_client, "dev_123", running_execution) # type: ignore[arg-type] result = await execution.result() assert result.exit_code == 0 - assert await result.stdout() == "output" - mock_async_client.devboxes.executions.await_completed.assert_called_once() + assert await result.stdout(num_lines=10) == "output" + mock_async_client.devboxes.wait_for_command.assert_awaited_once_with( + "exec_123", + devbox_id="dev_123", + statuses=["completed"], + ) @pytest.mark.asyncio async def test_result_with_streaming_group(self, mock_async_client: AsyncMock) -> None: @@ -178,7 +191,7 @@ async def test_result_with_streaming_group(self, mock_async_client: AsyncMock) - stderr="", ) - mock_async_client.devboxes.executions.await_completed = AsyncMock(return_value=completed_execution) + mock_async_client.devboxes.wait_for_command = AsyncMock(return_value=completed_execution) async def task() -> None: await asyncio.sleep(SHORT_SLEEP) @@ -191,6 +204,32 @@ async def task() -> None: assert result.exit_code == 0 assert execution._streaming_group is None # Should be cleaned up + mock_async_client.devboxes.wait_for_command.assert_awaited_once() + + @pytest.mark.asyncio + async def test_result_passes_options(self, mock_async_client: AsyncMock) -> None: + """Ensure result forwards options to wait_for_command.""" + execution_view = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="", + ) + + mock_async_client.devboxes.wait_for_command = AsyncMock(return_value=execution_view) + + execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] + await execution.result(timeout=30.0, idempotency_key="abc123") + + mock_async_client.devboxes.wait_for_command.assert_awaited_once_with( + "exec_123", + devbox_id="dev_123", + statuses=["completed"], + timeout=30.0, + idempotency_key="abc123", + ) @pytest.mark.asyncio async def test_get_state(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: @@ -206,8 +245,11 @@ async def test_get_state(self, mock_async_client: AsyncMock, execution_view: Moc result = await execution.get_state() assert result == updated_execution - assert execution._latest == updated_execution - mock_async_client.devboxes.executions.retrieve.assert_called_once() + assert execution._initial_result == execution_view + mock_async_client.devboxes.executions.retrieve.assert_awaited_once_with( + "exec_123", + devbox_id="dev_123", + ) @pytest.mark.asyncio async def test_kill(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: @@ -217,10 +259,9 @@ async def test_kill(self, mock_async_client: AsyncMock, execution_view: MockExec execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] await execution.kill() - mock_async_client.devboxes.executions.kill.assert_called_once_with( + mock_async_client.devboxes.executions.kill.assert_awaited_once_with( "exec_123", devbox_id="dev_123", - kill_process_group=None, ) @pytest.mark.asyncio @@ -233,71 +274,8 @@ async def test_kill_with_process_group( execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] await execution.kill(kill_process_group=True) - mock_async_client.devboxes.executions.kill.assert_called_once_with( + mock_async_client.devboxes.executions.kill.assert_awaited_once_with( "exec_123", devbox_id="dev_123", kill_process_group=True, ) - - @pytest.mark.asyncio - async def test_kill_with_streaming_cleanup( - self, mock_async_client: AsyncMock, execution_view: MockExecutionView - ) -> None: - """Test kill cleans up streaming.""" - mock_async_client.devboxes.executions.kill = AsyncMock(return_value=None) - - async def task() -> None: - await asyncio.sleep(LONG_SLEEP) # Long-running task - - tasks = [asyncio.create_task(task())] - streaming_group = _AsyncStreamingGroup(tasks) - - execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) # type: ignore[arg-type] - await execution.kill() - - assert execution._streaming_group is None # Should be cleaned up - assert all(task.cancelled() for task in tasks) # Tasks should be cancelled - - @pytest.mark.asyncio - async def test_settle_streaming_no_group( - self, mock_async_client: AsyncMock, execution_view: MockExecutionView - ) -> None: - """Test _settle_streaming when no streaming group.""" - execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] - await execution._settle_streaming(cancel=True) # Should not raise - - @pytest.mark.asyncio - async def test_settle_streaming_with_group_cancel( - self, mock_async_client: AsyncMock, execution_view: MockExecutionView - ) -> None: - """Test _settle_streaming with streaming group and cancel.""" - - async def task() -> None: - await asyncio.sleep(LONG_SLEEP) # Long-running task - - tasks = [asyncio.create_task(task())] - streaming_group = _AsyncStreamingGroup(tasks) - - execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) # type: ignore[arg-type] - await execution._settle_streaming(cancel=True) - - assert execution._streaming_group is None - assert all(task.cancelled() for task in tasks) - - @pytest.mark.asyncio - async def test_settle_streaming_with_group_wait( - self, mock_async_client: AsyncMock, execution_view: MockExecutionView - ) -> None: - """Test _settle_streaming with streaming group and wait.""" - - async def task() -> None: - await asyncio.sleep(SHORT_SLEEP) - - tasks = [asyncio.create_task(task())] - streaming_group = _AsyncStreamingGroup(tasks) - - execution = AsyncExecution(mock_async_client, "dev_123", execution_view, streaming_group) # type: ignore[arg-type] - await execution._settle_streaming(cancel=False) - - assert execution._streaming_group is None - assert all(task.done() for task in tasks) diff --git a/tests/sdk/test_async_execution_result.py b/tests/sdk/test_async_execution_result.py index 0cdc5256f..acb885900 100644 --- a/tests/sdk/test_async_execution_result.py +++ b/tests/sdk/test_async_execution_result.py @@ -103,6 +103,7 @@ async def test_stdout(self, mock_async_client: AsyncMock, execution_view: MockEx """Test stdout method.""" result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert await result.stdout() == "output" + assert await result.stdout(num_lines=10) == "output" @pytest.mark.asyncio async def test_stdout_empty(self, mock_async_client: AsyncMock) -> None: @@ -131,6 +132,7 @@ async def test_stderr(self, mock_async_client: AsyncMock) -> None: ) result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] assert await result.stderr() == "error message" + assert await result.stderr(num_lines=20) == "error message" @pytest.mark.asyncio async def test_stderr_empty(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: diff --git a/tests/sdk/test_async_storage_object.py b/tests/sdk/test_async_storage_object.py index 8c7164a94..b4623a95a 100644 --- a/tests/sdk/test_async_storage_object.py +++ b/tests/sdk/test_async_storage_object.py @@ -3,12 +3,11 @@ from __future__ import annotations from types import SimpleNamespace -from pathlib import Path -from unittest.mock import Mock, AsyncMock, patch +from unittest.mock import AsyncMock import pytest -from tests.sdk.conftest import MockObjectView, create_mock_httpx_client, create_mock_httpx_response +from tests.sdk.conftest import MockObjectView, create_mock_httpx_response from runloop_api_client.sdk import AsyncStorageObject @@ -105,15 +104,15 @@ async def test_get_download_url_with_duration(self, mock_async_client: AsyncMock mock_async_client.objects.download.assert_called_once() @pytest.mark.asyncio - @patch("httpx.AsyncClient") - async def test_download_as_bytes(self, mock_client_class: Mock, mock_async_client: AsyncMock) -> None: + async def test_download_as_bytes(self, mock_async_client: AsyncMock) -> None: """Test download_as_bytes method.""" download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") mock_async_client.objects.download = AsyncMock(return_value=download_url_view) mock_response = create_mock_httpx_response(content=b"file content") - mock_http_client = create_mock_httpx_client(methods={"get": mock_response}) - mock_client_class.return_value = mock_http_client + http_client = AsyncMock() + http_client.get = AsyncMock(return_value=mock_response) + mock_async_client._client = http_client obj = AsyncStorageObject(mock_async_client, "obj_123", None) result = await obj.download_as_bytes( @@ -125,50 +124,26 @@ async def test_download_as_bytes(self, mock_client_class: Mock, mock_async_clien ) assert result == b"file content" - # Verify get was called - mock_http_client.get.assert_called_once() + http_client.get.assert_awaited_once_with("https://download.example.com/obj_123") mock_response.raise_for_status.assert_called_once() @pytest.mark.asyncio - @patch("httpx.AsyncClient") - async def test_download_as_text_default_encoding( - self, mock_client_class: Mock, mock_async_client: AsyncMock - ) -> None: - """Test download_as_text with default encoding.""" + async def test_download_as_text(self, mock_async_client: AsyncMock) -> None: + """Test download_as_text forces UTF-8 encoding.""" download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") mock_async_client.objects.download = AsyncMock(return_value=download_url_view) - mock_response = create_mock_httpx_response(text="file content", encoding="utf-8") - mock_http_client = create_mock_httpx_client(methods={"get": mock_response}) - mock_client_class.return_value = mock_http_client + mock_response = create_mock_httpx_response(text="file content", encoding="latin-1") + http_client = AsyncMock() + http_client.get = AsyncMock(return_value=mock_response) + mock_async_client._client = http_client obj = AsyncStorageObject(mock_async_client, "obj_123", None) result = await obj.download_as_text() assert result == "file content" assert mock_response.encoding == "utf-8" - # Verify get was called - mock_http_client.get.assert_called_once() - - @pytest.mark.asyncio - @patch("httpx.AsyncClient") - async def test_download_as_text_custom_encoding( - self, mock_client_class: Mock, mock_async_client: AsyncMock - ) -> None: - """Test download_as_text with custom encoding.""" - download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") - mock_async_client.objects.download = AsyncMock(return_value=download_url_view) - - mock_response = create_mock_httpx_response(text="file content", encoding="utf-8") - mock_http_client = create_mock_httpx_client(methods={"get": mock_response}) - mock_client_class.return_value = mock_http_client - - obj = AsyncStorageObject(mock_async_client, "obj_123", None) - result = await obj.download_as_text(encoding="latin-1") - - assert result == "file content" - assert mock_response.encoding == "latin-1" - mock_http_client.get.assert_called_once() + http_client.get.assert_awaited_once_with("https://download.example.com/obj_123") @pytest.mark.asyncio async def test_delete(self, mock_async_client: AsyncMock, object_view: MockObjectView) -> None: @@ -188,60 +163,31 @@ async def test_delete(self, mock_async_client: AsyncMock, object_view: MockObjec mock_async_client.objects.delete.assert_called_once() @pytest.mark.asyncio - @patch("httpx.AsyncClient") - async def test_upload_content_string(self, mock_client_class: Mock, mock_async_client: AsyncMock) -> None: + async def test_upload_content_string(self, mock_async_client: AsyncMock) -> None: """Test upload_content with string.""" mock_response = create_mock_httpx_response() - mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) - mock_client_class.return_value = mock_http_client + http_client = AsyncMock() + http_client.put = AsyncMock(return_value=mock_response) + mock_async_client._client = http_client obj = AsyncStorageObject(mock_async_client, "obj_123", "https://upload.example.com") await obj.upload_content("test content") - # Verify put was called with correct URL - mock_http_client.put.assert_called_once() - call_args = mock_http_client.put.call_args - assert call_args[0][0] == "https://upload.example.com" + http_client.put.assert_awaited_once_with("https://upload.example.com", content="test content") mock_response.raise_for_status.assert_called_once() @pytest.mark.asyncio - @patch("httpx.AsyncClient") - async def test_upload_content_bytes(self, mock_client_class: Mock, mock_async_client: AsyncMock) -> None: + async def test_upload_content_bytes(self, mock_async_client: AsyncMock) -> None: """Test upload_content with bytes.""" mock_response = create_mock_httpx_response() - mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) - mock_client_class.return_value = mock_http_client + http_client = AsyncMock() + http_client.put = AsyncMock(return_value=mock_response) + mock_async_client._client = http_client obj = AsyncStorageObject(mock_async_client, "obj_123", "https://upload.example.com") await obj.upload_content(b"test content") - # Verify put was called with correct URL - mock_http_client.put.assert_called_once() - call_args = mock_http_client.put.call_args - assert call_args[0][0] == "https://upload.example.com" - mock_response.raise_for_status.assert_called_once() - - @pytest.mark.asyncio - @patch("httpx.AsyncClient") - async def test_upload_content_path( - self, mock_client_class: Mock, mock_async_client: AsyncMock, tmp_path: Path - ) -> None: - """Test upload_content with Path.""" - mock_response = create_mock_httpx_response() - mock_http_client = create_mock_httpx_client(methods={"put": mock_response}) - mock_client_class.return_value = mock_http_client - - temp_file = tmp_path / "test_file.txt" - temp_file.write_text("test content") - - obj = AsyncStorageObject(mock_async_client, "obj_123", "https://upload.example.com") - await obj.upload_content(temp_file) - - # Verify put was called - mock_http_client.put.assert_called_once() - call_args = mock_http_client.put.call_args - assert call_args[0][0] == "https://upload.example.com" - assert call_args[1]["content"] == b"test content" + http_client.put.assert_awaited_once_with("https://upload.example.com", content=b"test content") mock_response.raise_for_status.assert_called_once() @pytest.mark.asyncio diff --git a/tests/sdk/test_clients.py b/tests/sdk/test_clients.py index 2c5450375..0479795df 100644 --- a/tests/sdk/test_clients.py +++ b/tests/sdk/test_clients.py @@ -4,7 +4,9 @@ from types import SimpleNamespace from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import Mock + +import pytest from tests.sdk.conftest import ( MockDevboxView, @@ -195,7 +197,7 @@ def test_create(self, mock_client: Mock, object_view: MockObjectView) -> None: mock_client.objects.create.return_value = object_view client = StorageObjectClient(mock_client) - obj = client.create("test.txt", content_type="text", metadata={"key": "value"}) + obj = client.create(name="test.txt", content_type="text", metadata={"key": "value"}) assert isinstance(obj, StorageObject) assert obj.id == "obj_123" @@ -207,12 +209,11 @@ def test_create_auto_detect_content_type(self, mock_client: Mock, object_view: M mock_client.objects.create.return_value = object_view client = StorageObjectClient(mock_client) - obj = client.create("test.txt") + obj = client.create(name="test.txt") assert isinstance(obj, StorageObject) - # Should detect "text" from .txt extension call_kwargs = mock_client.objects.create.call_args[1] - assert call_kwargs["content_type"] == "text" + assert "content_type" not in call_kwargs def test_from_id(self, mock_client: Mock) -> None: """Test from_id method.""" @@ -250,55 +251,71 @@ def test_upload_from_file(self, mock_client: Mock, object_view: MockObjectView, temp_file = tmp_path / "test_file.txt" temp_file.write_text("test content") - with patch("httpx.put") as mock_put: - mock_response = create_mock_httpx_response() - mock_put.return_value = mock_response + http_client = Mock() + mock_response = create_mock_httpx_response() + http_client.put.return_value = mock_response + mock_client._client = http_client - client = StorageObjectClient(mock_client) - obj = client.upload_from_file(temp_file, name="test.txt") + client = StorageObjectClient(mock_client) + obj = client.upload_from_file(temp_file, name="test.txt") - assert isinstance(obj, StorageObject) - assert obj.id == "obj_123" - mock_client.objects.create.assert_called_once() - mock_client.objects.complete.assert_called_once() - mock_put.assert_called_once() + assert isinstance(obj, StorageObject) + assert obj.id == "obj_123" + mock_client.objects.create.assert_called_once() + mock_client.objects.complete.assert_called_once() + http_client.put.assert_called_once_with(object_view.upload_url, content=b"test content") def test_upload_from_text(self, mock_client: Mock, object_view: MockObjectView) -> None: """Test upload_from_text method.""" mock_client.objects.create.return_value = object_view - with patch("httpx.put") as mock_put: - mock_response = create_mock_httpx_response() - mock_put.return_value = mock_response + http_client = Mock() + mock_response = create_mock_httpx_response() + http_client.put.return_value = mock_response + mock_client._client = http_client - client = StorageObjectClient(mock_client) - obj = client.upload_from_text("test content", "test.txt", metadata={"key": "value"}) + client = StorageObjectClient(mock_client) + obj = client.upload_from_text("test content", "test.txt", metadata={"key": "value"}) - assert isinstance(obj, StorageObject) - assert obj.id == "obj_123" - mock_client.objects.create.assert_called_once() - call_kwargs = mock_client.objects.create.call_args[1] - assert call_kwargs["content_type"] == "text" - assert call_kwargs["metadata"] == {"key": "value"} - mock_client.objects.complete.assert_called_once() + assert isinstance(obj, StorageObject) + assert obj.id == "obj_123" + mock_client.objects.create.assert_called_once_with( + name="test.txt", + content_type="text", + metadata={"key": "value"}, + ) + http_client.put.assert_called_once_with(object_view.upload_url, content="test content") + mock_client.objects.complete.assert_called_once() def test_upload_from_bytes(self, mock_client: Mock, object_view: MockObjectView) -> None: """Test upload_from_bytes method.""" mock_client.objects.create.return_value = object_view - with patch("httpx.put") as mock_put: - mock_response = create_mock_httpx_response() - mock_put.return_value = mock_response + http_client = Mock() + mock_response = create_mock_httpx_response() + http_client.put.return_value = mock_response + mock_client._client = http_client - client = StorageObjectClient(mock_client) - obj = client.upload_from_bytes(b"test content", "test.bin", content_type="binary") + client = StorageObjectClient(mock_client) + obj = client.upload_from_bytes(b"test content", "test.bin", content_type="binary") + + assert isinstance(obj, StorageObject) + assert obj.id == "obj_123" + mock_client.objects.create.assert_called_once_with( + name="test.bin", + content_type="binary", + metadata=None, + ) + http_client.put.assert_called_once_with(object_view.upload_url, content=b"test content") + mock_client.objects.complete.assert_called_once() + + def test_upload_from_file_missing_path(self, mock_client: Mock, tmp_path: Path) -> None: + """upload_from_file should raise when file cannot be read.""" + client = StorageObjectClient(mock_client) + missing_file = tmp_path / "missing.txt" - assert isinstance(obj, StorageObject) - assert obj.id == "obj_123" - mock_client.objects.create.assert_called_once() - call_kwargs = mock_client.objects.create.call_args[1] - assert call_kwargs["content_type"] == "binary" - mock_client.objects.complete.assert_called_once() + with pytest.raises(OSError, match="Failed to read file"): + client.upload_from_file(missing_file) class TestRunloopSDK: diff --git a/tests/sdk/test_execution.py b/tests/sdk/test_execution.py index 0da625239..0c18f1e93 100644 --- a/tests/sdk/test_execution.py +++ b/tests/sdk/test_execution.py @@ -8,7 +8,6 @@ from unittest.mock import Mock from tests.sdk.conftest import ( - TASK_COMPLETION_LONG, THREAD_STARTUP_DELAY, TASK_COMPLETION_SHORT, MockExecutionView, @@ -18,7 +17,6 @@ # Legacy aliases for backward compatibility during transition SHORT_SLEEP = THREAD_STARTUP_DELAY MEDIUM_SLEEP = TASK_COMPLETION_SHORT * 10 # 0.2 -LONG_SLEEP = TASK_COMPLETION_LONG class TestStreamingGroup: @@ -87,7 +85,7 @@ def test_init(self, mock_client: Mock, execution_view: MockExecutionView) -> Non execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert execution.execution_id == "exec_123" assert execution.devbox_id == "dev_123" - assert execution._latest == execution_view + assert execution._initial_result == execution_view def test_init_with_streaming_group(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test Execution initialization with streaming group.""" @@ -104,19 +102,29 @@ def test_properties(self, mock_client: Mock, execution_view: MockExecutionView) assert execution.execution_id == "exec_123" assert execution.devbox_id == "dev_123" + def test_repr(self, mock_client: Mock, execution_view: MockExecutionView) -> None: + """Test Execution repr formatting.""" + execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] + assert repr(execution) == "" + def test_result_already_completed(self, mock_client: Mock, execution_view: MockExecutionView) -> None: - """Test result when execution is already completed.""" + """Test result delegates to wait_for_command when already completed.""" + mock_client.devboxes = Mock() + mock_client.devboxes.wait_for_command.return_value = execution_view + execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] result = execution.result() assert result.exit_code == 0 - assert result.stdout() == "output" - # Verify await_completed is not called when already completed - if hasattr(mock_client.devboxes.executions, "await_completed"): - assert not mock_client.devboxes.executions.await_completed.called + assert result.stdout(num_lines=10) == "output" + mock_client.devboxes.wait_for_command.assert_called_once_with( + "exec_123", + devbox_id="dev_123", + statuses=["completed"], + ) def test_result_needs_polling(self, mock_client: Mock) -> None: - """Test result when execution needs polling.""" + """Test result when execution needs to poll for completion.""" running_execution = SimpleNamespace( execution_id="exec_123", devbox_id="dev_123", @@ -131,21 +139,22 @@ def test_result_needs_polling(self, mock_client: Mock) -> None: stderr="", ) - mock_client.devboxes.executions.await_completed.return_value = completed_execution + mock_client.devboxes = Mock() + mock_client.devboxes.wait_for_command.return_value = completed_execution execution = Execution(mock_client, "dev_123", running_execution) # type: ignore[arg-type] result = execution.result() assert result.exit_code == 0 - assert result.stdout() == "output" - mock_client.devboxes.executions.await_completed.assert_called_once_with( + assert result.stdout(num_lines=10) == "output" + mock_client.devboxes.wait_for_command.assert_called_once_with( "exec_123", devbox_id="dev_123", - polling_config=None, + statuses=["completed"], ) def test_result_with_streaming_group(self, mock_client: Mock) -> None: - """Test result with streaming group cleanup.""" + """Test result waits for streaming group to finish.""" running_execution = SimpleNamespace( execution_id="exec_123", devbox_id="dev_123", @@ -160,7 +169,8 @@ def test_result_with_streaming_group(self, mock_client: Mock) -> None: stderr="", ) - mock_client.devboxes.executions.await_completed.return_value = completed_execution + mock_client.devboxes = Mock() + mock_client.devboxes.wait_for_command.return_value = completed_execution stop_event = threading.Event() thread = threading.Thread(target=lambda: time.sleep(SHORT_SLEEP)) @@ -172,6 +182,32 @@ def test_result_with_streaming_group(self, mock_client: Mock) -> None: assert result.exit_code == 0 assert execution._streaming_group is None # Should be cleaned up + mock_client.devboxes.wait_for_command.assert_called_once() + + def test_result_passes_options(self, mock_client: Mock) -> None: + """Ensure options are forwarded to wait_for_command.""" + execution_view = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="output", + stderr="", + ) + + mock_client.devboxes = Mock() + mock_client.devboxes.wait_for_command.return_value = execution_view + + execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] + execution.result(timeout=30.0, idempotency_key="abc123") + + mock_client.devboxes.wait_for_command.assert_called_once_with( + "exec_123", + devbox_id="dev_123", + statuses=["completed"], + timeout=30.0, + idempotency_key="abc123", + ) def test_get_state(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test get_state method.""" @@ -180,13 +216,14 @@ def test_get_state(self, mock_client: Mock, execution_view: MockExecutionView) - devbox_id="dev_123", status="running", ) + mock_client.devboxes.executions = Mock() mock_client.devboxes.executions.retrieve.return_value = updated_execution execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] result = execution.get_state() assert result == updated_execution - assert execution._latest == updated_execution + assert execution._initial_result == execution_view mock_client.devboxes.executions.retrieve.assert_called_once_with( "exec_123", devbox_id="dev_123", @@ -202,7 +239,6 @@ def test_kill(self, mock_client: Mock, execution_view: MockExecutionView) -> Non mock_client.devboxes.executions.kill.assert_called_once_with( "exec_123", devbox_id="dev_123", - kill_process_group=None, ) def test_kill_with_process_group(self, mock_client: Mock, execution_view: MockExecutionView) -> None: @@ -217,38 +253,3 @@ def test_kill_with_process_group(self, mock_client: Mock, execution_view: MockEx devbox_id="dev_123", kill_process_group=True, ) - - def test_kill_with_streaming_cleanup(self, mock_client: Mock, execution_view: MockExecutionView) -> None: - """Test kill cleans up streaming.""" - mock_client.devboxes.executions.kill.return_value = None - - stop_event = threading.Event() - # Thread needs to be started to be joinable - thread = threading.Thread(target=lambda: time.sleep(LONG_SLEEP)) - thread.start() - streaming_group = _StreamingGroup([thread], stop_event) - - execution = Execution(mock_client, "dev_123", execution_view, streaming_group) # type: ignore[arg-type] - execution.kill() - - assert execution._streaming_group is None # Should be cleaned up - assert stop_event.is_set() # Should be stopped - - def test_stop_streaming_no_group(self, mock_client: Mock, execution_view: MockExecutionView) -> None: - """Test _stop_streaming when no streaming group.""" - execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] - execution._stop_streaming() # Should not raise - - def test_stop_streaming_with_group(self, mock_client: Mock, execution_view: MockExecutionView) -> None: - """Test _stop_streaming with streaming group.""" - stop_event = threading.Event() - # Thread needs to be started to be joinable - thread = threading.Thread(target=lambda: time.sleep(LONG_SLEEP)) - thread.start() - streaming_group = _StreamingGroup([thread], stop_event) - - execution = Execution(mock_client, "dev_123", execution_view, streaming_group) # type: ignore[arg-type] - execution._stop_streaming() - - assert execution._streaming_group is None - assert stop_event.is_set() diff --git a/tests/sdk/test_execution_result.py b/tests/sdk/test_execution_result.py index 20f8d5519..8952e4870 100644 --- a/tests/sdk/test_execution_result.py +++ b/tests/sdk/test_execution_result.py @@ -100,6 +100,7 @@ def test_stdout(self, mock_client: Mock, execution_view: MockExecutionView) -> N """Test stdout method.""" result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.stdout() == "output" + assert result.stdout(num_lines=10) == "output" def test_stdout_empty(self, mock_client: Mock) -> None: """Test stdout method when stdout is None.""" @@ -126,6 +127,7 @@ def test_stderr(self, mock_client: Mock) -> None: ) result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] assert result.stderr() == "error message" + assert result.stderr(num_lines=20) == "error message" def test_stderr_empty(self, mock_client: Mock, execution_view: MockExecutionView) -> None: """Test stderr method when stderr is None.""" diff --git a/tests/sdk/test_helpers.py b/tests/sdk/test_helpers.py new file mode 100644 index 000000000..4c2482f5f --- /dev/null +++ b/tests/sdk/test_helpers.py @@ -0,0 +1,30 @@ +"""Tests for helper utilities.""" + +from __future__ import annotations + +from typing import Mapping, TypedDict + +from runloop_api_client.sdk._helpers import filter_params + + +class ExampleParams(TypedDict): + foo: int + bar: str + + +def test_filter_params_with_dict() -> None: + """filter_params should include only keys defined in the TypedDict.""" + params = {"foo": 1, "bar": "value", "extra": True} + + result = filter_params(params, ExampleParams) + + assert result == {"foo": 1, "bar": "value"} + + +def test_filter_params_with_mapping() -> None: + """filter_params should work with Mapping inputs.""" + params: Mapping[str, object] = {"foo": 42, "bar": "hello", "other": "ignored"} + + result = filter_params(params, ExampleParams) + + assert result == {"foo": 42, "bar": "hello"} diff --git a/tests/sdk/test_storage_object.py b/tests/sdk/test_storage_object.py index ff0f8ce9d..36fc8f6e2 100644 --- a/tests/sdk/test_storage_object.py +++ b/tests/sdk/test_storage_object.py @@ -3,8 +3,7 @@ from __future__ import annotations from types import SimpleNamespace -from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import Mock import pytest @@ -127,14 +126,15 @@ def test_get_download_url_with_duration(self, mock_client: Mock) -> None: timeout=30.0, ) - @patch("httpx.get") - def test_download_as_bytes(self, mock_get: Mock, mock_client: Mock) -> None: + def test_download_as_bytes(self, mock_client: Mock) -> None: """Test download_as_bytes method.""" download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") mock_client.objects.download.return_value = download_url_view mock_response = create_mock_httpx_response(content=b"file content") - mock_get.return_value = mock_response + http_client = Mock() + http_client.get.return_value = mock_response + mock_client._client = http_client obj = StorageObject(mock_client, "obj_123", None) result = obj.download_as_bytes( @@ -146,40 +146,25 @@ def test_download_as_bytes(self, mock_get: Mock, mock_client: Mock) -> None: ) assert result == b"file content" - mock_get.assert_called_once_with("https://download.example.com/obj_123") + http_client.get.assert_called_once_with("https://download.example.com/obj_123") mock_response.raise_for_status.assert_called_once() - @patch("httpx.get") - def test_download_as_text_default_encoding(self, mock_get: Mock, mock_client: Mock) -> None: - """Test download_as_text with default encoding.""" + def test_download_as_text(self, mock_client: Mock) -> None: + """Test download_as_text forces UTF-8 encoding.""" download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") mock_client.objects.download.return_value = download_url_view - mock_response = create_mock_httpx_response(text="file content", encoding="utf-8") - mock_get.return_value = mock_response + mock_response = create_mock_httpx_response(text="file content", encoding="latin-1") + http_client = Mock() + http_client.get.return_value = mock_response + mock_client._client = http_client obj = StorageObject(mock_client, "obj_123", None) result = obj.download_as_text() assert result == "file content" assert mock_response.encoding == "utf-8" - mock_get.assert_called_once() - - @patch("httpx.get") - def test_download_as_text_custom_encoding(self, mock_get: Mock, mock_client: Mock) -> None: - """Test download_as_text with custom encoding.""" - download_url_view = SimpleNamespace(download_url="https://download.example.com/obj_123") - mock_client.objects.download.return_value = download_url_view - - mock_response = create_mock_httpx_response(text="file content", encoding="utf-8") - mock_get.return_value = mock_response - - obj = StorageObject(mock_client, "obj_123", None) - result = obj.download_as_text(encoding="latin-1") - - assert result == "file content" - assert mock_response.encoding == "latin-1" - mock_get.assert_called_once() + http_client.get.assert_called_once_with("https://download.example.com/obj_123") def test_delete(self, mock_client: Mock, object_view: MockObjectView) -> None: """Test delete method.""" @@ -204,46 +189,30 @@ def test_delete(self, mock_client: Mock, object_view: MockObjectView) -> None: idempotency_key="key-123", ) - @patch("httpx.put") - def test_upload_content_string(self, mock_put: Mock, mock_client: Mock) -> None: + def test_upload_content_string(self, mock_client: Mock) -> None: """Test upload_content with string.""" mock_response = create_mock_httpx_response() - mock_put.return_value = mock_response + http_client = Mock() + http_client.put.return_value = mock_response + mock_client._client = http_client obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") obj.upload_content("test content") - mock_put.assert_called_once_with("https://upload.example.com", content=b"test content") + http_client.put.assert_called_once_with("https://upload.example.com", content="test content") mock_response.raise_for_status.assert_called_once() - @patch("httpx.put") - def test_upload_content_bytes(self, mock_put: Mock, mock_client: Mock) -> None: + def test_upload_content_bytes(self, mock_client: Mock) -> None: """Test upload_content with bytes.""" mock_response = create_mock_httpx_response() - mock_put.return_value = mock_response + http_client = Mock() + http_client.put.return_value = mock_response + mock_client._client = http_client obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") obj.upload_content(b"test content") - mock_put.assert_called_once_with("https://upload.example.com", content=b"test content") - mock_response.raise_for_status.assert_called_once() - - @patch("httpx.put") - def test_upload_content_path(self, mock_put: Mock, mock_client: Mock, tmp_path: Path) -> None: - """Test upload_content with Path.""" - mock_response = create_mock_httpx_response() - mock_put.return_value = mock_response - - temp_file = tmp_path / "test_file.txt" - temp_file.write_text("test content") - - obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") - obj.upload_content(temp_file) - - mock_put.assert_called_once() - call_args = mock_put.call_args - assert call_args[0][0] == "https://upload.example.com" - assert call_args[1]["content"] == b"test content" + http_client.put.assert_called_once_with("https://upload.example.com", content=b"test content") mock_response.raise_for_status.assert_called_once() def test_upload_content_no_url(self, mock_client: Mock) -> None: @@ -277,17 +246,16 @@ def test_large_file_upload(self, mock_client: Mock) -> None: object_view = SimpleNamespace(id="obj_123", upload_url="https://upload.example.com") mock_client.objects.create.return_value = object_view - with patch("httpx.put") as mock_put: - mock_response = create_mock_httpx_response() - mock_put.return_value = mock_response + http_client = Mock() + mock_response = create_mock_httpx_response() + http_client.put.return_value = mock_response + mock_client._client = http_client - obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") - large_content = b"x" * LARGE_FILE_SIZE # 10MB - obj.upload_content(large_content) + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + large_content = b"x" * LARGE_FILE_SIZE # 10MB + obj.upload_content(large_content) - mock_put.assert_called_once() - call_args = mock_put.call_args - assert len(call_args[1]["content"]) == LARGE_FILE_SIZE + http_client.put.assert_called_once_with("https://upload.example.com", content=large_content) class TestStorageObjectPythonSpecific: @@ -299,33 +267,29 @@ def test_content_type_detection(self, mock_client: Mock, object_view: MockObject client = StorageObjectClient(mock_client) - # Python detects from extension - client.create("test.txt") + # When no content type provided, create forwards only provided params + client.create(name="test.txt") call1 = mock_client.objects.create.call_args[1] - assert call1["content_type"] == "text" + assert "content_type" not in call1 # Explicit content type - client.create("test.bin", content_type="binary") + client.create(name="test.bin", content_type="binary") call2 = mock_client.objects.create.call_args[1] assert call2["content_type"] == "binary" - def test_upload_data_types(self, mock_client: Mock, tmp_path: Path) -> None: + def test_upload_data_types(self, mock_client: Mock) -> None: """Test Python supports more upload data types.""" - with patch("httpx.put") as mock_put: - mock_response = create_mock_httpx_response() - mock_put.return_value = mock_response - - obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") + http_client = Mock() + mock_response = create_mock_httpx_response() + http_client.put.return_value = mock_response + mock_client._client = http_client - # String - obj.upload_content("string content") + obj = StorageObject(mock_client, "obj_123", "https://upload.example.com") - # Bytes - obj.upload_content(b"bytes content") + # String + obj.upload_content("string content") - # Path (Python-specific) - temp_file = tmp_path / "test_file.txt" - temp_file.write_text("file content") - obj.upload_content(temp_file) + # Bytes + obj.upload_content(b"bytes content") - assert mock_put.call_count == 3 + assert http_client.put.call_count == 2 From 5ef1a6149b5cfe53270c601c1c59d8353f00b29c Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 17 Nov 2025 09:50:32 -0800 Subject: [PATCH 41/56] end to end tests --- tests/smoketests/sdk/test_async_blueprint.py | 9 +- tests/smoketests/sdk/test_async_devbox.py | 83 ++++++++++++++----- tests/smoketests/sdk/test_async_snapshot.py | 34 ++++---- .../sdk/test_async_storage_object.py | 30 +++---- tests/smoketests/sdk/test_blueprint.py | 9 +- tests/smoketests/sdk/test_devbox.py | 81 ++++++++++++------ tests/smoketests/sdk/test_snapshot.py | 32 +++---- tests/smoketests/sdk/test_storage_object.py | 30 +++---- 8 files changed, 190 insertions(+), 118 deletions(-) diff --git a/tests/smoketests/sdk/test_async_blueprint.py b/tests/smoketests/sdk/test_async_blueprint.py index 45ec2df34..d9c0a1241 100644 --- a/tests/smoketests/sdk/test_async_blueprint.py +++ b/tests/smoketests/sdk/test_async_blueprint.py @@ -222,7 +222,7 @@ async def test_create_devbox_from_blueprint(self, async_sdk_client: AsyncRunloop try: # Create devbox from the blueprint devbox = await async_sdk_client.devbox.create_from_blueprint_id( - blueprint.id, + blueprint_id=blueprint.id, name=unique_name("sdk-async-devbox-from-blueprint"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -235,9 +235,10 @@ async def test_create_devbox_from_blueprint(self, async_sdk_client: AsyncRunloop assert info.status == "running" # Verify the blueprint's software is installed - result = await devbox.cmd.exec("which python3") + result = await devbox.cmd.exec(command="which python3") assert result.exit_code == 0 assert result.success is True + assert "python" in await result.stdout(num_lines=1) finally: await devbox.shutdown() finally: @@ -255,14 +256,14 @@ async def test_create_multiple_devboxes_from_blueprint(self, async_sdk_client: A try: # Create first devbox devbox1 = await async_sdk_client.devbox.create_from_blueprint_id( - blueprint.id, + blueprint_id=blueprint.id, name=unique_name("sdk-async-devbox-1"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) # Create second devbox devbox2 = await async_sdk_client.devbox.create_from_blueprint_id( - blueprint.id, + blueprint_id=blueprint.id, name=unique_name("sdk-async-devbox-2"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) diff --git a/tests/smoketests/sdk/test_async_devbox.py b/tests/smoketests/sdk/test_async_devbox.py index 12144963f..d34174bea 100644 --- a/tests/smoketests/sdk/test_async_devbox.py +++ b/tests/smoketests/sdk/test_async_devbox.py @@ -106,27 +106,28 @@ class TestAsyncDevboxCommandExecution: @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) async def test_exec_simple_command(self, shared_devbox: AsyncDevbox) -> None: """Test executing a simple command asynchronously.""" - result = await shared_devbox.cmd.exec("echo 'Hello from async SDK!'") + result = await shared_devbox.cmd.exec(command="echo 'Hello from async SDK!'") assert result is not None assert result.exit_code == 0 assert result.success is True - stdout = await result.stdout() + stdout = await result.stdout(num_lines=1) assert "Hello from async SDK!" in stdout @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) async def test_exec_with_exit_code(self, shared_devbox: AsyncDevbox) -> None: """Test command execution captures exit codes correctly.""" - result = await shared_devbox.cmd.exec("exit 42") + result = await shared_devbox.cmd.exec(command="exit 42") assert result.exit_code == 42 assert result.success is False + assert await result.stdout(num_lines=1) == "" @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) async def test_exec_async_command(self, shared_devbox: AsyncDevbox) -> None: """Test executing a command asynchronously with exec_async.""" - execution = await shared_devbox.cmd.exec_async("echo 'Async command' && sleep 1") + execution = await shared_devbox.cmd.exec_async(command="echo 'Async command' && sleep 1") assert execution is not None assert execution.execution_id is not None @@ -136,7 +137,7 @@ async def test_exec_async_command(self, shared_devbox: AsyncDevbox) -> None: assert result.exit_code == 0 assert result.success is True - stdout = await result.stdout() + stdout = await result.stdout(num_lines=2) assert "Async command" in stdout @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) @@ -148,13 +149,16 @@ def stdout_callback(line: str) -> None: stdout_lines.append(line) result = await shared_devbox.cmd.exec( - 'echo "line1" && echo "line2" && echo "line3"', + command='echo "line1" && echo "line2" && echo "line3"', stdout=stdout_callback, ) assert result.success is True assert result.exit_code == 0 + combined_stdout = await result.stdout(num_lines=3) + assert "line1" in combined_stdout + # Verify callback received output assert len(stdout_lines) > 0 stdout_combined = "".join(stdout_lines) @@ -171,19 +175,36 @@ def stderr_callback(line: str) -> None: stderr_lines.append(line) result = await shared_devbox.cmd.exec( - 'echo "error1" >&2 && echo "error2" >&2', + command='echo "error1" >&2 && echo "error2" >&2', stderr=stderr_callback, ) assert result.success is True assert result.exit_code == 0 + combined_stderr = await result.stderr(num_lines=2) + assert "error1" in combined_stderr + # Verify callback received stderr output assert len(stderr_lines) > 0 stderr_combined = "".join(stderr_lines) assert "error1" in stderr_combined assert "error2" in stderr_combined + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_exec_with_large_stdout(self, shared_devbox: AsyncDevbox) -> None: + """Ensure we capture all stdout lines (similar to TS last_n coverage).""" + result = await shared_devbox.cmd.exec( + command="; ".join([f"echo line {i}" for i in range(1, 7)]), + ) + + assert result.exit_code == 0 + lines = (await result.stdout()).strip().split("\n") + assert lines == [f"line {i}" for i in range(1, 7)] + + tail = (await result.stdout(num_lines=3)).strip().split("\n") + assert tail == ["line 4", "line 5", "line 6"] + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) async def test_exec_with_output_callback(self, shared_devbox: AsyncDevbox) -> None: """Test command execution with combined output callback.""" @@ -193,13 +214,16 @@ def output_callback(line: str) -> None: output_lines.append(line) result = await shared_devbox.cmd.exec( - 'echo "stdout1" && echo "stderr1" >&2 && echo "stdout2"', + command='echo "stdout1" && echo "stderr1" >&2 && echo "stdout2"', output=output_callback, ) assert result.success is True assert result.exit_code == 0 + stdout_capture = await result.stdout(num_lines=2) + assert "stdout1" in stdout_capture or "stdout2" in stdout_capture + # Verify callback received both stdout and stderr assert len(output_lines) > 0 output_combined = "".join(output_lines) @@ -214,7 +238,7 @@ def stdout_callback(line: str) -> None: stdout_lines.append(line) execution = await shared_devbox.cmd.exec_async( - 'echo "async output"', + command='echo "async output"', stdout=stdout_callback, ) @@ -225,6 +249,9 @@ def stdout_callback(line: str) -> None: assert result.success is True assert result.exit_code == 0 + async_stdout = await result.stdout(num_lines=1) + assert "async output" in async_stdout + # Verify streaming captured output assert len(stdout_lines) > 0 stdout_combined = "".join(stdout_lines) @@ -241,10 +268,10 @@ async def test_file_write_and_read(self, shared_devbox: AsyncDevbox) -> None: content = "Hello from async SDK file operations!" # Write file - await shared_devbox.file.write(file_path, content) + await shared_devbox.file.write(file_path=file_path, contents=content) # Read file - read_content = await shared_devbox.file.read(file_path) + read_content = await shared_devbox.file.read(file_path=file_path) assert read_content == content @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) @@ -254,10 +281,10 @@ async def test_file_write_bytes(self, shared_devbox: AsyncDevbox) -> None: content = b"Binary content from async SDK" # Write bytes - await shared_devbox.file.write(file_path, content) + await shared_devbox.file.write(file_path=file_path, contents=content.decode("utf-8")) # Read and verify - read_content = await shared_devbox.file.read(file_path) + read_content = await shared_devbox.file.read(file_path=file_path) assert read_content == content.decode("utf-8") @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) @@ -267,10 +294,10 @@ async def test_file_download(self, shared_devbox: AsyncDevbox) -> None: content = "Content to download" # Write file first - await shared_devbox.file.write(file_path, content) + await shared_devbox.file.write(file_path=file_path, contents=content) # Download file - downloaded = await shared_devbox.file.download(file_path) + downloaded = await shared_devbox.file.download(path=file_path) assert isinstance(downloaded, bytes) assert downloaded.decode("utf-8") == content @@ -285,10 +312,10 @@ async def test_file_upload(self, shared_devbox: AsyncDevbox) -> None: try: # Upload file remote_path = "~/uploaded_async_test.txt" - await shared_devbox.file.upload(remote_path, Path(tmp_path)) + await shared_devbox.file.upload(path=remote_path, file=Path(tmp_path)) # Verify by reading - content = await shared_devbox.file.read(remote_path) + content = await shared_devbox.file.read(file_path=remote_path) assert content == "Uploaded content from async SDK" finally: # Cleanup temp file @@ -309,6 +336,10 @@ async def test_suspend_and_resume(self, async_sdk_client: AsyncRunloopSDK) -> No try: # Suspend the devbox suspended_info = await devbox.suspend() + if suspended_info.status != "suspended": + suspended_info = await devbox.await_suspended( + polling_config=PollingConfig(timeout_seconds=120.0, interval_seconds=5.0) + ) assert suspended_info.status == "suspended" # Verify suspended state @@ -317,6 +348,10 @@ async def test_suspend_and_resume(self, async_sdk_client: AsyncRunloopSDK) -> No # Resume the devbox resumed_info = await devbox.resume() + if resumed_info.status != "running": + resumed_info = await devbox.await_running( + polling_config=PollingConfig(timeout_seconds=120.0, interval_seconds=5.0) + ) assert resumed_info.status == "running" # Verify running state @@ -402,7 +437,7 @@ async def test_create_from_blueprint_id(self, async_sdk_client: AsyncRunloopSDK) try: # Create devbox from blueprint devbox = await async_sdk_client.devbox.create_from_blueprint_id( - blueprint.id, + blueprint_id=blueprint.id, name=unique_name("sdk-async-devbox-from-blueprint-id"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -430,7 +465,7 @@ async def test_create_from_blueprint_name(self, async_sdk_client: AsyncRunloopSD try: # Create devbox from blueprint name devbox = await async_sdk_client.devbox.create_from_blueprint_name( - blueprint_name, + blueprint_name=blueprint_name, name=unique_name("sdk-async-devbox-from-blueprint-name"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -455,7 +490,9 @@ async def test_create_from_snapshot(self, async_sdk_client: AsyncRunloopSDK) -> try: # Create a file in the devbox - await source_devbox.file.write("/tmp/test_async_snapshot.txt", "Async snapshot test content") + await source_devbox.file.write( + file_path="/tmp/test_async_snapshot.txt", contents="Async snapshot test content" + ) # Create snapshot snapshot = await source_devbox.snapshot_disk( @@ -465,7 +502,7 @@ async def test_create_from_snapshot(self, async_sdk_client: AsyncRunloopSDK) -> try: # Create devbox from snapshot devbox = await async_sdk_client.devbox.create_from_snapshot( - snapshot.id, + snapshot_id=snapshot.id, name=unique_name("sdk-async-devbox-from-snapshot"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -476,7 +513,7 @@ async def test_create_from_snapshot(self, async_sdk_client: AsyncRunloopSDK) -> assert info.status == "running" # Verify snapshot content is present - content = await devbox.file.read("/tmp/test_async_snapshot.txt") + content = await devbox.file.read(file_path="/tmp/test_async_snapshot.txt") assert content == "Async snapshot test content" finally: await devbox.shutdown() @@ -532,7 +569,7 @@ async def test_snapshot_disk(self, async_sdk_client: AsyncRunloopSDK) -> None: try: # Create a file to snapshot - await devbox.file.write("/tmp/async_snapshot_test.txt", "Async snapshot content") + await devbox.file.write(file_path="/tmp/async_snapshot_test.txt", contents="Async snapshot content") # Create snapshot (waits for completion) snapshot = await devbox.snapshot_disk( diff --git a/tests/smoketests/sdk/test_async_snapshot.py b/tests/smoketests/sdk/test_async_snapshot.py index 66fcb7ee3..bc17386d2 100644 --- a/tests/smoketests/sdk/test_async_snapshot.py +++ b/tests/smoketests/sdk/test_async_snapshot.py @@ -28,7 +28,9 @@ async def test_snapshot_create_and_info(self, async_sdk_client: AsyncRunloopSDK) try: # Create a file to verify snapshot captures state - await devbox.file.write("/tmp/async_snapshot_marker.txt", "This file should be in snapshot") + await devbox.file.write( + file_path="/tmp/async_snapshot_marker.txt", contents="This file should be in snapshot" + ) # Create snapshot snapshot = await devbox.snapshot_disk( @@ -209,7 +211,7 @@ async def test_restore_devbox_from_snapshot(self, async_sdk_client: AsyncRunloop try: # Create unique content in source devbox test_content = f"Async unique content: {unique_name('content')}" - await source_devbox.file.write("/tmp/test_async_restore.txt", test_content) + await source_devbox.file.write(file_path="/tmp/test_async_restore.txt", contents=test_content) # Create snapshot snapshot = await source_devbox.snapshot_disk( @@ -219,7 +221,7 @@ async def test_restore_devbox_from_snapshot(self, async_sdk_client: AsyncRunloop try: # Create new devbox from snapshot restored_devbox = await async_sdk_client.devbox.create_from_snapshot( - snapshot.id, + snapshot_id=snapshot.id, name=unique_name("sdk-async-restored-devbox"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -231,7 +233,7 @@ async def test_restore_devbox_from_snapshot(self, async_sdk_client: AsyncRunloop assert info.status == "running" # Verify content from snapshot is present - restored_content = await restored_devbox.file.read("/tmp/test_async_restore.txt") + restored_content = await restored_devbox.file.read(file_path="/tmp/test_async_restore.txt") assert restored_content == test_content finally: await restored_devbox.shutdown() @@ -251,7 +253,7 @@ async def test_multiple_devboxes_from_snapshot(self, async_sdk_client: AsyncRunl try: # Create content - await source_devbox.file.write("/tmp/async_shared.txt", "Async shared content") + await source_devbox.file.write(file_path="/tmp/async_shared.txt", contents="Async shared content") # Create snapshot snapshot = await source_devbox.snapshot_disk( @@ -261,14 +263,14 @@ async def test_multiple_devboxes_from_snapshot(self, async_sdk_client: AsyncRunl try: # Create first devbox from snapshot devbox1 = await async_sdk_client.devbox.create_from_snapshot( - snapshot.id, + snapshot_id=snapshot.id, name=unique_name("sdk-async-restored-1"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) # Create second devbox from snapshot devbox2 = await async_sdk_client.devbox.create_from_snapshot( - snapshot.id, + snapshot_id=snapshot.id, name=unique_name("sdk-async-restored-2"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -282,8 +284,8 @@ async def test_multiple_devboxes_from_snapshot(self, async_sdk_client: AsyncRunl assert info2.status == "running" # Both should have the snapshot content - content1 = await devbox1.file.read("/tmp/async_shared.txt") - content2 = await devbox2.file.read("/tmp/async_shared.txt") + content1 = await devbox1.file.read(file_path="/tmp/async_shared.txt") + content2 = await devbox2.file.read(file_path="/tmp/async_shared.txt") assert content1 == "Async shared content" assert content2 == "Async shared content" finally: @@ -379,12 +381,12 @@ async def test_snapshot_preserves_file_permissions(self, async_sdk_client: Async try: # Create executable file - await devbox.file.write("/tmp/test_async_exec.sh", "#!/bin/bash\necho 'Hello'") - await devbox.cmd.exec("chmod +x /tmp/test_async_exec.sh") + await devbox.file.write(file_path="/tmp/test_async_exec.sh", contents="#!/bin/bash\necho 'Hello'") + await devbox.cmd.exec(command="chmod +x /tmp/test_async_exec.sh") # Verify it's executable - result = await devbox.cmd.exec("test -x /tmp/test_async_exec.sh && echo 'executable'") - stdout = await result.stdout() + result = await devbox.cmd.exec(command="test -x /tmp/test_async_exec.sh && echo 'executable'") + stdout = await result.stdout(num_lines=1) assert "executable" in stdout # Create snapshot @@ -395,7 +397,7 @@ async def test_snapshot_preserves_file_permissions(self, async_sdk_client: Async try: # Restore from snapshot restored_devbox = await async_sdk_client.devbox.create_from_snapshot( - snapshot.id, + snapshot_id=snapshot.id, name=unique_name("sdk-async-restored-permissions"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -403,9 +405,9 @@ async def test_snapshot_preserves_file_permissions(self, async_sdk_client: Async try: # Verify file is still executable result = await restored_devbox.cmd.exec( - "test -x /tmp/test_async_exec.sh && echo 'still_executable'" + command="test -x /tmp/test_async_exec.sh && echo 'still_executable'" ) - stdout = await result.stdout() + stdout = await result.stdout(num_lines=1) assert "still_executable" in stdout finally: await restored_devbox.shutdown() diff --git a/tests/smoketests/sdk/test_async_storage_object.py b/tests/smoketests/sdk/test_async_storage_object.py index 9d18fc10c..b8d7b546b 100644 --- a/tests/smoketests/sdk/test_async_storage_object.py +++ b/tests/smoketests/sdk/test_async_storage_object.py @@ -106,7 +106,7 @@ async def test_upload_from_text(self, async_sdk_client: AsyncRunloopSDK) -> None assert obj.id is not None # Verify content - downloaded = await obj.download_as_text() + downloaded = await obj.download_as_text(duration_seconds=120) assert downloaded == text_content finally: await obj.delete() @@ -126,7 +126,7 @@ async def test_upload_from_bytes(self, async_sdk_client: AsyncRunloopSDK) -> Non assert obj.id is not None # Verify content - downloaded = await obj.download_as_bytes() + downloaded = await obj.download_as_bytes(duration_seconds=120) assert downloaded == bytes_content finally: await obj.delete() @@ -150,7 +150,7 @@ async def test_upload_from_file(self, async_sdk_client: AsyncRunloopSDK) -> None assert obj.id is not None # Verify content - downloaded = await obj.download_as_text() + downloaded = await obj.download_as_text(duration_seconds=150) assert downloaded == "Content from async file upload" finally: await obj.delete() @@ -171,7 +171,7 @@ async def test_download_as_text(self, async_sdk_client: AsyncRunloopSDK) -> None ) try: - downloaded = await obj.download_as_text() + downloaded = await obj.download_as_text(duration_seconds=90) assert downloaded == content finally: await obj.delete() @@ -187,7 +187,7 @@ async def test_download_as_bytes(self, async_sdk_client: AsyncRunloopSDK) -> Non ) try: - downloaded = await obj.download_as_bytes() + downloaded = await obj.download_as_bytes(duration_seconds=120) assert downloaded == content assert isinstance(downloaded, bytes) finally: @@ -322,12 +322,12 @@ async def test_access_mounted_storage_object(self, async_sdk_client: AsyncRunloo try: # Read the mounted file - content = await devbox.file.read("/home/user/async-mounted-file") + content = await devbox.file.read(file_path="/home/user/async-mounted-file") assert content == "Async content to mount and access" # Verify file exists via command - result = await devbox.cmd.exec("test -f /home/user/async-mounted-file && echo 'exists'") - stdout = await result.stdout() + result = await devbox.cmd.exec(command="test -f /home/user/async-mounted-file && echo 'exists'") + stdout = await result.stdout(num_lines=1) assert "exists" in stdout finally: await devbox.shutdown() @@ -351,7 +351,7 @@ async def test_storage_object_large_content(self, async_sdk_client: AsyncRunloop try: # Verify content - downloaded = await obj.download_as_text() + downloaded = await obj.download_as_text(duration_seconds=120) assert len(downloaded) == len(large_content) assert downloaded == large_content finally: @@ -371,7 +371,7 @@ async def test_storage_object_binary_content(self, async_sdk_client: AsyncRunloo try: # Verify content - downloaded = await obj.download_as_bytes() + downloaded = await obj.download_as_bytes(duration_seconds=120) assert downloaded == binary_content finally: await obj.delete() @@ -386,7 +386,7 @@ async def test_storage_object_empty_content(self, async_sdk_client: AsyncRunloop try: # Verify content - downloaded = await obj.download_as_text() + downloaded = await obj.download_as_text(duration_seconds=60) assert downloaded == "" finally: await obj.delete() @@ -415,7 +415,7 @@ async def test_complete_upload_download_workflow(self, async_sdk_client: AsyncRu assert result.state == "READ_ONLY" # Download and verify - downloaded = await obj.download_as_text() + downloaded = await obj.download_as_text(duration_seconds=120) assert downloaded == original_content # Refresh info @@ -454,12 +454,12 @@ async def test_storage_object_in_devbox_workflow(self, async_sdk_client: AsyncRu try: # Read mounted content in devbox - content = await devbox.file.read("/home/user/async-workflow-data") + content = await devbox.file.read(file_path="/home/user/async-workflow-data") assert content == "Async initial content" # Verify we can work with the file - result = await devbox.cmd.exec("cat /home/user/async-workflow-data") - stdout = await result.stdout() + result = await devbox.cmd.exec(command="cat /home/user/async-workflow-data") + stdout = await result.stdout(num_lines=1) assert "Async initial content" in stdout finally: await devbox.shutdown() diff --git a/tests/smoketests/sdk/test_blueprint.py b/tests/smoketests/sdk/test_blueprint.py index a6852eaf5..5f63e8794 100644 --- a/tests/smoketests/sdk/test_blueprint.py +++ b/tests/smoketests/sdk/test_blueprint.py @@ -222,7 +222,7 @@ def test_create_devbox_from_blueprint(self, sdk_client: RunloopSDK) -> None: try: # Create devbox from the blueprint devbox = sdk_client.devbox.create_from_blueprint_id( - blueprint.id, + blueprint_id=blueprint.id, name=unique_name("sdk-devbox-from-blueprint"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -235,9 +235,10 @@ def test_create_devbox_from_blueprint(self, sdk_client: RunloopSDK) -> None: assert info.status == "running" # Verify the blueprint's software is installed - result = devbox.cmd.exec("which python3") + result = devbox.cmd.exec(command="which python3") assert result.exit_code == 0 assert result.success is True + assert "python" in result.stdout(num_lines=1) finally: devbox.shutdown() finally: @@ -255,14 +256,14 @@ def test_create_multiple_devboxes_from_blueprint(self, sdk_client: RunloopSDK) - try: # Create first devbox devbox1 = sdk_client.devbox.create_from_blueprint_id( - blueprint.id, + blueprint_id=blueprint.id, name=unique_name("sdk-devbox-1"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) # Create second devbox devbox2 = sdk_client.devbox.create_from_blueprint_id( - blueprint.id, + blueprint_id=blueprint.id, name=unique_name("sdk-devbox-2"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) diff --git a/tests/smoketests/sdk/test_devbox.py b/tests/smoketests/sdk/test_devbox.py index 87ab8de49..527a3b53b 100644 --- a/tests/smoketests/sdk/test_devbox.py +++ b/tests/smoketests/sdk/test_devbox.py @@ -107,27 +107,28 @@ class TestDevboxCommandExecution: @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) def test_exec_simple_command(self, shared_devbox: Devbox) -> None: """Test executing a simple command synchronously.""" - result = shared_devbox.cmd.exec("echo 'Hello from SDK!'") + result = shared_devbox.cmd.exec(command="echo 'Hello from SDK!'") assert result is not None assert result.exit_code == 0 assert result.success is True - stdout = result.stdout() + stdout = result.stdout(num_lines=1) assert "Hello from SDK!" in stdout @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) def test_exec_with_exit_code(self, shared_devbox: Devbox) -> None: """Test command execution captures exit codes correctly.""" - result = shared_devbox.cmd.exec("exit 42") + result = shared_devbox.cmd.exec(command="exit 42") assert result.exit_code == 42 assert result.success is False + assert "" == result.stdout(num_lines=1) @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) def test_exec_async_command(self, shared_devbox: Devbox) -> None: """Test executing a command asynchronously.""" - execution = shared_devbox.cmd.exec_async("echo 'Async command' && sleep 1") + execution = shared_devbox.cmd.exec_async(command="echo 'Async command' && sleep 1") assert execution is not None assert execution.execution_id is not None @@ -137,7 +138,7 @@ def test_exec_async_command(self, shared_devbox: Devbox) -> None: assert result.exit_code == 0 assert result.success is True - stdout = result.stdout() + stdout = result.stdout(num_lines=2) assert "Async command" in stdout @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) @@ -149,13 +150,16 @@ def stdout_callback(line: str) -> None: stdout_lines.append(line) result = shared_devbox.cmd.exec( - 'echo "line1" && echo "line2" && echo "line3"', + command='echo "line1" && echo "line2" && echo "line3"', stdout=stdout_callback, ) assert result.success is True assert result.exit_code == 0 + combined_stdout = result.stdout(num_lines=3) + assert "line1" in combined_stdout + # Verify callback received output assert len(stdout_lines) > 0 stdout_combined = "".join(stdout_lines) @@ -172,19 +176,36 @@ def stderr_callback(line: str) -> None: stderr_lines.append(line) result = shared_devbox.cmd.exec( - 'echo "error1" >&2 && echo "error2" >&2', + command='echo "error1" >&2 && echo "error2" >&2', stderr=stderr_callback, ) assert result.success is True assert result.exit_code == 0 + combined_stderr = result.stderr(num_lines=2) + assert "error1" in combined_stderr + # Verify callback received stderr output assert len(stderr_lines) > 0 stderr_combined = "".join(stderr_lines) assert "error1" in stderr_combined assert "error2" in stderr_combined + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_exec_with_large_stdout(self, shared_devbox: Devbox) -> None: + """Ensure we capture all stdout lines (similar to TS last_n coverage).""" + result = shared_devbox.cmd.exec( + command="; ".join([f"echo line {i}" for i in range(1, 7)]), + ) + + assert result.exit_code == 0 + lines = result.stdout().strip().split("\n") + assert lines == [f"line {i}" for i in range(1, 7)] + + tail = result.stdout(num_lines=3).strip().split("\n") + assert tail == ["line 4", "line 5", "line 6"] + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) def test_exec_with_output_callback(self, shared_devbox: Devbox) -> None: """Test command execution with combined output callback.""" @@ -194,13 +215,16 @@ def output_callback(line: str) -> None: output_lines.append(line) result = shared_devbox.cmd.exec( - 'echo "stdout1" && echo "stderr1" >&2 && echo "stdout2"', + command='echo "stdout1" && echo "stderr1" >&2 && echo "stdout2"', output=output_callback, ) assert result.success is True assert result.exit_code == 0 + stdout_capture = result.stdout(num_lines=2) + assert "stdout1" in stdout_capture or "stdout2" in stdout_capture + # Verify callback received both stdout and stderr assert len(output_lines) > 0 output_combined = "".join(output_lines) @@ -215,7 +239,7 @@ def stdout_callback(line: str) -> None: stdout_lines.append(line) execution = shared_devbox.cmd.exec_async( - 'echo "async output"', + command='echo "async output"', stdout=stdout_callback, ) @@ -226,6 +250,9 @@ def stdout_callback(line: str) -> None: assert result.success is True assert result.exit_code == 0 + async_stdout = result.stdout(num_lines=1) + assert "async output" in async_stdout + # Verify streaming captured output assert len(stdout_lines) > 0 stdout_combined = "".join(stdout_lines) @@ -242,10 +269,10 @@ def test_file_write_and_read(self, shared_devbox: Devbox) -> None: content = "Hello from SDK file operations!" # Write file - shared_devbox.file.write(file_path, content) + shared_devbox.file.write(file_path=file_path, contents=content) # Read file - read_content = shared_devbox.file.read(file_path) + read_content = shared_devbox.file.read(file_path=file_path) assert read_content == content @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) @@ -255,10 +282,10 @@ def test_file_write_bytes(self, shared_devbox: Devbox) -> None: content = b"Binary content from SDK" # Write bytes - shared_devbox.file.write(file_path, content) + shared_devbox.file.write(file_path=file_path, contents=content.decode("utf-8")) # Read and verify - read_content = shared_devbox.file.read(file_path) + read_content = shared_devbox.file.read(file_path=file_path) assert read_content == content.decode("utf-8") @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) @@ -268,10 +295,10 @@ def test_file_download(self, shared_devbox: Devbox) -> None: content = "Content to download" # Write file first - shared_devbox.file.write(file_path, content) + shared_devbox.file.write(file_path=file_path, contents=content) # Download file - downloaded = shared_devbox.file.download(file_path) + downloaded = shared_devbox.file.download(path=file_path) assert isinstance(downloaded, bytes) assert downloaded.decode("utf-8") == content @@ -286,10 +313,10 @@ def test_file_upload(self, shared_devbox: Devbox) -> None: try: # Upload file remote_path = "~/uploaded_test.txt" - shared_devbox.file.upload(remote_path, Path(tmp_path)) + shared_devbox.file.upload(path=remote_path, file=Path(tmp_path)) # Verify by reading - content = shared_devbox.file.read(remote_path) + content = shared_devbox.file.read(file_path=remote_path) assert content == "Uploaded content from SDK" finally: # Cleanup temp file @@ -309,7 +336,9 @@ def test_suspend_and_resume(self, sdk_client: RunloopSDK) -> None: try: # Suspend the devbox - suspended_info = devbox.suspend() + suspended_info = devbox.suspend( + polling_config=PollingConfig(timeout_seconds=120.0, interval_seconds=5.0), + ) assert suspended_info.status == "suspended" # Verify suspended state @@ -317,7 +346,9 @@ def test_suspend_and_resume(self, sdk_client: RunloopSDK) -> None: assert info.status == "suspended" # Resume the devbox - resumed_info = devbox.resume() + resumed_info = devbox.resume( + polling_config=PollingConfig(timeout_seconds=120.0, interval_seconds=5.0), + ) assert resumed_info.status == "running" # Verify running state @@ -403,7 +434,7 @@ def test_create_from_blueprint_id(self, sdk_client: RunloopSDK) -> None: try: # Create devbox from blueprint devbox = sdk_client.devbox.create_from_blueprint_id( - blueprint.id, + blueprint_id=blueprint.id, name=unique_name("sdk-devbox-from-blueprint-id"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -431,7 +462,7 @@ def test_create_from_blueprint_name(self, sdk_client: RunloopSDK) -> None: try: # Create devbox from blueprint name devbox = sdk_client.devbox.create_from_blueprint_name( - blueprint_name, + blueprint_name=blueprint_name, name=unique_name("sdk-devbox-from-blueprint-name"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -456,7 +487,7 @@ def test_create_from_snapshot(self, sdk_client: RunloopSDK) -> None: try: # Create a file in the devbox - source_devbox.file.write("/tmp/test_snapshot.txt", "Snapshot test content") + source_devbox.file.write(file_path="/tmp/test_snapshot.txt", contents="Snapshot test content") # Create snapshot snapshot = source_devbox.snapshot_disk( @@ -466,7 +497,7 @@ def test_create_from_snapshot(self, sdk_client: RunloopSDK) -> None: try: # Create devbox from snapshot devbox = sdk_client.devbox.create_from_snapshot( - snapshot.id, + snapshot_id=snapshot.id, name=unique_name("sdk-devbox-from-snapshot"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -477,7 +508,7 @@ def test_create_from_snapshot(self, sdk_client: RunloopSDK) -> None: assert info.status == "running" # Verify snapshot content is present - content = devbox.file.read("/tmp/test_snapshot.txt") + content = devbox.file.read(file_path="/tmp/test_snapshot.txt") assert content == "Snapshot test content" finally: devbox.shutdown() @@ -533,7 +564,7 @@ def test_snapshot_disk(self, sdk_client: RunloopSDK) -> None: try: # Create a file to snapshot - devbox.file.write("/tmp/snapshot_test.txt", "Snapshot content") + devbox.file.write(file_path="/tmp/snapshot_test.txt", contents="Snapshot content") # Create snapshot (waits for completion) snapshot = devbox.snapshot_disk( diff --git a/tests/smoketests/sdk/test_snapshot.py b/tests/smoketests/sdk/test_snapshot.py index 9df1dd215..a5b18c384 100644 --- a/tests/smoketests/sdk/test_snapshot.py +++ b/tests/smoketests/sdk/test_snapshot.py @@ -28,7 +28,7 @@ def test_snapshot_create_and_info(self, sdk_client: RunloopSDK) -> None: try: # Create a file to verify snapshot captures state - devbox.file.write("/tmp/snapshot_marker.txt", "This file should be in snapshot") + devbox.file.write(file_path="/tmp/snapshot_marker.txt", contents="This file should be in snapshot") # Create snapshot snapshot = devbox.snapshot_disk( @@ -210,7 +210,7 @@ def test_restore_devbox_from_snapshot(self, sdk_client: RunloopSDK) -> None: try: # Create unique content in source devbox test_content = f"Unique content: {unique_name('content')}" - source_devbox.file.write("/tmp/test_restore.txt", test_content) + source_devbox.file.write(file_path="/tmp/test_restore.txt", contents=test_content) # Create snapshot snapshot = source_devbox.snapshot_disk( @@ -220,7 +220,7 @@ def test_restore_devbox_from_snapshot(self, sdk_client: RunloopSDK) -> None: try: # Create new devbox from snapshot restored_devbox = sdk_client.devbox.create_from_snapshot( - snapshot.id, + snapshot_id=snapshot.id, name=unique_name("sdk-restored-devbox"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -232,7 +232,7 @@ def test_restore_devbox_from_snapshot(self, sdk_client: RunloopSDK) -> None: assert info.status == "running" # Verify content from snapshot is present - restored_content = restored_devbox.file.read("/tmp/test_restore.txt") + restored_content = restored_devbox.file.read(file_path="/tmp/test_restore.txt") assert restored_content == test_content finally: restored_devbox.shutdown() @@ -252,7 +252,7 @@ def test_multiple_devboxes_from_snapshot(self, sdk_client: RunloopSDK) -> None: try: # Create content - source_devbox.file.write("/tmp/shared.txt", "Shared content") + source_devbox.file.write(file_path="/tmp/shared.txt", contents="Shared content") # Create snapshot snapshot = source_devbox.snapshot_disk( @@ -262,14 +262,14 @@ def test_multiple_devboxes_from_snapshot(self, sdk_client: RunloopSDK) -> None: try: # Create first devbox from snapshot devbox1 = sdk_client.devbox.create_from_snapshot( - snapshot.id, + snapshot_id=snapshot.id, name=unique_name("sdk-restored-1"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) # Create second devbox from snapshot devbox2 = sdk_client.devbox.create_from_snapshot( - snapshot.id, + snapshot_id=snapshot.id, name=unique_name("sdk-restored-2"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -281,8 +281,8 @@ def test_multiple_devboxes_from_snapshot(self, sdk_client: RunloopSDK) -> None: assert devbox2.get_info().status == "running" # Both should have the snapshot content - content1 = devbox1.file.read("/tmp/shared.txt") - content2 = devbox2.file.read("/tmp/shared.txt") + content1 = devbox1.file.read(file_path="/tmp/shared.txt") + content2 = devbox2.file.read(file_path="/tmp/shared.txt") assert content1 == "Shared content" assert content2 == "Shared content" finally: @@ -378,12 +378,12 @@ def test_snapshot_preserves_file_permissions(self, sdk_client: RunloopSDK) -> No try: # Create executable file - devbox.file.write("/tmp/test_exec.sh", "#!/bin/bash\necho 'Hello'") - devbox.cmd.exec("chmod +x /tmp/test_exec.sh") + devbox.file.write(file_path="/tmp/test_exec.sh", contents="#!/bin/bash\necho 'Hello'") + devbox.cmd.exec(command="chmod +x /tmp/test_exec.sh") # Verify it's executable - result = devbox.cmd.exec("test -x /tmp/test_exec.sh && echo 'executable'") - assert "executable" in result.stdout() + result = devbox.cmd.exec(command="test -x /tmp/test_exec.sh && echo 'executable'") + assert "executable" in result.stdout(num_lines=1) # Create snapshot snapshot = devbox.snapshot_disk( @@ -393,15 +393,15 @@ def test_snapshot_preserves_file_permissions(self, sdk_client: RunloopSDK) -> No try: # Restore from snapshot restored_devbox = sdk_client.devbox.create_from_snapshot( - snapshot.id, + snapshot_id=snapshot.id, name=unique_name("sdk-restored-permissions"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) try: # Verify file is still executable - result = restored_devbox.cmd.exec("test -x /tmp/test_exec.sh && echo 'still_executable'") - assert "still_executable" in result.stdout() + result = restored_devbox.cmd.exec(command="test -x /tmp/test_exec.sh && echo 'still_executable'") + assert "still_executable" in result.stdout(num_lines=1) finally: restored_devbox.shutdown() finally: diff --git a/tests/smoketests/sdk/test_storage_object.py b/tests/smoketests/sdk/test_storage_object.py index eccbf140e..cb0ce557e 100644 --- a/tests/smoketests/sdk/test_storage_object.py +++ b/tests/smoketests/sdk/test_storage_object.py @@ -106,7 +106,7 @@ def test_upload_from_text(self, sdk_client: RunloopSDK) -> None: assert obj.id is not None # Verify content - downloaded = obj.download_as_text() + downloaded = obj.download_as_text(duration_seconds=120) assert downloaded == text_content finally: obj.delete() @@ -126,7 +126,7 @@ def test_upload_from_bytes(self, sdk_client: RunloopSDK) -> None: assert obj.id is not None # Verify content - downloaded = obj.download_as_bytes() + downloaded = obj.download_as_bytes(duration_seconds=120) assert downloaded == bytes_content finally: obj.delete() @@ -150,7 +150,7 @@ def test_upload_from_file(self, sdk_client: RunloopSDK) -> None: assert obj.id is not None # Verify content - downloaded = obj.download_as_text() + downloaded = obj.download_as_text(duration_seconds=150) assert downloaded == "Content from file upload" finally: obj.delete() @@ -171,7 +171,7 @@ def test_download_as_text(self, sdk_client: RunloopSDK) -> None: ) try: - downloaded = obj.download_as_text() + downloaded = obj.download_as_text(duration_seconds=120) assert downloaded == content finally: obj.delete() @@ -187,7 +187,7 @@ def test_download_as_bytes(self, sdk_client: RunloopSDK) -> None: ) try: - downloaded = obj.download_as_bytes() + downloaded = obj.download_as_bytes(duration_seconds=120) assert downloaded == content assert isinstance(downloaded, bytes) finally: @@ -322,12 +322,12 @@ def test_access_mounted_storage_object(self, sdk_client: RunloopSDK) -> None: try: # Read the mounted file - content = devbox.file.read("/home/user/mounted-file") + content = devbox.file.read(file_path="/home/user/mounted-file") assert content == "Content to mount and access" # Verify file exists via command - result = devbox.cmd.exec("test -f /home/user/mounted-file && echo 'exists'") - assert "exists" in result.stdout() + result = devbox.cmd.exec(command="test -f /home/user/mounted-file && echo 'exists'") + assert "exists" in result.stdout(num_lines=1) finally: devbox.shutdown() finally: @@ -350,7 +350,7 @@ def test_storage_object_large_content(self, sdk_client: RunloopSDK) -> None: try: # Verify content - downloaded = obj.download_as_text() + downloaded = obj.download_as_text(duration_seconds=120) assert len(downloaded) == len(large_content) assert downloaded == large_content finally: @@ -370,7 +370,7 @@ def test_storage_object_binary_content(self, sdk_client: RunloopSDK) -> None: try: # Verify content - downloaded = obj.download_as_bytes() + downloaded = obj.download_as_bytes(duration_seconds=120) assert downloaded == binary_content finally: obj.delete() @@ -385,7 +385,7 @@ def test_storage_object_empty_content(self, sdk_client: RunloopSDK) -> None: try: # Verify content - downloaded = obj.download_as_text() + downloaded = obj.download_as_text(duration_seconds=90) assert downloaded == "" finally: obj.delete() @@ -414,7 +414,7 @@ def test_complete_upload_download_workflow(self, sdk_client: RunloopSDK) -> None assert result.state == "READ_ONLY" # Download and verify - downloaded = obj.download_as_text() + downloaded = obj.download_as_text(duration_seconds=120) assert downloaded == original_content # Refresh info @@ -453,12 +453,12 @@ def test_storage_object_in_devbox_workflow(self, sdk_client: RunloopSDK) -> None try: # Read mounted content in devbox - content = devbox.file.read("/home/user/workflow-data") + content = devbox.file.read(file_path="/home/user/workflow-data") assert content == "Initial content" # Verify we can work with the file - result = devbox.cmd.exec("cat /home/user/workflow-data") - assert "Initial content" in result.stdout() + result = devbox.cmd.exec(command="cat /home/user/workflow-data") + assert "Initial content" in result.stdout(num_lines=1) finally: devbox.shutdown() finally: From 35282968213645dea2b249c0f4660f229de0ea95 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 17 Nov 2025 13:29:47 -0800 Subject: [PATCH 42/56] uv.lock version update --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 41d171c3c..f1cb1d0ea 100644 --- a/uv.lock +++ b/uv.lock @@ -1238,7 +1238,7 @@ wheels = [ [[package]] name = "runloop-api-client" -version = "0.66.1" +version = "0.67.0" source = { editable = "." } dependencies = [ { name = "anyio" }, From 6d3e81993157ab05f910e2ad8057e38b2cf9ebbe Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 17 Nov 2025 14:21:56 -0800 Subject: [PATCH 43/56] add maintainence comments for manually edited types files --- src/runloop_api_client/types/devbox_create_params.py | 9 +++++++++ src/runloop_api_client/types/object_create_params.py | 2 ++ 2 files changed, 11 insertions(+) diff --git a/src/runloop_api_client/types/devbox_create_params.py b/src/runloop_api_client/types/devbox_create_params.py index 6068e4d61..33a2488e0 100644 --- a/src/runloop_api_client/types/devbox_create_params.py +++ b/src/runloop_api_client/types/devbox_create_params.py @@ -11,7 +11,14 @@ __all__ = ["DevboxCreateParams"] +# We split up the original DevboxCreateParams into two nested classes to enable us to +# omit blueprint_id, blueprint_name, and snapshot_id when we unpack the TypedDict +# params for methods like create_from_blueprint_id, create_from_blueprint_name, and +# create_from_snapshot, which shouldn't allow you to specify creation source kwargs. + +# DevboxBaseCreateParams should contain all the fields that are common to all the +# create methods. Any updates to the OpenAPI spec should be reflected here. class DevboxBaseCreateParams(TypedDict, total=False): code_mounts: Optional[Iterable[CodeMountParameters]] """A list of code mounts to be included in the Devbox.""" @@ -53,6 +60,8 @@ class DevboxBaseCreateParams(TypedDict, total=False): """ +# DevboxCreateParams should only implement fields that specify the devbox creation source. +# These are omitted from specialized create methods. class DevboxCreateParams(DevboxBaseCreateParams, total=False): blueprint_id: Optional[str] """Blueprint ID to use for the Devbox. diff --git a/src/runloop_api_client/types/object_create_params.py b/src/runloop_api_client/types/object_create_params.py index 99b77bcd0..99cdff0e7 100644 --- a/src/runloop_api_client/types/object_create_params.py +++ b/src/runloop_api_client/types/object_create_params.py @@ -8,6 +8,8 @@ __all__ = ["ObjectCreateParams"] +# We manually define the content type here to use as a type hint in the SDK. +# If the API supports new content types, update this list accordingly. ContentType = Literal["unspecified", "text", "binary", "gzip", "tar", "tgz"] From 5c900b09831a159111de35f9273a959ed203d84a Mon Sep 17 00:00:00 2001 From: sid-rl Date: Mon, 17 Nov 2025 17:37:10 -0800 Subject: [PATCH 44/56] paginated stdout/stderr in ExecutionResult (#670) * added pagination logic to stdout/stderr * unit test adjustments * added smoke tests and todos for fixing and testing output line counting logic --- .../sdk/async_execution_result.py | 102 +++++++--- .../sdk/execution_result.py | 102 +++++++--- tests/sdk/async_devbox/test_core.py | 6 - tests/sdk/conftest.py | 2 + tests/sdk/test_async_clients.py | 14 -- tests/sdk/test_async_execution.py | 18 +- tests/sdk/test_async_execution_result.py | 182 +++++++++++++++++- tests/sdk/test_clients.py | 11 -- tests/sdk/test_execution.py | 15 +- tests/sdk/test_execution_result.py | 159 +++++++++++++++ tests/sdk/test_storage_object.py | 17 -- tests/smoketests/sdk/test_async_devbox.py | 68 +++++++ tests/smoketests/sdk/test_devbox.py | 68 +++++++ 13 files changed, 638 insertions(+), 126 deletions(-) diff --git a/src/runloop_api_client/sdk/async_execution_result.py b/src/runloop_api_client/sdk/async_execution_result.py index 75bffe3ec..4c135f558 100644 --- a/src/runloop_api_client/sdk/async_execution_result.py +++ b/src/runloop_api_client/sdk/async_execution_result.py @@ -2,9 +2,12 @@ from __future__ import annotations -from typing_extensions import Optional, override +from typing import Callable, Optional, Awaitable +from typing_extensions import override from .._client import AsyncRunloop +from .._streaming import AsyncStream +from ..types.devboxes.execution_update_chunk import ExecutionUpdateChunk from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView @@ -48,32 +51,85 @@ def failed(self) -> bool: exit_code = self.exit_code return exit_code is not None and exit_code != 0 - # TODO: add pagination support once we have it in the API + def _count_non_empty_lines(self, text: str) -> int: + """Count non-empty lines in text, excluding trailing empty strings.""" + if not text: + return 0 + # Remove trailing newlines, split, and count non-empty lines + return sum(1 for line in text.rstrip("\n").split("\n") if line) + + def _get_last_n_lines(self, text: str, n: int) -> str: + """Extract the last N lines from text.""" + # TODO: Fix inconsistency - _count_non_empty_lines counts non-empty lines but + # _get_last_n_lines returns N lines (may include empty ones). This means + # num_lines=50 might return fewer than 50 non-empty lines. Should either: + # 1. Make _get_last_n_lines return N non-empty lines, OR + # 2. Make _count_non_empty_lines count all lines + # This affects both Python and TypeScript SDKs - fix together. + if n <= 0 or not text: + return "" + # Remove trailing newlines before splitting and slicing + return "\n".join(text.rstrip("\n").split("\n")[-n:]) + + async def _get_output( + self, + current_output: str, + is_truncated: bool, + num_lines: Optional[int], + stream_fn: Callable[[], Awaitable[AsyncStream[ExecutionUpdateChunk]]], + ) -> str: + """Common logic for getting output with optional line limiting and streaming.""" + # Check if we have enough lines already + if num_lines is not None and (not is_truncated or self._count_non_empty_lines(current_output) >= num_lines): + return self._get_last_n_lines(current_output, num_lines) + + # Stream full output if truncated + if is_truncated: + stream = await stream_fn() + output = "".join([chunk.output async for chunk in stream]) + return self._get_last_n_lines(output, num_lines) if num_lines is not None else output + + # Return current output, optionally limited to last N lines + return self._get_last_n_lines(current_output, num_lines) if num_lines is not None else current_output + async def stdout(self, num_lines: Optional[int] = None) -> str: - text = self._result.stdout or "" - return _tail_lines(text, num_lines) + """ + Return captured standard output, streaming full output if truncated. + + Args: + num_lines: Optional number of lines to return from the end (most recent) + + Returns: + stdout content, optionally limited to last N lines + """ + return await self._get_output( + self._result.stdout or "", + self._result.stdout_truncated is True, + num_lines, + lambda: self._client.devboxes.executions.stream_stdout_updates( + self.execution_id, devbox_id=self._devbox_id + ), + ) - # TODO: add pagination support once we have it in the API async def stderr(self, num_lines: Optional[int] = None) -> str: - text = self._result.stderr or "" - return _tail_lines(text, num_lines) + """ + Return captured standard error, streaming full output if truncated. + + Args: + num_lines: Optional number of lines to return from the end (most recent) + + Returns: + stderr content, optionally limited to last N lines + """ + return await self._get_output( + self._result.stderr or "", + self._result.stderr_truncated is True, + num_lines, + lambda: self._client.devboxes.executions.stream_stderr_updates( + self.execution_id, devbox_id=self._devbox_id + ), + ) @property def raw(self) -> DevboxAsyncExecutionDetailView: return self._result - - -def _tail_lines(text: str, num_lines: Optional[int]) -> str: - if not text: - return "" - if num_lines is None or num_lines <= 0: - return text - - lines = text.splitlines() - if not lines: - return text - - clipped = "\n".join(lines[-num_lines:]) - if text.endswith("\n"): - clipped += "\n" - return clipped diff --git a/src/runloop_api_client/sdk/execution_result.py b/src/runloop_api_client/sdk/execution_result.py index a7dc6547b..17f0e624e 100644 --- a/src/runloop_api_client/sdk/execution_result.py +++ b/src/runloop_api_client/sdk/execution_result.py @@ -2,10 +2,12 @@ from __future__ import annotations -from typing import Optional +from typing import Callable, Optional from typing_extensions import override from .._client import Runloop +from .._streaming import Stream +from ..types.devboxes.execution_update_chunk import ExecutionUpdateChunk from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView @@ -56,35 +58,85 @@ def failed(self) -> bool: exit_code = self.exit_code return exit_code is not None and exit_code != 0 - # TODO: add pagination support once we have it in the API + def _count_non_empty_lines(self, text: str) -> int: + """Count non-empty lines in text, excluding trailing empty strings.""" + if not text: + return 0 + # Remove trailing newlines, split, and count non-empty lines + return sum(1 for line in text.rstrip("\n").split("\n") if line) + + def _get_last_n_lines(self, text: str, n: int) -> str: + """Extract the last N lines from text.""" + # TODO: Fix inconsistency - _count_non_empty_lines counts non-empty lines but + # _get_last_n_lines returns N lines (may include empty ones). This means + # num_lines=50 might return fewer than 50 non-empty lines. Should either: + # 1. Make _get_last_n_lines return N non-empty lines, OR + # 2. Make _count_non_empty_lines count all lines + # This affects both Python and TypeScript SDKs - fix together. + if n <= 0 or not text: + return "" + # Remove trailing newlines before splitting and slicing + return "\n".join(text.rstrip("\n").split("\n")[-n:]) + + def _get_output( + self, + current_output: str, + is_truncated: bool, + num_lines: Optional[int], + stream_fn: Callable[[], Stream[ExecutionUpdateChunk]], + ) -> str: + """Common logic for getting output with optional line limiting and streaming.""" + # Check if we have enough lines already + if num_lines is not None and (not is_truncated or self._count_non_empty_lines(current_output) >= num_lines): + return self._get_last_n_lines(current_output, num_lines) + + # Stream full output if truncated + if is_truncated: + output = "".join(chunk.output for chunk in stream_fn()) + return self._get_last_n_lines(output, num_lines) if num_lines is not None else output + + # Return current output, optionally limited to last N lines + return self._get_last_n_lines(current_output, num_lines) if num_lines is not None else current_output + def stdout(self, num_lines: Optional[int] = None) -> str: - """Return captured standard output.""" - text = self._result.stdout or "" - return _tail_lines(text, num_lines) + """ + Return captured standard output, streaming full output if truncated. + + Args: + num_lines: Optional number of lines to return from the end (most recent) + + Returns: + stdout content, optionally limited to last N lines + """ + return self._get_output( + self._result.stdout or "", + self._result.stdout_truncated is True, + num_lines, + lambda: self._client.devboxes.executions.stream_stdout_updates( + self.execution_id, devbox_id=self._devbox_id + ), + ) - # TODO: add pagination support once we have it in the API def stderr(self, num_lines: Optional[int] = None) -> str: - """Return captured standard error.""" - text = self._result.stderr or "" - return _tail_lines(text, num_lines) + """ + Return captured standard error, streaming full output if truncated. + + Args: + num_lines: Optional number of lines to return from the end (most recent) + + Returns: + stderr content, optionally limited to last N lines + """ + return self._get_output( + self._result.stderr or "", + self._result.stderr_truncated is True, + num_lines, + lambda: self._client.devboxes.executions.stream_stderr_updates( + self.execution_id, devbox_id=self._devbox_id + ), + ) @property def raw(self) -> DevboxAsyncExecutionDetailView: """Access the underlying API response.""" return self._result - - -def _tail_lines(text: str, num_lines: Optional[int]) -> str: - if not text: - return "" - if num_lines is None or num_lines <= 0: - return text - - lines = text.splitlines() - if not lines: - return text - - clipped = "\n".join(lines[-num_lines:]) - if text.endswith("\n"): - clipped += "\n" - return clipped diff --git a/tests/sdk/async_devbox/test_core.py b/tests/sdk/async_devbox/test_core.py index 5af02a3e3..60dcf7fdc 100644 --- a/tests/sdk/async_devbox/test_core.py +++ b/tests/sdk/async_devbox/test_core.py @@ -137,11 +137,9 @@ async def test_shutdown(self, mock_async_client: AsyncMock, devbox_view: MockDev async def test_suspend(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: """Test suspend method.""" mock_async_client.devboxes.suspend = AsyncMock(return_value=devbox_view) - polling_config = PollingConfig(timeout_seconds=60.0) devbox = AsyncDevbox(mock_async_client, "dev_123") result = await devbox.suspend( - polling_config=polling_config, extra_headers={"X-Custom": "value"}, extra_query={"param": "value"}, extra_body={"key": "value"}, @@ -152,7 +150,6 @@ async def test_suspend(self, mock_async_client: AsyncMock, devbox_view: MockDevb assert result == devbox_view mock_async_client.devboxes.suspend.assert_called_once_with( "dev_123", - polling_config=polling_config, extra_headers={"X-Custom": "value"}, extra_query={"param": "value"}, extra_body={"key": "value"}, @@ -164,11 +161,9 @@ async def test_suspend(self, mock_async_client: AsyncMock, devbox_view: MockDevb async def test_resume(self, mock_async_client: AsyncMock, devbox_view: MockDevboxView) -> None: """Test resume method.""" mock_async_client.devboxes.resume = AsyncMock(return_value=devbox_view) - polling_config = PollingConfig(timeout_seconds=60.0) devbox = AsyncDevbox(mock_async_client, "dev_123") result = await devbox.resume( - polling_config=polling_config, extra_headers={"X-Custom": "value"}, extra_query={"param": "value"}, extra_body={"key": "value"}, @@ -179,7 +174,6 @@ async def test_resume(self, mock_async_client: AsyncMock, devbox_view: MockDevbo assert result == devbox_view mock_async_client.devboxes.resume.assert_called_once_with( "dev_123", - polling_config=polling_config, extra_headers={"X-Custom": "value"}, extra_query={"param": "value"}, extra_body={"key": "value"}, diff --git a/tests/sdk/conftest.py b/tests/sdk/conftest.py index 50bcf196d..436c4de53 100644 --- a/tests/sdk/conftest.py +++ b/tests/sdk/conftest.py @@ -55,6 +55,8 @@ class MockExecutionView: exit_status: int = 0 stdout: str = "output" stderr: str = "" + stdout_truncated: bool = False + stderr_truncated: bool = False @dataclass diff --git a/tests/sdk/test_async_clients.py b/tests/sdk/test_async_clients.py index 2a2f191e2..882e54e7d 100644 --- a/tests/sdk/test_async_clients.py +++ b/tests/sdk/test_async_clients.py @@ -215,20 +215,6 @@ async def test_create(self, mock_async_client: AsyncMock, object_view: MockObjec metadata={"key": "value"}, ) - @pytest.mark.asyncio - async def test_create_auto_detect_content_type( - self, mock_async_client: AsyncMock, object_view: MockObjectView - ) -> None: - """Test create auto-detects content type.""" - mock_async_client.objects.create = AsyncMock(return_value=object_view) - - client = AsyncStorageObjectClient(mock_async_client) - obj = await client.create(name="test.txt") - - assert isinstance(obj, AsyncStorageObject) - call_kwargs = mock_async_client.objects.create.call_args[1] - assert "content_type" not in call_kwargs - def test_from_id(self, mock_async_client: AsyncMock) -> None: """Test from_id method.""" client = AsyncStorageObjectClient(mock_async_client) diff --git a/tests/sdk/test_async_execution.py b/tests/sdk/test_async_execution.py index 6ce89a6cb..b33b4cf1f 100644 --- a/tests/sdk/test_async_execution.py +++ b/tests/sdk/test_async_execution.py @@ -159,6 +159,8 @@ async def test_result_needs_polling(self, mock_async_client: AsyncMock) -> None: exit_status=0, stdout="output", stderr="", + stdout_truncated=False, + stderr_truncated=False, ) mock_async_client.devboxes.wait_for_command = AsyncMock(return_value=completed_execution) @@ -263,19 +265,3 @@ async def test_kill(self, mock_async_client: AsyncMock, execution_view: MockExec "exec_123", devbox_id="dev_123", ) - - @pytest.mark.asyncio - async def test_kill_with_process_group( - self, mock_async_client: AsyncMock, execution_view: MockExecutionView - ) -> None: - """Test kill with kill_process_group.""" - mock_async_client.devboxes.executions.kill = AsyncMock(return_value=None) - - execution = AsyncExecution(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] - await execution.kill(kill_process_group=True) - - mock_async_client.devboxes.executions.kill.assert_awaited_once_with( - "exec_123", - devbox_id="dev_123", - kill_process_group=True, - ) diff --git a/tests/sdk/test_async_execution_result.py b/tests/sdk/test_async_execution_result.py index acb885900..f73a1e2bb 100644 --- a/tests/sdk/test_async_execution_result.py +++ b/tests/sdk/test_async_execution_result.py @@ -3,7 +3,7 @@ from __future__ import annotations from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import Mock, AsyncMock import pytest @@ -45,6 +45,8 @@ def test_exit_code_none(self, mock_async_client: AsyncMock) -> None: exit_status=None, stdout="", stderr="", + stdout_truncated=False, + stderr_truncated=False, ) result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] assert result.exit_code is None @@ -63,6 +65,8 @@ def test_success_false(self, mock_async_client: AsyncMock) -> None: exit_status=1, stdout="", stderr="error", + stdout_truncated=False, + stderr_truncated=False, ) result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] assert result.success is False @@ -81,6 +85,8 @@ def test_failed_true(self, mock_async_client: AsyncMock) -> None: exit_status=1, stdout="", stderr="error", + stdout_truncated=False, + stderr_truncated=False, ) result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] assert result.failed is True @@ -94,6 +100,8 @@ def test_failed_none(self, mock_async_client: AsyncMock) -> None: exit_status=None, stdout="", stderr="", + stdout_truncated=False, + stderr_truncated=False, ) result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] assert result.failed is False @@ -115,6 +123,8 @@ async def test_stdout_empty(self, mock_async_client: AsyncMock) -> None: exit_status=0, stdout=None, stderr="", + stdout_truncated=False, + stderr_truncated=False, ) result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] assert await result.stdout() == "" @@ -129,6 +139,8 @@ async def test_stderr(self, mock_async_client: AsyncMock) -> None: exit_status=1, stdout="", stderr="error message", + stdout_truncated=False, + stderr_truncated=False, ) result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] assert await result.stderr() == "error message" @@ -144,3 +156,171 @@ def test_raw_property(self, mock_async_client: AsyncMock, execution_view: MockEx """Test raw property.""" result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.raw == execution_view + + @pytest.mark.asyncio + async def test_stdout_with_truncation_and_streaming( + self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock + ) -> None: + """Test stdout streams full output when truncated.""" + from types import SimpleNamespace as SN + + # Mock chunk data + async def mock_iter(): + yield SN(output="line1\n") + yield SN(output="line2\n") + yield SN(output="line3\n") + + mock_async_stream.__aiter__ = Mock(return_value=mock_iter()) + + # Setup client mock to return our stream + mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) + + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="partial", + stderr="", + stdout_truncated=True, + stderr_truncated=False, + ) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] + + # Should stream full output + output = await result.stdout() + assert output == "line1\nline2\nline3\n" + mock_async_client.devboxes.executions.stream_stdout_updates.assert_called_once_with( + "exec_123", devbox_id="dev_123" + ) + + @pytest.mark.asyncio + async def test_stderr_with_truncation_and_streaming( + self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock + ) -> None: + """Test stderr streams full output when truncated.""" + from types import SimpleNamespace as SN + + # Mock chunk data + async def mock_iter(): + yield SN(output="error1\n") + yield SN(output="error2\n") + + mock_async_stream.__aiter__ = Mock(return_value=mock_iter()) + + # Setup client mock to return our stream + mock_async_client.devboxes.executions.stream_stderr_updates = AsyncMock(return_value=mock_async_stream) + + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="", + stderr="partial error", + stdout_truncated=False, + stderr_truncated=True, + ) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] + + # Should stream full output + output = await result.stderr() + assert output == "error1\nerror2\n" + mock_async_client.devboxes.executions.stream_stderr_updates.assert_called_once_with( + "exec_123", devbox_id="dev_123" + ) + + @pytest.mark.asyncio + async def test_stdout_with_num_lines_when_truncated( + self, mock_async_client: AsyncMock, mock_async_stream: AsyncMock + ) -> None: + """Test stdout with num_lines parameter when truncated.""" + from types import SimpleNamespace as SN + + # Mock chunk data with many lines + async def mock_iter(): + yield SN(output="line1\nline2\nline3\n") + yield SN(output="line4\nline5\n") + + mock_async_stream.__aiter__ = Mock(return_value=mock_iter()) + + # Setup client mock to return our stream + mock_async_client.devboxes.executions.stream_stdout_updates = AsyncMock(return_value=mock_async_stream) + + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="line1\n", + stderr="", + stdout_truncated=True, + stderr_truncated=False, + ) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] + + # Should stream and return last 2 lines + output = await result.stdout(num_lines=2) + assert output == "line4\nline5" + + @pytest.mark.asyncio + async def test_stdout_no_streaming_when_not_truncated(self, mock_async_client: AsyncMock) -> None: + """Test stdout doesn't stream when not truncated.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="complete output", + stderr="", + stdout_truncated=False, + stderr_truncated=False, + ) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] + + # Should return existing output without streaming + output = await result.stdout() + assert output == "complete output" + + @pytest.mark.asyncio + async def test_stdout_with_num_lines_no_truncation(self, mock_async_client: AsyncMock) -> None: + """Test stdout with num_lines when not truncated.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="line1\nline2\nline3\nline4\nline5", + stderr="", + stdout_truncated=False, + stderr_truncated=False, + ) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] + + # Should return last 2 lines without streaming + output = await result.stdout(num_lines=2) + assert output == "line4\nline5" + + def test_count_non_empty_lines(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: + """Test the _count_non_empty_lines helper method.""" + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] + + # Test various input strings + assert result._count_non_empty_lines("") == 0 + assert result._count_non_empty_lines("single") == 1 + assert result._count_non_empty_lines("line1\nline2") == 2 + assert result._count_non_empty_lines("line1\nline2\n") == 2 + assert result._count_non_empty_lines("line1\n\nline3") == 2 # Empty line in middle + assert result._count_non_empty_lines("line1\nline2\nline3\n\n") == 3 # Trailing newlines + + def test_get_last_n_lines(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: + """Test the _get_last_n_lines helper method.""" + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] + + # Test various scenarios + assert result._get_last_n_lines("", 5) == "" + assert result._get_last_n_lines("single", 1) == "single" + assert result._get_last_n_lines("line1\nline2\nline3", 2) == "line2\nline3" + assert result._get_last_n_lines("line1\nline2\nline3\n", 2) == "line2\nline3" + assert result._get_last_n_lines("line1\nline2", 10) == "line1\nline2" # Request more than available + assert result._get_last_n_lines("line1\nline2", 0) == "" # Zero lines diff --git a/tests/sdk/test_clients.py b/tests/sdk/test_clients.py index 0479795df..5b7a34d6f 100644 --- a/tests/sdk/test_clients.py +++ b/tests/sdk/test_clients.py @@ -204,17 +204,6 @@ def test_create(self, mock_client: Mock, object_view: MockObjectView) -> None: assert obj.upload_url == "https://upload.example.com/obj_123" mock_client.objects.create.assert_called_once() - def test_create_auto_detect_content_type(self, mock_client: Mock, object_view: MockObjectView) -> None: - """Test create auto-detects content type.""" - mock_client.objects.create.return_value = object_view - - client = StorageObjectClient(mock_client) - obj = client.create(name="test.txt") - - assert isinstance(obj, StorageObject) - call_kwargs = mock_client.objects.create.call_args[1] - assert "content_type" not in call_kwargs - def test_from_id(self, mock_client: Mock) -> None: """Test from_id method.""" client = StorageObjectClient(mock_client) diff --git a/tests/sdk/test_execution.py b/tests/sdk/test_execution.py index 0c18f1e93..fa2aaca2f 100644 --- a/tests/sdk/test_execution.py +++ b/tests/sdk/test_execution.py @@ -137,6 +137,8 @@ def test_result_needs_polling(self, mock_client: Mock) -> None: exit_status=0, stdout="output", stderr="", + stdout_truncated=False, + stderr_truncated=False, ) mock_client.devboxes = Mock() @@ -240,16 +242,3 @@ def test_kill(self, mock_client: Mock, execution_view: MockExecutionView) -> Non "exec_123", devbox_id="dev_123", ) - - def test_kill_with_process_group(self, mock_client: Mock, execution_view: MockExecutionView) -> None: - """Test kill with kill_process_group.""" - mock_client.devboxes.executions.kill.return_value = None - - execution = Execution(mock_client, "dev_123", execution_view) # type: ignore[arg-type] - execution.kill(kill_process_group=True) - - mock_client.devboxes.executions.kill.assert_called_once_with( - "exec_123", - devbox_id="dev_123", - kill_process_group=True, - ) diff --git a/tests/sdk/test_execution_result.py b/tests/sdk/test_execution_result.py index 8952e4870..2960208ac 100644 --- a/tests/sdk/test_execution_result.py +++ b/tests/sdk/test_execution_result.py @@ -43,6 +43,8 @@ def test_exit_code_none(self, mock_client: Mock) -> None: exit_status=None, stdout="", stderr="", + stdout_truncated=False, + stderr_truncated=False, ) result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] assert result.exit_code is None @@ -61,6 +63,8 @@ def test_success_false(self, mock_client: Mock) -> None: exit_status=1, stdout="", stderr="error", + stdout_truncated=False, + stderr_truncated=False, ) result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] assert result.success is False @@ -79,6 +83,8 @@ def test_failed_true(self, mock_client: Mock) -> None: exit_status=1, stdout="", stderr="error", + stdout_truncated=False, + stderr_truncated=False, ) result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] assert result.failed is True @@ -92,6 +98,8 @@ def test_failed_none(self, mock_client: Mock) -> None: exit_status=None, stdout="", stderr="", + stdout_truncated=False, + stderr_truncated=False, ) result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] assert result.failed is False @@ -111,6 +119,8 @@ def test_stdout_empty(self, mock_client: Mock) -> None: exit_status=0, stdout=None, stderr="", + stdout_truncated=False, + stderr_truncated=False, ) result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] assert result.stdout() == "" @@ -124,6 +134,8 @@ def test_stderr(self, mock_client: Mock) -> None: exit_status=1, stdout="", stderr="error message", + stdout_truncated=False, + stderr_truncated=False, ) result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] assert result.stderr() == "error message" @@ -138,3 +150,150 @@ def test_raw_property(self, mock_client: Mock, execution_view: MockExecutionView """Test raw property.""" result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.raw == execution_view + + def test_stdout_with_truncation_and_streaming(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test stdout streams full output when truncated.""" + from types import SimpleNamespace as SN + + # Mock chunk data + chunk1 = SN(output="line1\n") + chunk2 = SN(output="line2\n") + chunk3 = SN(output="line3\n") + mock_stream.__iter__ = Mock(return_value=iter([chunk1, chunk2, chunk3])) + + # Setup client mock to return our stream + mock_client.devboxes.executions.stream_stdout_updates = Mock(return_value=mock_stream) + + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="partial", + stderr="", + stdout_truncated=True, + stderr_truncated=False, + ) + result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] + + # Should stream full output + output = result.stdout() + assert output == "line1\nline2\nline3\n" + mock_client.devboxes.executions.stream_stdout_updates.assert_called_once_with("exec_123", devbox_id="dev_123") + + def test_stderr_with_truncation_and_streaming(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test stderr streams full output when truncated.""" + from types import SimpleNamespace as SN + + # Mock chunk data + chunk1 = SN(output="error1\n") + chunk2 = SN(output="error2\n") + mock_stream.__iter__ = Mock(return_value=iter([chunk1, chunk2])) + + # Setup client mock to return our stream + mock_client.devboxes.executions.stream_stderr_updates = Mock(return_value=mock_stream) + + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="", + stderr="partial error", + stdout_truncated=False, + stderr_truncated=True, + ) + result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] + + # Should stream full output + output = result.stderr() + assert output == "error1\nerror2\n" + mock_client.devboxes.executions.stream_stderr_updates.assert_called_once_with("exec_123", devbox_id="dev_123") + + def test_stdout_with_num_lines_when_truncated(self, mock_client: Mock, mock_stream: Mock) -> None: + """Test stdout with num_lines parameter when truncated.""" + from types import SimpleNamespace as SN + + # Mock chunk data with many lines + chunk1 = SN(output="line1\nline2\nline3\n") + chunk2 = SN(output="line4\nline5\n") + mock_stream.__iter__ = Mock(return_value=iter([chunk1, chunk2])) + + # Setup client mock to return our stream + mock_client.devboxes.executions.stream_stdout_updates = Mock(return_value=mock_stream) + + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="line1\n", + stderr="", + stdout_truncated=True, + stderr_truncated=False, + ) + result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] + + # Should stream and return last 2 lines + output = result.stdout(num_lines=2) + assert output == "line4\nline5" + + def test_stdout_no_streaming_when_not_truncated(self, mock_client: Mock) -> None: + """Test stdout doesn't stream when not truncated.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="complete output", + stderr="", + stdout_truncated=False, + stderr_truncated=False, + ) + result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] + + # Should return existing output without streaming + output = result.stdout() + assert output == "complete output" + + def test_stdout_with_num_lines_no_truncation(self, mock_client: Mock) -> None: + """Test stdout with num_lines when not truncated.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + exit_status=0, + stdout="line1\nline2\nline3\nline4\nline5", + stderr="", + stdout_truncated=False, + stderr_truncated=False, + ) + result = ExecutionResult(mock_client, "dev_123", execution) # type: ignore[arg-type] + + # Should return last 2 lines without streaming + output = result.stdout(num_lines=2) + assert output == "line4\nline5" + + def test_count_non_empty_lines(self, mock_client: Mock, execution_view: MockExecutionView) -> None: + """Test the _count_non_empty_lines helper method.""" + result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] + + # Test various input strings + assert result._count_non_empty_lines("") == 0 + assert result._count_non_empty_lines("single") == 1 + assert result._count_non_empty_lines("line1\nline2") == 2 + assert result._count_non_empty_lines("line1\nline2\n") == 2 + assert result._count_non_empty_lines("line1\n\nline3") == 2 # Empty line in middle + assert result._count_non_empty_lines("line1\nline2\nline3\n\n") == 3 # Trailing newlines + + def test_get_last_n_lines(self, mock_client: Mock, execution_view: MockExecutionView) -> None: + """Test the _get_last_n_lines helper method.""" + result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] + + # Test various scenarios + assert result._get_last_n_lines("", 5) == "" + assert result._get_last_n_lines("single", 1) == "single" + assert result._get_last_n_lines("line1\nline2\nline3", 2) == "line2\nline3" + assert result._get_last_n_lines("line1\nline2\nline3\n", 2) == "line2\nline3" + assert result._get_last_n_lines("line1\nline2", 10) == "line1\nline2" # Request more than available + assert result._get_last_n_lines("line1\nline2", 0) == "" # Zero lines diff --git a/tests/sdk/test_storage_object.py b/tests/sdk/test_storage_object.py index 36fc8f6e2..ed2a90477 100644 --- a/tests/sdk/test_storage_object.py +++ b/tests/sdk/test_storage_object.py @@ -9,7 +9,6 @@ from tests.sdk.conftest import MockObjectView, create_mock_httpx_response from runloop_api_client.sdk import StorageObject -from runloop_api_client.sdk.sync import StorageObjectClient class TestStorageObject: @@ -261,22 +260,6 @@ def test_large_file_upload(self, mock_client: Mock) -> None: class TestStorageObjectPythonSpecific: """Tests for Python-specific StorageObject behavior.""" - def test_content_type_detection(self, mock_client: Mock, object_view: MockObjectView) -> None: - """Test content type detection differences.""" - mock_client.objects.create.return_value = object_view - - client = StorageObjectClient(mock_client) - - # When no content type provided, create forwards only provided params - client.create(name="test.txt") - call1 = mock_client.objects.create.call_args[1] - assert "content_type" not in call1 - - # Explicit content type - client.create(name="test.bin", content_type="binary") - call2 = mock_client.objects.create.call_args[1] - assert call2["content_type"] == "binary" - def test_upload_data_types(self, mock_client: Mock) -> None: """Test Python supports more upload data types.""" http_client = Mock() diff --git a/tests/smoketests/sdk/test_async_devbox.py b/tests/smoketests/sdk/test_async_devbox.py index d34174bea..892aa1e9c 100644 --- a/tests/smoketests/sdk/test_async_devbox.py +++ b/tests/smoketests/sdk/test_async_devbox.py @@ -614,3 +614,71 @@ async def test_snapshot_disk_async(self, async_sdk_client: AsyncRunloopSDK) -> N await snapshot.delete() finally: await devbox.shutdown() + + +class TestAsyncDevboxExecutionPagination: + """Test stdout/stderr pagination and streaming functionality.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_exec_with_large_stdout_streaming(self, shared_devbox: AsyncDevbox) -> None: + """Test that large stdout output is fully captured via streaming when truncated.""" + # Generate 1000 lines of output + result = await shared_devbox.cmd.exec( + command='for i in $(seq 1 1000); do echo "Line $i with some content to make it realistic"; done', + ) + + assert result.exit_code == 0 + stdout = await result.stdout() + lines = stdout.strip().split("\n") + + # Verify we got all 1000 lines + assert len(lines) == 1000, f"Expected 1000 lines, got {len(lines)}" + + # Verify first and last lines + assert "Line 1" in lines[0] + assert "Line 1000" in lines[-1] + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_exec_with_large_stderr_streaming(self, shared_devbox: AsyncDevbox) -> None: + """Test that large stderr output is fully captured via streaming when truncated.""" + # Generate 1000 lines of stderr output + result = await shared_devbox.cmd.exec( + command='for i in $(seq 1 1000); do echo "Error line $i" >&2; done', + ) + + assert result.exit_code == 0 + stderr = await result.stderr() + lines = stderr.strip().split("\n") + + # Verify we got all 1000 lines + assert len(lines) == 1000, f"Expected 1000 lines, got {len(lines)}" + + # Verify first and last lines + assert "Error line 1" in lines[0] + assert "Error line 1000" in lines[-1] + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + async def test_exec_with_truncated_stdout_num_lines(self, shared_devbox: AsyncDevbox) -> None: + """Test num_lines parameter works correctly with potentially truncated output.""" + # Generate 2000 lines of output + result = await shared_devbox.cmd.exec( + command='for i in $(seq 1 2000); do echo "Line $i"; done', + ) + + assert result.exit_code == 0 + + # Request last 50 lines + stdout = await result.stdout(num_lines=50) + lines = stdout.strip().split("\n") + + # Verify we got exactly 50 lines + assert len(lines) == 50, f"Expected 50 lines, got {len(lines)}" + + # Verify these are the last 50 lines + assert "Line 1951" in lines[0] + assert "Line 2000" in lines[-1] + + # TODO: Add test_exec_stdout_line_counting test once empty line logic is fixed. + # Currently there's an inconsistency where _count_non_empty_lines counts non-empty + # lines but _get_last_n_lines returns N lines (including empty ones). This affects + # both Python and TypeScript SDKs and needs to be fixed together. diff --git a/tests/smoketests/sdk/test_devbox.py b/tests/smoketests/sdk/test_devbox.py index 527a3b53b..69e605d79 100644 --- a/tests/smoketests/sdk/test_devbox.py +++ b/tests/smoketests/sdk/test_devbox.py @@ -609,3 +609,71 @@ def test_snapshot_disk_async(self, sdk_client: RunloopSDK) -> None: snapshot.delete() finally: devbox.shutdown() + + +class TestDevboxExecutionPagination: + """Test stdout/stderr pagination and streaming functionality.""" + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_exec_with_large_stdout_streaming(self, shared_devbox: Devbox) -> None: + """Test that large stdout output is fully captured via streaming when truncated.""" + # Generate 1000 lines of output + result = shared_devbox.cmd.exec( + command='for i in $(seq 1 1000); do echo "Line $i with some content to make it realistic"; done', + ) + + assert result.exit_code == 0 + stdout = result.stdout() + lines = stdout.strip().split("\n") + + # Verify we got all 1000 lines + assert len(lines) == 1000, f"Expected 1000 lines, got {len(lines)}" + + # Verify first and last lines + assert "Line 1" in lines[0] + assert "Line 1000" in lines[-1] + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_exec_with_large_stderr_streaming(self, shared_devbox: Devbox) -> None: + """Test that large stderr output is fully captured via streaming when truncated.""" + # Generate 1000 lines of stderr output + result = shared_devbox.cmd.exec( + command='for i in $(seq 1 1000); do echo "Error line $i" >&2; done', + ) + + assert result.exit_code == 0 + stderr = result.stderr() + lines = stderr.strip().split("\n") + + # Verify we got all 1000 lines + assert len(lines) == 1000, f"Expected 1000 lines, got {len(lines)}" + + # Verify first and last lines + assert "Error line 1" in lines[0] + assert "Error line 1000" in lines[-1] + + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + def test_exec_with_truncated_stdout_num_lines(self, shared_devbox: Devbox) -> None: + """Test num_lines parameter works correctly with potentially truncated output.""" + # Generate 2000 lines of output + result = shared_devbox.cmd.exec( + command='for i in $(seq 1 2000); do echo "Line $i"; done', + ) + + assert result.exit_code == 0 + + # Request last 50 lines + stdout = result.stdout(num_lines=50) + lines = stdout.strip().split("\n") + + # Verify we got exactly 50 lines + assert len(lines) == 50, f"Expected 50 lines, got {len(lines)}" + + # Verify these are the last 50 lines + assert "Line 1951" in lines[0] + assert "Line 2000" in lines[-1] + + # TODO: Add test_exec_stdout_line_counting test once empty line logic is fixed. + # Currently there's an inconsistency where _count_non_empty_lines counts non-empty + # lines but _get_last_n_lines returns N lines (including empty ones). This affects + # both Python and TypeScript SDKs and needs to be fixed together. From 8a6f6346feb673cf3a1766b30857560733687b83 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Mon, 17 Nov 2025 17:49:24 -0800 Subject: [PATCH 45/56] increased smoke test timeouts --- tests/smoketests/sdk/test_async_snapshot.py | 26 ++++++++++----------- tests/smoketests/sdk/test_sdk.py | 6 ++--- tests/smoketests/sdk/test_snapshot.py | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/smoketests/sdk/test_async_snapshot.py b/tests/smoketests/sdk/test_async_snapshot.py index bc17386d2..f70529a0f 100644 --- a/tests/smoketests/sdk/test_async_snapshot.py +++ b/tests/smoketests/sdk/test_async_snapshot.py @@ -10,14 +10,14 @@ pytestmark = [pytest.mark.smoketest, pytest.mark.asyncio] -THIRTY_SECOND_TIMEOUT = 30 TWO_MINUTE_TIMEOUT = 120 +FOUR_MINUTE_TIMEOUT = 240 class TestAsyncSnapshotLifecycle: """Test basic async snapshot lifecycle operations.""" - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) async def test_snapshot_create_and_info(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test creating a snapshot from devbox.""" # Create a devbox @@ -51,7 +51,7 @@ async def test_snapshot_create_and_info(self, async_sdk_client: AsyncRunloopSDK) finally: await devbox.shutdown() - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) async def test_snapshot_with_commit_message(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test creating a snapshot with commit message.""" devbox = await async_sdk_client.devbox.create( @@ -78,7 +78,7 @@ async def test_snapshot_with_commit_message(self, async_sdk_client: AsyncRunloop finally: await devbox.shutdown() - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) async def test_snapshot_with_metadata(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test creating a snapshot with metadata.""" devbox = await async_sdk_client.devbox.create( @@ -107,7 +107,7 @@ async def test_snapshot_with_metadata(self, async_sdk_client: AsyncRunloopSDK) - finally: await devbox.shutdown() - @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) async def test_snapshot_delete(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test deleting a snapshot.""" devbox = await async_sdk_client.devbox.create( @@ -138,7 +138,7 @@ async def test_snapshot_delete(self, async_sdk_client: AsyncRunloopSDK) -> None: class TestAsyncSnapshotCompletion: """Test async snapshot completion and status tracking.""" - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) async def test_snapshot_await_completed(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test waiting for snapshot completion.""" devbox = await async_sdk_client.devbox.create( @@ -165,7 +165,7 @@ async def test_snapshot_await_completed(self, async_sdk_client: AsyncRunloopSDK) finally: await devbox.shutdown() - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) async def test_snapshot_status_tracking(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test tracking snapshot status through lifecycle.""" devbox = await async_sdk_client.devbox.create( @@ -199,7 +199,7 @@ async def test_snapshot_status_tracking(self, async_sdk_client: AsyncRunloopSDK) class TestAsyncSnapshotDevboxRestoration: """Test creating devboxes from snapshots asynchronously.""" - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) async def test_restore_devbox_from_snapshot(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test creating a devbox from a snapshot and verifying state is restored.""" # Create source devbox @@ -242,7 +242,7 @@ async def test_restore_devbox_from_snapshot(self, async_sdk_client: AsyncRunloop finally: await source_devbox.shutdown() - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) async def test_multiple_devboxes_from_snapshot(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test creating multiple devboxes from the same snapshot.""" # Create source devbox @@ -300,7 +300,7 @@ async def test_multiple_devboxes_from_snapshot(self, async_sdk_client: AsyncRunl class TestAsyncSnapshotListing: """Test async snapshot listing and retrieval operations.""" - @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) async def test_list_snapshots(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test listing snapshots.""" snapshots = await async_sdk_client.snapshot.list(limit=10) @@ -309,7 +309,7 @@ async def test_list_snapshots(self, async_sdk_client: AsyncRunloopSDK) -> None: # List might be empty, that's okay assert len(snapshots) >= 0 - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) async def test_get_snapshot_by_id(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test retrieving snapshot by ID.""" # Create a devbox and snapshot @@ -336,7 +336,7 @@ async def test_get_snapshot_by_id(self, async_sdk_client: AsyncRunloopSDK) -> No finally: await devbox.shutdown() - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) + @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) async def test_list_snapshots_by_devbox(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test listing snapshots filtered by devbox.""" # Create a devbox @@ -370,7 +370,7 @@ async def test_list_snapshots_by_devbox(self, async_sdk_client: AsyncRunloopSDK) class TestAsyncSnapshotEdgeCases: """Test async snapshot edge cases and special scenarios.""" - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) + @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) async def test_snapshot_preserves_file_permissions(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test that snapshot preserves file permissions.""" # Create devbox diff --git a/tests/smoketests/sdk/test_sdk.py b/tests/smoketests/sdk/test_sdk.py index c17f97299..b55a98112 100644 --- a/tests/smoketests/sdk/test_sdk.py +++ b/tests/smoketests/sdk/test_sdk.py @@ -8,13 +8,13 @@ pytestmark = [pytest.mark.smoketest] -FIVE_SECOND_TIMEOUT = 5 +THIRTY_SECOND_TIMEOUT = 30 class TestRunloopSDKInitialization: """Test RunloopSDK client initialization and structure.""" - @pytest.mark.timeout(FIVE_SECOND_TIMEOUT) + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) def test_sdk_instance_creation(self, sdk_client: RunloopSDK) -> None: """Test that SDK instance is created successfully with all client properties.""" assert sdk_client is not None @@ -23,7 +23,7 @@ def test_sdk_instance_creation(self, sdk_client: RunloopSDK) -> None: assert sdk_client.snapshot is not None assert sdk_client.storage_object is not None - @pytest.mark.timeout(FIVE_SECOND_TIMEOUT) + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) def test_legacy_api_access(self, sdk_client: RunloopSDK) -> None: """Test that legacy API client is accessible through sdk.api.""" assert sdk_client.api is not None diff --git a/tests/smoketests/sdk/test_snapshot.py b/tests/smoketests/sdk/test_snapshot.py index a5b18c384..143e2105b 100644 --- a/tests/smoketests/sdk/test_snapshot.py +++ b/tests/smoketests/sdk/test_snapshot.py @@ -103,7 +103,7 @@ def test_snapshot_with_metadata(self, sdk_client: RunloopSDK) -> None: finally: devbox.shutdown() - @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + @pytest.mark.timeout(TWO_MINUTE_TIMEOUT) def test_snapshot_delete(self, sdk_client: RunloopSDK) -> None: """Test deleting a snapshot.""" devbox = sdk_client.devbox.create( From 2eaff32f4945aab16d9c4733e479b9a5de1ead72 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 18 Nov 2025 12:14:17 -0800 Subject: [PATCH 46/56] scoped docstrings for public modules, classes and methods --- src/runloop_api_client/sdk/async_.py | 266 ++++++++++++++++- src/runloop_api_client/sdk/async_blueprint.py | 47 ++- src/runloop_api_client/sdk/async_devbox.py | 269 +++++++++++++++++- src/runloop_api_client/sdk/async_execution.py | 49 +++- .../sdk/async_execution_result.py | 56 +++- src/runloop_api_client/sdk/async_snapshot.py | 55 +++- .../sdk/async_storage_object.py | 86 +++++- src/runloop_api_client/sdk/blueprint.py | 47 ++- src/runloop_api_client/sdk/devbox.py | 132 +++++---- src/runloop_api_client/sdk/execution.py | 38 ++- .../sdk/execution_result.py | 56 +++- src/runloop_api_client/sdk/snapshot.py | 55 +++- src/runloop_api_client/sdk/storage_object.py | 86 +++++- src/runloop_api_client/sdk/sync.py | 235 +++++++++++++-- 14 files changed, 1339 insertions(+), 138 deletions(-) diff --git a/src/runloop_api_client/sdk/async_.py b/src/runloop_api_client/sdk/async_.py index e40a20220..7f297f923 100644 --- a/src/runloop_api_client/sdk/async_.py +++ b/src/runloop_api_client/sdk/async_.py @@ -30,15 +30,38 @@ class AsyncDevboxClient: - """Async manager for :class:`AsyncDevbox` wrappers.""" + """High-level async manager for creating and managing AsyncDevbox instances. + + Accessed via ``runloop.devbox`` from :class:`AsyncRunloopSDK`, provides + coroutines to create devboxes from scratch, blueprints, or snapshots, and to + list existing devboxes. + + Example: + >>> runloop = AsyncRunloopSDK() + >>> devbox = await runloop.devbox.create(name="my-devbox") + >>> devboxes = await runloop.devbox.list(limit=10) + """ def __init__(self, client: AsyncRunloop) -> None: + """Initialize the manager. + + Args: + client: Generated AsyncRunloop client to wrap. + """ self._client = client async def create( self, **params: Unpack[SDKDevboxCreateParams], ) -> AsyncDevbox: + """Provision a new devbox and wait until it reaches ``running`` state. + + Args: + **params: Keyword arguments forwarded to the devbox creation API. + + Returns: + AsyncDevbox: Wrapper bound to the newly created devbox. + """ devbox_view = await self._client.devboxes.create_and_await_running( **params, ) @@ -49,6 +72,15 @@ async def create_from_blueprint_id( blueprint_id: str, **params: Unpack[SDKDevboxExtraCreateParams], ) -> AsyncDevbox: + """Create a devbox from an existing blueprint by identifier. + + Args: + blueprint_id: Blueprint ID to create from. + **params: Additional creation parameters (metadata, launch parameters, etc.). + + Returns: + AsyncDevbox: Wrapper bound to the newly created devbox. + """ devbox_view = await self._client.devboxes.create_and_await_running( blueprint_id=blueprint_id, **params, @@ -60,6 +92,15 @@ async def create_from_blueprint_name( blueprint_name: str, **params: Unpack[SDKDevboxExtraCreateParams], ) -> AsyncDevbox: + """Create a devbox from the latest blueprint with the given name. + + Args: + blueprint_name: Blueprint name to create from. + **params: Additional creation parameters (metadata, launch parameters, etc.). + + Returns: + AsyncDevbox: Wrapper bound to the newly created devbox. + """ devbox_view = await self._client.devboxes.create_and_await_running( blueprint_name=blueprint_name, **params, @@ -71,6 +112,15 @@ async def create_from_snapshot( snapshot_id: str, **params: Unpack[SDKDevboxExtraCreateParams], ) -> AsyncDevbox: + """Create a devbox initialized from a snapshot. + + Args: + snapshot_id: Snapshot ID to create from. + **params: Additional creation parameters (metadata, launch parameters, etc.). + + Returns: + AsyncDevbox: Wrapper bound to the newly created devbox. + """ devbox_view = await self._client.devboxes.create_and_await_running( snapshot_id=snapshot_id, **params, @@ -78,12 +128,28 @@ async def create_from_snapshot( return AsyncDevbox(self._client, devbox_view.id) def from_id(self, devbox_id: str) -> AsyncDevbox: + """Attach to an existing devbox by ID. + + Args: + devbox_id: Existing devbox ID. + + Returns: + AsyncDevbox: Wrapper bound to the requested devbox. + """ return AsyncDevbox(self._client, devbox_id) async def list( self, **params: Unpack[SDKDevboxListParams], ) -> list[AsyncDevbox]: + """List devboxes accessible to the caller. + + Args: + **params: Filtering and pagination parameters. + + Returns: + list[AsyncDevbox]: Collection of devbox wrappers. + """ page = await self._client.devboxes.list( **params, ) @@ -91,46 +157,118 @@ async def list( class AsyncSnapshotClient: - """Async manager for :class:`AsyncSnapshot` wrappers.""" + """High-level async manager for working with disk snapshots. + + Accessed via ``runloop.snapshot`` from :class:`AsyncRunloopSDK`, provides + coroutines to list snapshots and access snapshot details. + + Example: + >>> runloop = AsyncRunloopSDK() + >>> snapshots = await runloop.snapshot.list(devbox_id="dev-123") + >>> snapshot = await runloop.snapshot.from_id("snap-123") + """ def __init__(self, client: AsyncRunloop) -> None: + """Initialize the manager. + + Args: + client: Generated AsyncRunloop client to wrap. + """ self._client = client async def list( self, **params: Unpack[SDKDiskSnapshotListParams], ) -> list[AsyncSnapshot]: + """List snapshots created from devboxes. + + Args: + **params: Filtering and pagination parameters. + + Returns: + list[AsyncSnapshot]: Snapshot wrappers for each record. + """ page = await self._client.devboxes.disk_snapshots.list( **params, ) return [AsyncSnapshot(self._client, item.id) for item in page.snapshots] def from_id(self, snapshot_id: str) -> AsyncSnapshot: + """Return a snapshot wrapper for the given ID. + + Args: + snapshot_id: Snapshot ID to wrap. + + Returns: + AsyncSnapshot: Wrapper for the snapshot resource. + """ return AsyncSnapshot(self._client, snapshot_id) class AsyncBlueprintClient: - """Async manager for :class:`AsyncBlueprint` wrappers.""" + """High-level async manager for creating and managing blueprints. + + Accessed via ``runloop.blueprint`` from :class:`AsyncRunloopSDK`, provides + coroutines to create Dockerfile-based blueprints, inspect build logs, + and list existing blueprints. + + Example: + >>> runloop = AsyncRunloopSDK() + >>> blueprint = await runloop.blueprint.create( + ... name="my-blueprint", + ... dockerfile="FROM ubuntu:22.04\\nRUN apt-get update", + ... ) + >>> blueprints = await runloop.blueprint.list() + """ def __init__(self, client: AsyncRunloop) -> None: + """Initialize the manager. + + Args: + client: Generated AsyncRunloop client to wrap. + """ self._client = client async def create( self, **params: Unpack[SDKBlueprintCreateParams], ) -> AsyncBlueprint: + """Create a blueprint and wait for the build to finish. + + Args: + **params: Blueprint definition (Dockerfile, metadata, etc.). + + Returns: + AsyncBlueprint: Wrapper bound to the finished blueprint. + """ blueprint = await self._client.blueprints.create_and_await_build_complete( **params, ) return AsyncBlueprint(self._client, blueprint.id) def from_id(self, blueprint_id: str) -> AsyncBlueprint: + """Return a blueprint wrapper for the given ID. + + Args: + blueprint_id: Blueprint ID to wrap. + + Returns: + AsyncBlueprint: Wrapper for the blueprint resource. + """ return AsyncBlueprint(self._client, blueprint_id) async def list( self, **params: Unpack[SDKBlueprintListParams], ) -> list[AsyncBlueprint]: + """List available blueprints. + + Args: + **params: Filtering and pagination parameters. + + Returns: + list[AsyncBlueprint]: Blueprint wrappers for each record. + """ page = await self._client.blueprints.list( **params, ) @@ -138,25 +276,65 @@ async def list( class AsyncStorageObjectClient: - """Async manager for :class:`AsyncStorageObject` wrappers.""" + """High-level async manager for creating and managing storage objects. + + Accessed via ``runloop.storage_object`` from :class:`AsyncRunloopSDK`, provides + coroutines to create, upload, download, and list storage objects with convenient + helpers for file and text uploads. + + Example: + >>> runloop = AsyncRunloopSDK() + >>> obj = await runloop.storage_object.upload_from_text("Hello!", "greeting.txt") + >>> content = await obj.download_as_text() + >>> objects = await runloop.storage_object.list() + """ def __init__(self, client: AsyncRunloop) -> None: + """Initialize the manager. + + Args: + client: Generated AsyncRunloop client to wrap. + """ self._client = client async def create( self, **params: Unpack[SDKObjectCreateParams], ) -> AsyncStorageObject: + """Create a storage object and obtain an upload URL. + + Args: + **params: Object creation parameters (name, content type, metadata). + + Returns: + AsyncStorageObject: Wrapper with upload URL set for immediate uploads. + """ obj = await self._client.objects.create(**params) return AsyncStorageObject(self._client, obj.id, upload_url=obj.upload_url) def from_id(self, object_id: str) -> AsyncStorageObject: + """Return a storage object wrapper by identifier. + + Args: + object_id: Storage object identifier to wrap. + + Returns: + AsyncStorageObject: Wrapper for the storage object resource. + """ return AsyncStorageObject(self._client, object_id, upload_url=None) async def list( self, **params: Unpack[SDKObjectListParams], ) -> list[AsyncStorageObject]: + """List storage objects owned by the caller. + + Args: + **params: Filtering and pagination parameters. + + Returns: + list[AsyncStorageObject]: Storage object wrappers for each record. + """ page = await self._client.objects.list( **params, ) @@ -171,6 +349,21 @@ async def upload_from_file( metadata: Optional[Dict[str, str]] = None, **options: Unpack[LongRequestOptions], ) -> AsyncStorageObject: + """Create and upload an object from a local file path. + + Args: + file_path: Local filesystem path to read. + name: Optional object name; defaults to the file name. + content_type: Optional MIME type to apply to the object. + metadata: Optional key-value metadata. + **options: Additional request configuration. + + Returns: + AsyncStorageObject: Wrapper for the uploaded object. + + Raises: + OSError: If the local file cannot be read. + """ path = Path(file_path) try: @@ -193,6 +386,17 @@ async def upload_from_text( metadata: Optional[Dict[str, str]] = None, **options: Unpack[LongRequestOptions], ) -> AsyncStorageObject: + """Create and upload an object from a text payload. + + Args: + text: Text content to upload. + name: Object display name. + metadata: Optional key-value metadata. + **options: Additional request configuration. + + Returns: + AsyncStorageObject: Wrapper for the uploaded object. + """ obj = await self.create(name=name, content_type="text", metadata=metadata, **options) await obj.upload_content(text) await obj.complete() @@ -207,6 +411,18 @@ async def upload_from_bytes( metadata: Optional[Dict[str, str]] = None, **options: Unpack[LongRequestOptions], ) -> AsyncStorageObject: + """Create and upload an object from a bytes payload. + + Args: + data: Binary payload to upload. + name: Object display name. + content_type: MIME type describing the payload. + metadata: Optional key-value metadata. + **options: Additional request configuration. + + Returns: + AsyncStorageObject: Wrapper for the uploaded object. + """ obj = await self.create(name=name, content_type=content_type, metadata=metadata, **options) await obj.upload_content(data) await obj.complete() @@ -214,11 +430,25 @@ async def upload_from_bytes( class AsyncRunloopSDK: - """ - High-level asynchronous entry point for the Runloop SDK. - - The generated async REST client remains available via the ``api`` attribute. - Higher-level helpers will be introduced incrementally. + """High-level asynchronous entry point for the Runloop SDK. + + Provides a Pythonic, object-oriented interface for managing devboxes, + blueprints, snapshots, and storage objects. Exposes the generated async REST + client via the ``api`` attribute for advanced use cases. + + Attributes: + api: Direct access to the generated async REST API client. + devbox: High-level async interface for devbox management. + blueprint: High-level async interface for blueprint management. + snapshot: High-level async interface for snapshot management. + storage_object: High-level async interface for storage object management. + + Example: + >>> runloop = AsyncRunloopSDK() # Uses RUNLOOP_API_KEY env var + >>> devbox = await runloop.devbox.create(name="my-devbox") + >>> result = await devbox.cmd.exec(command="echo 'hello'") + >>> print(await result.stdout()) + >>> await devbox.shutdown() """ api: AsyncRunloop @@ -238,6 +468,17 @@ def __init__( default_query: Mapping[str, object] | None = None, http_client: httpx.AsyncClient | None = None, ) -> None: + """Configure the asynchronous SDK wrapper. + + Args: + bearer_token: API token; falls back to ``RUNLOOP_API_KEY`` env var. + base_url: Override the API base URL. + timeout: Request timeout (seconds) or ``Timeout`` object. + max_retries: Maximum automatic retry attempts. + default_headers: Headers merged into every request. + default_query: Default query parameters merged into every request. + http_client: Custom ``httpx.AsyncClient`` instance to reuse. + """ self.api = AsyncRunloop( bearer_token=bearer_token, base_url=base_url, @@ -254,10 +495,17 @@ def __init__( self.storage_object = AsyncStorageObjectClient(self.api) async def aclose(self) -> None: + """Close the underlying HTTP client and release resources.""" await self.api.close() async def __aenter__(self) -> "AsyncRunloopSDK": + """Allow ``async with AsyncRunloopSDK() as runloop`` usage. + + Returns: + AsyncRunloopSDK: The active SDK instance. + """ return self async def __aexit__(self, *_exc_info: object) -> None: + """Ensure the API client closes when leaving the context manager.""" await self.aclose() diff --git a/src/runloop_api_client/sdk/async_blueprint.py b/src/runloop_api_client/sdk/async_blueprint.py index 5d8498738..5d52e4904 100644 --- a/src/runloop_api_client/sdk/async_blueprint.py +++ b/src/runloop_api_client/sdk/async_blueprint.py @@ -12,15 +12,19 @@ class AsyncBlueprint: - """ - Async wrapper around blueprint operations. - """ + """Asynchronous wrapper around a blueprint resource.""" def __init__( self, client: AsyncRunloop, blueprint_id: str, ) -> None: + """Initialize the wrapper. + + Args: + client: Generated AsyncRunloop client. + blueprint_id: Blueprint ID returned by the API. + """ self._client = client self._id = blueprint_id @@ -30,12 +34,25 @@ def __repr__(self) -> str: @property def id(self) -> str: + """Return the blueprint ID. + + Returns: + str: Unique blueprint ID. + """ return self._id async def get_info( self, **options: Unpack[RequestOptions], ) -> BlueprintView: + """Retrieve the latest blueprint details. + + Args: + **options: Optional request configuration. + + Returns: + BlueprintView: API response describing the blueprint. + """ return await self._client.blueprints.retrieve( self._id, **options, @@ -45,6 +62,14 @@ async def logs( self, **options: Unpack[RequestOptions], ) -> BlueprintBuildLogsListView: + """Retrieve build logs for the blueprint. + + Args: + **options: Optional request configuration. + + Returns: + BlueprintBuildLogsListView: Log entries for the most recent build. + """ return await self._client.blueprints.logs( self._id, **options, @@ -54,6 +79,14 @@ async def delete( self, **options: Unpack[LongRequestOptions], ) -> object: + """Delete the blueprint. + + Args: + **options: Optional long-running request configuration. + + Returns: + object: API response acknowledging deletion. + """ return await self._client.blueprints.delete( self._id, **options, @@ -63,6 +96,14 @@ async def create_devbox( self, **params: Unpack[SDKDevboxExtraCreateParams], ) -> "AsyncDevbox": + """Create a devbox derived from the blueprint. + + Args: + **params: Creation parameters to forward to the devbox API. + + Returns: + AsyncDevbox: Wrapper bound to the running devbox. + """ devbox_view = await self._client.devboxes.create_and_await_running( blueprint_id=self._id, **params, diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index 98b2c5520..1282443fb 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -48,11 +48,33 @@ class AsyncDevbox: - """ - Async object-oriented wrapper around devbox operations. + """High-level async interface for managing a Runloop devbox. + + This class provides a Pythonic, awaitable API for interacting with devboxes, + including command execution, file operations, networking, and lifecycle + management. + + Example: + >>> devbox = await sdk.devbox.create(name="my-devbox") + >>> async with devbox: + ... result = await devbox.cmd.exec(command="echo 'hello'") + ... print(await result.stdout()) + # Devbox is automatically shut down on exit + + Attributes: + id: The devbox identifier. + cmd: Command execution interface (exec, exec_async). + file: File operations interface (read, write, upload, download). + net: Network operations interface (SSH keys, tunnels). """ def __init__(self, client: AsyncRunloop, devbox_id: str) -> None: + """Initialize the wrapper. + + Args: + client: Generated async Runloop client. + devbox_id: Devbox identifier returned by the API. + """ self._client = client self._id = devbox_id self._logger = logging.getLogger(__name__) @@ -62,9 +84,15 @@ def __repr__(self) -> str: return f"" async def __aenter__(self) -> "AsyncDevbox": + """Enable ``async with devbox`` usage by returning ``self``. + + Returns: + AsyncDevbox: The active devbox instance. + """ return self async def __aexit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any) -> None: + """Ensure the devbox shuts down when leaving an async context manager.""" try: await self.shutdown() except Exception: @@ -72,27 +100,64 @@ async def __aexit__(self, exc_type: type[BaseException] | None, exc: BaseExcepti @property def id(self) -> str: + """Return the devbox identifier. + + Returns: + str: Unique devbox ID. + """ return self._id async def get_info( self, **options: Unpack[RequestOptions], ) -> DevboxView: + """Retrieve current devbox status and metadata. + + Args: + **options: Optional request configuration. + + Returns: + DevboxView: Current devbox state info. + """ return await self._client.devboxes.retrieve( self._id, **options, ) async def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView: + """Wait for the devbox to reach running state. + + Args: + polling_config: Optional polling behavior overrides. + + Returns: + DevboxView: Devbox state info after it reaches running status. + """ return await self._client.devboxes.await_running(self._id, polling_config=polling_config) async def await_suspended(self, *, polling_config: PollingConfig | None = None) -> DevboxView: + """Wait for the devbox to reach suspended state. + + Args: + polling_config: Optional polling behavior overrides. + + Returns: + DevboxView: Devbox state info after it reaches suspended status. + """ return await self._client.devboxes.await_suspended(self._id, polling_config=polling_config) async def shutdown( self, **options: Unpack[LongRequestOptions], ) -> DevboxView: + """Shutdown the devbox, terminating all processes and releasing resources. + + Args: + **options: Optional long-running request configuration. + + Returns: + DevboxView: Final devbox state info. + """ return await self._client.devboxes.shutdown( self._id, **options, @@ -102,6 +167,14 @@ async def suspend( self, **options: Unpack[LongRequestOptions], ) -> DevboxView: + """Suspend the devbox without destroying state. + + Args: + **options: Optional long-running request configuration. + + Returns: + DevboxView: Suspended devbox state info. + """ return await self._client.devboxes.suspend( self._id, **options, @@ -111,6 +184,14 @@ async def resume( self, **options: Unpack[LongRequestOptions], ) -> DevboxView: + """Resume a suspended devbox. + + Args: + **options: Optional long-running request configuration. + + Returns: + DevboxView: Resumed devbox state info. + """ return await self._client.devboxes.resume( self._id, **options, @@ -120,6 +201,17 @@ async def keep_alive( self, **options: Unpack[LongRequestOptions], ) -> object: + """Extend the devbox timeout, preventing automatic shutdown. + + Call this periodically for long-running workflows to prevent the devbox + from being automatically shut down due to inactivity. + + Args: + **options: Optional long-running request configuration. + + Returns: + object: Response confirming the keep-alive request. + """ return await self._client.devboxes.keep_alive( self._id, **options, @@ -129,6 +221,17 @@ async def snapshot_disk( self, **params: Unpack[SDKDevboxSnapshotDiskParams], ) -> "AsyncSnapshot": + """Create a disk snapshot of the devbox and wait for completion. + + Captures the current state of the devbox disk, which can be used to create + new devboxes with the same state. + + Args: + **params: Snapshot metadata, naming, and polling configuration. + + Returns: + AsyncSnapshot: Wrapper representing the completed snapshot. + """ snapshot_data = await self._client.devboxes.snapshot_disk_async( self._id, **filter_params(params, SDKDevboxSnapshotDiskAsyncParams), @@ -141,6 +244,17 @@ async def snapshot_disk_async( self, **params: Unpack[SDKDevboxSnapshotDiskAsyncParams], ) -> "AsyncSnapshot": + """Create a disk snapshot of the devbox asynchronously. + + Starts the snapshot creation process and returns immediately without waiting + for completion. Use snapshot.await_completed() to wait for completion. + + Args: + **params: Snapshot metadata and naming options. + + Returns: + AsyncSnapshot: Wrapper representing the snapshot request. + """ snapshot_data = await self._client.devboxes.snapshot_disk_async( self._id, **params, @@ -148,18 +262,34 @@ async def snapshot_disk_async( return self._snapshot_from_id(snapshot_data.id) async def close(self) -> None: + """Alias for :meth:`shutdown` to support common resource patterns.""" await self.shutdown() @property def cmd(self) -> AsyncCommandInterface: + """Return the command execution interface. + + Returns: + AsyncCommandInterface: Helper for running shell commands. + """ return _AsyncCommandInterface(self) @property def file(self) -> AsyncFileInterface: + """Return the file operations interface. + + Returns: + AsyncFileInterface: Helper for reading/writing files. + """ return _AsyncFileInterface(self) @property def net(self) -> AsyncNetworkInterface: + """Return the networking interface. + + Returns: + AsyncNetworkInterface: Helper for SSH keys and tunnels. + """ return _AsyncNetworkInterface(self) # ------------------------------------------------------------------ # @@ -241,6 +371,12 @@ async def _stream_worker( class _AsyncCommandInterface: + """Interface for executing commands on a devbox. + + Accessed via devbox.cmd property. Provides exec() for synchronous execution + and exec_async() for asynchronous execution with process management. + """ + def __init__(self, devbox: AsyncDevbox) -> None: self._devbox = devbox @@ -248,6 +384,19 @@ async def exec( self, **params: Unpack[SDKDevboxExecuteParams], ) -> AsyncExecutionResult: + """Execute a command synchronously and wait for completion. + + Args: + **params: Command parameters, streaming callbacks, and polling config. + + Returns: + AsyncExecutionResult: Wrapper with exit status and output helpers. + + Example: + >>> result = await devbox.cmd.exec(command="echo 'hello'") + >>> print(await result.stdout()) + >>> print(f"Exit code: {result.exit_code}") + """ devbox = self._devbox client = devbox._client @@ -290,6 +439,24 @@ async def exec_async( self, **params: Unpack[SDKDevboxExecuteAsyncParams], ) -> AsyncExecution: + """Execute a command asynchronously without waiting for completion. + + Starts command execution and returns immediately with an AsyncExecution object + for process management. Use execution.result() to wait for completion or + execution.kill() to terminate the process. + + Args: + **params: Command parameters and streaming callbacks. + + Returns: + AsyncExecution: Handle for managing the running process. + + Example: + >>> execution = await devbox.cmd.exec_async(command="sleep 10") + >>> state = await execution.get_state() + >>> print(f"Status: {state.status}") + >>> await execution.kill() # Terminate early if needed + """ devbox = self._devbox client = devbox._client @@ -308,6 +475,12 @@ async def exec_async( class _AsyncFileInterface: + """Interface for file operations on a devbox. + + Accessed via devbox.file property. Provides coroutines for reading, writing, + uploading, and downloading files. + """ + def __init__(self, devbox: AsyncDevbox) -> None: self._devbox = devbox @@ -315,6 +488,18 @@ async def read( self, **params: Unpack[SDKDevboxReadFileContentsParams], ) -> str: + """Read a file from the devbox. + + Args: + **params: Parameters such as ``path``. + + Returns: + str: File contents. + + Example: + >>> content = await devbox.file.read(path="/home/user/data.txt") + >>> print(content) + """ return await self._devbox._client.devboxes.read_file_contents( self._devbox.id, **params, @@ -324,10 +509,19 @@ async def write( self, **params: Unpack[SDKDevboxWriteFileContentsParams], ) -> DevboxExecutionDetailView: - contents = params.get("contents") - if isinstance(contents, bytes): - params = {**params, "contents": contents.decode("utf-8")} + """Write contents to a file in the devbox. + + Creates or overwrites the file at the specified path. + Args: + **params: Parameters such as ``file_path`` and ``contents``. + + Returns: + DevboxExecutionDetailView: Execution metadata for the write command. + + Example: + >>> await devbox.file.write(file_path="/home/user/config.json", contents='{"key": "value"}') + """ return await self._devbox._client.devboxes.write_file_contents( self._devbox.id, **params, @@ -337,6 +531,19 @@ async def download( self, **params: Unpack[SDKDevboxDownloadFileParams], ) -> bytes: + """Download a file from the devbox. + + Args: + **params: Parameters such as ``path``. + + Returns: + bytes: Raw file contents. + + Example: + >>> data = await devbox.file.download(path="/home/user/output.bin") + >>> with open("local_output.bin", "wb") as f: + ... f.write(data) + """ response = await self._devbox._client.devboxes.download_file( self._devbox.id, **params, @@ -347,6 +554,18 @@ async def upload( self, **params: Unpack[SDKDevboxUploadFileParams], ) -> object: + """Upload a file to the devbox. + + Args: + **params: Parameters such as destination ``path`` and local ``file``. + + Returns: + object: API response confirming the upload. + + Example: + >>> from pathlib import Path + >>> await devbox.file.upload(path="/home/user/data.csv", file=Path("local_data.csv")) + """ return await self._devbox._client.devboxes.upload_file( self._devbox.id, **params, @@ -354,6 +573,11 @@ async def upload( class _AsyncNetworkInterface: + """Interface for networking operations on a devbox. + + Accessed via devbox.net property. Provides coroutines for SSH access and tunneling. + """ + def __init__(self, devbox: AsyncDevbox) -> None: self._devbox = devbox @@ -361,6 +585,18 @@ async def create_ssh_key( self, **options: Unpack[LongRequestOptions], ) -> DevboxCreateSSHKeyResponse: + """Create an SSH key for remote access to the devbox. + + Args: + **options: Optional long-running request configuration. + + Returns: + DevboxCreateSSHKeyResponse: Response containing SSH connection info. + + Example: + >>> ssh_key = await devbox.net.create_ssh_key() + >>> print(f"SSH URL: {ssh_key.url}") + """ return await self._devbox._client.devboxes.create_ssh_key( self._devbox.id, **options, @@ -370,6 +606,18 @@ async def create_tunnel( self, **params: Unpack[SDKDevboxCreateTunnelParams], ) -> DevboxTunnelView: + """Create a network tunnel to expose a devbox port publicly. + + Args: + **params: Parameters such as the devbox ``port`` to expose. + + Returns: + DevboxTunnelView: Details about the public endpoint. + + Example: + >>> tunnel = await devbox.net.create_tunnel(port=8080) + >>> print(f"Public URL: {tunnel.url}") + """ return await self._devbox._client.devboxes.create_tunnel( self._devbox.id, **params, @@ -379,6 +627,17 @@ async def remove_tunnel( self, **params: Unpack[SDKDevboxRemoveTunnelParams], ) -> object: + """Remove a network tunnel, disabling public access to the port. + + Args: + **params: Parameters such as the ``port`` to close. + + Returns: + object: Response confirming the tunnel removal. + + Example: + >>> await devbox.net.remove_tunnel(port=8080) + """ return await self._devbox._client.devboxes.remove_tunnel( self._devbox.id, **params, diff --git a/src/runloop_api_client/sdk/async_execution.py b/src/runloop_api_client/sdk/async_execution.py index 7a534f5ca..b2c730b37 100644 --- a/src/runloop_api_client/sdk/async_execution.py +++ b/src/runloop_api_client/sdk/async_execution.py @@ -39,8 +39,22 @@ def _log_results(self, results: tuple[object | BaseException | None, ...]) -> No class AsyncExecution: - """ - Represents an asynchronous command execution on a devbox. + """Manages an asynchronous command execution on a devbox. + + Provides coroutines to poll execution state, wait for completion, and + terminate the running process. Created by ``await devbox.cmd.exec_async()``. + + Attributes: + execution_id: The unique execution identifier. + devbox_id: The devbox where the command is executing. + + Example: + >>> execution = await devbox.cmd.exec_async(command="python train.py") + >>> state = await execution.get_state() + >>> if state.status == "running": + ... await execution.kill() + >>> result = await execution.result() # Wait for completion + >>> print(await result.stdout()) """ def __init__( @@ -62,13 +76,31 @@ def __repr__(self) -> str: @property def execution_id(self) -> str: + """Return the execution identifier. + + Returns: + str: Unique execution ID. + """ return self._execution_id @property def devbox_id(self) -> str: + """Return the devbox identifier. + + Returns: + str: Devbox ID where the command is running. + """ return self._devbox_id async def result(self, **options: Unpack[LongRequestOptions]) -> AsyncExecutionResult: + """Wait for completion and return an :class:`AsyncExecutionResult`. + + Args: + **options: Optional long-running request configuration. + + Returns: + AsyncExecutionResult: Wrapper with exit status and output helpers. + """ # Wait for both command completion and streaming to finish awaitables: list[Awaitable[DevboxAsyncExecutionDetailView | None]] = [ self._client.devboxes.wait_for_command( @@ -96,6 +128,14 @@ async def result(self, **options: Unpack[LongRequestOptions]) -> AsyncExecutionR return AsyncExecutionResult(self._client, self._devbox_id, final) async def get_state(self, **options: Unpack[RequestOptions]) -> DevboxAsyncExecutionDetailView: + """Fetch the latest execution state. + + Args: + **options: Optional request configuration. + + Returns: + DevboxAsyncExecutionDetailView: Current execution metadata. + """ return await self._client.devboxes.executions.retrieve( self._execution_id, devbox_id=self._devbox_id, @@ -103,6 +143,11 @@ async def get_state(self, **options: Unpack[RequestOptions]) -> DevboxAsyncExecu ) async def kill(self, **options: Unpack[LongRequestOptions]) -> None: + """Request termination of the running execution. + + Args: + **options: Optional long-running request configuration. + """ await self._client.devboxes.executions.kill( self._execution_id, devbox_id=self._devbox_id, diff --git a/src/runloop_api_client/sdk/async_execution_result.py b/src/runloop_api_client/sdk/async_execution_result.py index 4c135f558..bac277b7a 100644 --- a/src/runloop_api_client/sdk/async_execution_result.py +++ b/src/runloop_api_client/sdk/async_execution_result.py @@ -12,8 +12,10 @@ class AsyncExecutionResult: - """ - Completed asynchronous command execution result. + """Completed asynchronous command execution result. + + Provides convenient helpers to inspect process exit status and captured + output. """ def __init__( @@ -32,22 +34,47 @@ def __repr__(self) -> str: @property def devbox_id(self) -> str: + """Associated devbox identifier. + + Returns: + str: Devbox ID where the command executed. + """ return self._devbox_id @property def execution_id(self) -> str: + """Underlying execution identifier. + + Returns: + str: Unique execution ID. + """ return self._result.execution_id @property def exit_code(self) -> int | None: + """Process exit code, or ``None`` if unavailable. + + Returns: + int | None: Exit status code. + """ return self._result.exit_status @property def success(self) -> bool: + """Whether the process exited successfully (exit code ``0``). + + Returns: + bool: ``True`` if the exit code is ``0``. + """ return self.exit_code == 0 @property def failed(self) -> bool: + """Whether the process exited with a non-zero exit code. + + Returns: + bool: ``True`` if the exit code is non-zero. + """ exit_code = self.exit_code return exit_code is not None and exit_code != 0 @@ -78,7 +105,17 @@ async def _get_output( num_lines: Optional[int], stream_fn: Callable[[], Awaitable[AsyncStream[ExecutionUpdateChunk]]], ) -> str: - """Common logic for getting output with optional line limiting and streaming.""" + """Common helper for fetching buffered or streamed output. + + Args: + current_output: Cached output string from the API. + is_truncated: Whether ``current_output`` is truncated. + num_lines: Optional number of tail lines to return. + stream_fn: Awaitable returning a streaming iterator for full output. + + Returns: + str: Output string honoring ``num_lines`` if provided. + """ # Check if we have enough lines already if num_lines is not None and (not is_truncated or self._count_non_empty_lines(current_output) >= num_lines): return self._get_last_n_lines(current_output, num_lines) @@ -97,10 +134,10 @@ async def stdout(self, num_lines: Optional[int] = None) -> str: Return captured standard output, streaming full output if truncated. Args: - num_lines: Optional number of lines to return from the end (most recent) + num_lines: Optional number of lines to return from the end (most recent). Returns: - stdout content, optionally limited to last N lines + str: Stdout content, optionally limited to the last ``num_lines`` lines. """ return await self._get_output( self._result.stdout or "", @@ -116,10 +153,10 @@ async def stderr(self, num_lines: Optional[int] = None) -> str: Return captured standard error, streaming full output if truncated. Args: - num_lines: Optional number of lines to return from the end (most recent) + num_lines: Optional number of lines to return from the end (most recent). Returns: - stderr content, optionally limited to last N lines + str: Stderr content, optionally limited to the last ``num_lines`` lines. """ return await self._get_output( self._result.stderr or "", @@ -132,4 +169,9 @@ async def stderr(self, num_lines: Optional[int] = None) -> str: @property def raw(self) -> DevboxAsyncExecutionDetailView: + """Access the underlying API response. + + Returns: + DevboxAsyncExecutionDetailView: Raw API payload. + """ return self._result diff --git a/src/runloop_api_client/sdk/async_snapshot.py b/src/runloop_api_client/sdk/async_snapshot.py index d21b06cda..765eddaad 100644 --- a/src/runloop_api_client/sdk/async_snapshot.py +++ b/src/runloop_api_client/sdk/async_snapshot.py @@ -18,15 +18,19 @@ class AsyncSnapshot: - """ - Async wrapper around snapshot operations. - """ + """Async wrapper around snapshot operations.""" def __init__( self, client: AsyncRunloop, snapshot_id: str, ) -> None: + """Initialize the wrapper. + + Args: + client: Generated AsyncRunloop client. + snapshot_id: Snapshot identifier returned by the API. + """ self._client = client self._id = snapshot_id @@ -36,12 +40,25 @@ def __repr__(self) -> str: @property def id(self) -> str: + """Return the snapshot identifier. + + Returns: + str: Unique snapshot ID. + """ return self._id async def get_info( self, **options: Unpack[RequestOptions], ) -> DevboxSnapshotAsyncStatusView: + """Retrieve the latest snapshot status. + + Args: + **options: Optional request configuration. + + Returns: + DevboxSnapshotAsyncStatusView: Snapshot state payload. + """ return await self._client.devboxes.disk_snapshots.query_status( self._id, **options, @@ -51,6 +68,14 @@ async def update( self, **params: Unpack[SDKDiskSnapshotUpdateParams], ) -> DevboxSnapshotView: + """Update snapshot metadata. + + Args: + **params: Fields to update on the snapshot. + + Returns: + DevboxSnapshotView: Updated snapshot details. + """ return await self._client.devboxes.disk_snapshots.update( self._id, **params, @@ -60,6 +85,14 @@ async def delete( self, **options: Unpack[LongRequestOptions], ) -> object: + """Delete the snapshot. + + Args: + **options: Optional long-running request configuration. + + Returns: + object: API response acknowledging deletion. + """ return await self._client.devboxes.disk_snapshots.delete( self._id, **options, @@ -69,6 +102,14 @@ async def await_completed( self, **options: Unpack[PollingRequestOptions], ) -> DevboxSnapshotAsyncStatusView: + """Block until the snapshot operation finishes. + + Args: + **options: Polling configuration (timeouts, intervals). + + Returns: + DevboxSnapshotAsyncStatusView: Final snapshot status. + """ return await self._client.devboxes.disk_snapshots.await_completed( self._id, **options, @@ -78,6 +119,14 @@ async def create_devbox( self, **params: Unpack[SDKDevboxExtraCreateParams], ) -> "AsyncDevbox": + """Create a devbox restored from this snapshot. + + Args: + **params: Creation parameters forwarded to the devbox API. + + Returns: + AsyncDevbox: Wrapper bound to the running devbox. + """ devbox_view = await self._client.devboxes.create_and_await_running( snapshot_id=self._id, **params, diff --git a/src/runloop_api_client/sdk/async_storage_object.py b/src/runloop_api_client/sdk/async_storage_object.py index e2bd443e6..6f3df03b4 100644 --- a/src/runloop_api_client/sdk/async_storage_object.py +++ b/src/runloop_api_client/sdk/async_storage_object.py @@ -11,11 +11,16 @@ class AsyncStorageObject: - """ - Async wrapper around storage object operations. - """ + """Async wrapper around storage object operations, including uploads and downloads.""" def __init__(self, client: AsyncRunloop, object_id: str, upload_url: str | None) -> None: + """Initialize the wrapper. + + Args: + client: Generated AsyncRunloop client. + object_id: Storage object identifier returned by the API. + upload_url: Optional pre-signed upload URL if the object is still open. + """ self._client = client self._id = object_id self._upload_url = upload_url @@ -26,16 +31,34 @@ def __repr__(self) -> str: @property def id(self) -> str: + """Return the storage object identifier. + + Returns: + str: Unique object ID. + """ return self._id @property def upload_url(self) -> str | None: + """Return the pre-signed upload URL, if available. + + Returns: + str | None: Upload URL when the object is pending completion. + """ return self._upload_url async def refresh( self, **options: Unpack[RequestOptions], ) -> ObjectView: + """Fetch the latest metadata for the object. + + Args: + **options: Optional request configuration. + + Returns: + ObjectView: Updated object metadata. + """ return await self._client.objects.retrieve( self._id, **options, @@ -45,6 +68,14 @@ async def complete( self, **options: Unpack[LongRequestOptions], ) -> ObjectView: + """Mark the object as fully uploaded. + + Args: + **options: Optional long-running request configuration. + + Returns: + ObjectView: Finalized object metadata. + """ result = await self._client.objects.complete( self._id, **options, @@ -56,6 +87,14 @@ async def get_download_url( self, **params: Unpack[SDKObjectDownloadParams], ) -> ObjectDownloadURLView: + """Request a signed download URL for the object. + + Args: + **params: Parameters controlling the download URL (e.g., expiry). + + Returns: + ObjectDownloadURLView: URL + metadata describing the download. + """ return await self._client.objects.download( self._id, **params, @@ -65,6 +104,14 @@ async def download_as_bytes( self, **params: Unpack[SDKObjectDownloadParams], ) -> bytes: + """Download the object contents as bytes. + + Args: + **params: Parameters forwarded to ``get_download_url``. + + Returns: + bytes: Entire object payload. + """ url_view = await self.get_download_url( **params, ) @@ -76,6 +123,14 @@ async def download_as_text( self, **params: Unpack[SDKObjectDownloadParams], ) -> str: + """Download the object contents as UTF-8 text. + + Args: + **params: Parameters forwarded to ``get_download_url``. + + Returns: + str: Entire object payload decoded as UTF-8. + """ url_view = await self.get_download_url( **params, ) @@ -88,17 +143,42 @@ async def delete( self, **options: Unpack[LongRequestOptions], ) -> ObjectView: + """Delete the storage object. + + Args: + **options: Optional long-running request configuration. + + Returns: + ObjectView: API response for the deleted object. + """ return await self._client.objects.delete( self._id, **options, ) async def upload_content(self, content: str | bytes) -> None: + """Upload content to the object's pre-signed URL. + + Args: + content: Bytes or text payload to upload. + + Raises: + RuntimeError: If no upload URL is available. + httpx.HTTPStatusError: Propagated from the underlying ``httpx`` client when the upload fails. + """ url = self._ensure_upload_url() response = await self._client._client.put(url, content=content) response.raise_for_status() def _ensure_upload_url(self) -> str: + """Return the upload URL, ensuring it exists. + + Returns: + str: Upload URL ready for use. + + Raises: + RuntimeError: If no upload URL is available. + """ if not self._upload_url: raise RuntimeError("No upload URL available. Create a new object before uploading content.") return self._upload_url diff --git a/src/runloop_api_client/sdk/blueprint.py b/src/runloop_api_client/sdk/blueprint.py index eb58c151c..a3e8d16f6 100644 --- a/src/runloop_api_client/sdk/blueprint.py +++ b/src/runloop_api_client/sdk/blueprint.py @@ -12,15 +12,19 @@ class Blueprint: - """ - High-level wrapper around a blueprint resource. - """ + """Synchronous wrapper around a blueprint resource.""" def __init__( self, client: Runloop, blueprint_id: str, ) -> None: + """Initialize the wrapper. + + Args: + client: Generated Runloop client. + blueprint_id: Blueprint ID returned by the API. + """ self._client = client self._id = blueprint_id @@ -30,12 +34,25 @@ def __repr__(self) -> str: @property def id(self) -> str: + """Return the blueprint ID. + + Returns: + str: Unique blueprint ID. + """ return self._id def get_info( self, **options: Unpack[RequestOptions], ) -> BlueprintView: + """Retrieve the latest blueprint details. + + Args: + **options: Optional request configuration. + + Returns: + BlueprintView: API response describing the blueprint. + """ return self._client.blueprints.retrieve( self._id, **options, @@ -45,6 +62,14 @@ def logs( self, **options: Unpack[RequestOptions], ) -> BlueprintBuildLogsListView: + """Retrieve build logs for the blueprint. + + Args: + **options: Optional request configuration. + + Returns: + BlueprintBuildLogsListView: Log entries for the most recent build. + """ return self._client.blueprints.logs( self._id, **options, @@ -54,6 +79,14 @@ def delete( self, **options: Unpack[LongRequestOptions], ) -> object: + """Delete the blueprint. + + Args: + **options: Optional long-running request configuration. + + Returns: + object: API response acknowledging deletion. + """ return self._client.blueprints.delete( self._id, **options, @@ -63,6 +96,14 @@ def create_devbox( self, **params: Unpack[SDKDevboxExtraCreateParams], ) -> "Devbox": + """Create a devbox derived from the blueprint. + + Args: + **params: Creation parameters to forward to the devbox API. + + Returns: + Devbox: Wrapper bound to the running devbox. + """ devbox_view = self._client.devboxes.create_and_await_running( blueprint_id=self._id, **params, diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index 9895c03db..31474b6f0 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -66,6 +66,12 @@ class Devbox: """ def __init__(self, client: Runloop, devbox_id: str) -> None: + """Initialize the wrapper. + + Args: + client: Generated Runloop client. + devbox_id: Devbox identifier returned by the API. + """ self._client = client self._id = devbox_id self._logger = logging.getLogger(__name__) @@ -75,9 +81,15 @@ def __repr__(self) -> str: return f"" def __enter__(self) -> "Devbox": + """Enable ``with devbox`` usage by returning ``self``. + + Returns: + Devbox: The active devbox instance. + """ return self def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: Any) -> None: + """Shutdown the devbox when leaving a context manager.""" try: self.shutdown() except Exception: @@ -85,6 +97,11 @@ def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | No @property def id(self) -> str: + """Return the devbox identifier. + + Returns: + str: Unique devbox ID. + """ return self._id def get_info( @@ -93,8 +110,11 @@ def get_info( ) -> DevboxView: """Retrieve current devbox status and metadata. + Args: + **options: Optional request configuration. + Returns: - DevboxView containing the devbox's current state, status, and metadata. + DevboxView: Current devbox state info. """ return self._client.devboxes.retrieve( self._id, @@ -110,7 +130,7 @@ def await_running(self, *, polling_config: PollingConfig | None = None) -> Devbo polling_config: Optional configuration for polling behavior (timeout, interval). Returns: - DevboxView with the devbox in running state. + DevboxView: Devbox state info after it reaches running status. """ return self._client.devboxes.await_running(self._id, polling_config=polling_config) @@ -123,7 +143,7 @@ def await_suspended(self, *, polling_config: PollingConfig | None = None) -> Dev polling_config: Optional configuration for polling behavior (timeout, interval). Returns: - DevboxView with the devbox in suspended state. + DevboxView: Devbox state info after it reaches suspended status. """ return self._client.devboxes.await_suspended(self._id, polling_config=polling_config) @@ -133,8 +153,11 @@ def shutdown( ) -> DevboxView: """Shutdown the devbox, terminating all processes and releasing resources. + Args: + **options: Long-running request configuration (timeouts, retries, etc.). + Returns: - DevboxView with the final devbox state. + DevboxView: Final devbox state info. """ return self._client.devboxes.shutdown( self._id, @@ -151,10 +174,10 @@ def suspend( Waits for the devbox to reach suspended state before returning. Args: - polling_config: Optional configuration for polling behavior (timeout, interval). + **options: Optional long-running request and polling configuration. Returns: - DevboxView with the devbox in suspended state. + DevboxView: Suspended devbox state info. """ self._client.devboxes.suspend( self._id, @@ -171,10 +194,10 @@ def resume( Waits for the devbox to reach running state before returning. Args: - polling_config: Optional configuration for polling behavior (timeout, interval). + **options: Optional long-running request and polling configuration. Returns: - DevboxView with the devbox in running state. + DevboxView: Resumed devbox state info. """ self._client.devboxes.resume( self._id, @@ -191,8 +214,11 @@ def keep_alive( Call this periodically for long-running workflows to prevent the devbox from being automatically shut down due to inactivity. + Args: + **options: Optional long-running request configuration. + Returns: - Response object confirming the keep-alive request. + object: Response confirming the keep-alive request. """ return self._client.devboxes.keep_alive( self._id, @@ -209,13 +235,10 @@ def snapshot_disk( new devboxes with the same state. Args: - commit_message: Optional message describing the snapshot. - metadata: Optional key-value metadata to attach to the snapshot. - name: Optional name for the snapshot. - polling_config: Optional configuration for polling behavior (timeout, interval). + **params: Snapshot metadata, naming, and polling configuration. Returns: - Snapshot object representing the completed snapshot. + Snapshot: Wrapper representing the completed snapshot. """ snapshot_data = self._client.devboxes.snapshot_disk_async( self._id, @@ -235,12 +258,10 @@ def snapshot_disk_async( for completion. Use snapshot.await_completed() to wait for completion. Args: - commit_message: Optional message describing the snapshot. - metadata: Optional key-value metadata to attach to the snapshot. - name: Optional name for the snapshot. + **params: Snapshot metadata and naming options. Returns: - Snapshot object (snapshot may still be in progress). + Snapshot: Wrapper representing the snapshot (may still be processing). """ snapshot_data = self._client.devboxes.snapshot_disk_async( self._id, @@ -249,18 +270,34 @@ def snapshot_disk_async( return self._snapshot_from_id(snapshot_data.id) def close(self) -> None: + """Alias for :meth:`shutdown` to support common resource patterns.""" self.shutdown() @property def cmd(self) -> CommandInterface: + """Return the command execution interface. + + Returns: + CommandInterface: Helper for running shell commands. + """ return _CommandInterface(self) @property def file(self) -> FileInterface: + """Return the file operations interface. + + Returns: + FileInterface: Helper for reading/writing files. + """ return _FileInterface(self) @property def net(self) -> NetworkInterface: + """Return the networking interface. + + Returns: + NetworkInterface: Helper for SSH keys and tunnels. + """ return _NetworkInterface(self) # --------------------------------------------------------------------- # @@ -374,19 +411,13 @@ def exec( """Execute a command synchronously and wait for completion. Args: - command: The shell command to execute. - shell_name: Optional shell to use (e.g., "bash", "sh"). - stdout: Optional callback to receive stdout lines in real-time. - stderr: Optional callback to receive stderr lines in real-time. - output: Optional callback to receive combined output lines in real-time. - polling_config: Optional configuration for polling behavior. - attach_stdin: Whether to attach stdin for interactive commands. + **params: Command parameters, streaming callbacks, and polling config. Returns: - ExecutionResult with exit code and captured output. + ExecutionResult: Wrapper with exit status and output helpers. Example: - >>> result = devbox.cmd.exec("ls -la") + >>> result = devbox.cmd.exec(command="ls -la") >>> print(result.stdout()) >>> print(f"Exit code: {result.exit_code}") """ @@ -413,9 +444,7 @@ def exec( ) if streaming_group is not None: - # Ensure log streaming has drained before returning the result. _stop_streaming() - # below will perform the final cleanup, but we still join here so callers only - # resume once all logs have been delivered. + # Ensure log streaming has completed before returning the result. streaming_group.join() return ExecutionResult(client, devbox.id, final) @@ -431,18 +460,12 @@ def exec_async( execution.kill() to terminate the process. Args: - command: The shell command to execute. - shell_name: Optional shell to use (e.g., "bash", "sh"). - stdout: Optional callback to receive stdout lines in real-time. - stderr: Optional callback to receive stderr lines in real-time. - output: Optional callback to receive combined output lines in real-time. - attach_stdin: Whether to attach stdin for interactive commands. - + **params: Command parameters and streaming callbacks. Returns: - Execution object for managing the running process. + Execution: Handle for managing the running process. Example: - >>> execution = devbox.cmd.exec_async("sleep 10") + >>> execution = devbox.cmd.exec_async(command="sleep 10") >>> state = execution.get_state() >>> print(f"Status: {state.status}") >>> execution.kill() # Terminate early if needed @@ -481,10 +504,10 @@ def read( """Read a file from the devbox. Args: - path: Absolute path to the file in the devbox. + **params: Parameters such as ``path``. Returns: - File contents as a string. + str: File contents. Example: >>> content = devbox.file.read("/home/user/data.txt") @@ -504,11 +527,10 @@ def write( Creates or overwrites the file at the specified path. Args: - file_path: Absolute path to the file in the devbox. - contents: File contents as string. + **params: Parameters such as ``file_path`` and ``contents``. Returns: - Execution details for the write operation. + DevboxExecutionDetailView: Execution metadata for the write command. Example: >>> devbox.file.write(file_path="/home/user/config.json", contents='{"key": "value"}') @@ -525,10 +547,10 @@ def download( """Download a file from the devbox. Args: - path: Absolute path to the file in the devbox. + **params: Parameters such as ``path``. Returns: - File contents as bytes. + bytes: Raw file contents. Example: >>> data = devbox.file.download("/home/user/output.bin") @@ -548,11 +570,10 @@ def upload( """Upload a file to the devbox. Args: - path: Destination path in the devbox. - file: File to upload (Path-like object or bytes). + **params: Parameters such as destination ``path`` and local ``file``. Returns: - Response object confirming the upload. + object: API response confirming the upload. Example: >>> from pathlib import Path @@ -579,8 +600,11 @@ def create_ssh_key( ) -> DevboxCreateSSHKeyResponse: """Create an SSH key for remote access to the devbox. + Args: + **options: Optional long-running request configuration. + Returns: - SSH key response containing the SSH URL and credentials. + DevboxCreateSSHKeyResponse: Response containing SSH connection info. Example: >>> ssh_key = devbox.net.create_ssh_key() @@ -598,10 +622,10 @@ def create_tunnel( """Create a network tunnel to expose a devbox port publicly. Args: - port: The port number in the devbox to expose. + **params: Parameters such as the devbox ``port`` to expose. Returns: - DevboxTunnelView containing the public URL for the tunnel. + DevboxTunnelView: Details about the public endpoint. Example: >>> tunnel = devbox.net.create_tunnel(port=8080) @@ -619,10 +643,10 @@ def remove_tunnel( """Remove a network tunnel, disabling public access to the port. Args: - port: The port number of the tunnel to remove. + **params: Parameters such as the ``port`` to close. Returns: - Response object confirming the tunnel removal. + object: Response confirming the tunnel removal. Example: >>> devbox.net.remove_tunnel(port=8080) diff --git a/src/runloop_api_client/sdk/execution.py b/src/runloop_api_client/sdk/execution.py index 6b8e0bde6..458efa30c 100644 --- a/src/runloop_api_client/sdk/execution.py +++ b/src/runloop_api_client/sdk/execution.py @@ -41,14 +41,14 @@ class Execution: """Manages an asynchronous command execution on a devbox. Provides methods to poll execution state, wait for completion, and terminate - the running process. Created by devbox.cmd.exec_async(). + the running process. Created by ``devbox.cmd.exec_async()``. Attributes: execution_id: The unique execution identifier. devbox_id: The devbox where the command is executing. Example: - >>> execution = devbox.cmd.exec_async("python train.py") + >>> execution = devbox.cmd.exec_async(command="python train.py") >>> state = execution.get_state() >>> if state.status == "running": ... execution.kill() @@ -75,15 +75,30 @@ def __repr__(self) -> str: @property def execution_id(self) -> str: + """Return the execution identifier. + + Returns: + str: Unique execution ID. + """ return self._execution_id @property def devbox_id(self) -> str: + """Return the devbox identifier. + + Returns: + str: Devbox ID where the command is running. + """ return self._devbox_id def result(self, **options: Unpack[LongRequestOptions]) -> ExecutionResult: - """ - Wait for completion and return an :class:`ExecutionResult`. + """Wait for completion and return an :class:`ExecutionResult`. + + Args: + **options: Optional long-running request configuration. + + Returns: + ExecutionResult: Wrapper with exit status and output helpers. """ # Wait for command completion final = self._client.devboxes.wait_for_command( @@ -101,8 +116,13 @@ def result(self, **options: Unpack[LongRequestOptions]) -> ExecutionResult: return ExecutionResult(self._client, self._devbox_id, final) def get_state(self, **options: Unpack[RequestOptions]) -> DevboxAsyncExecutionDetailView: - """ - Fetch the latest execution state. + """Fetch the latest execution state. + + Args: + **options: Optional request configuration. + + Returns: + DevboxAsyncExecutionDetailView: Current execution metadata. """ return self._client.devboxes.executions.retrieve( self._execution_id, @@ -111,8 +131,10 @@ def get_state(self, **options: Unpack[RequestOptions]) -> DevboxAsyncExecutionDe ) def kill(self, **options: Unpack[LongRequestOptions]) -> None: - """ - Request termination of the running execution. + """Request termination of the running execution. + + Args: + **options: Optional long-running request configuration. """ self._client.devboxes.executions.kill( self._execution_id, diff --git a/src/runloop_api_client/sdk/execution_result.py b/src/runloop_api_client/sdk/execution_result.py index 17f0e624e..5715e46cb 100644 --- a/src/runloop_api_client/sdk/execution_result.py +++ b/src/runloop_api_client/sdk/execution_result.py @@ -34,27 +34,47 @@ def __repr__(self) -> str: @property def devbox_id(self) -> str: - """Associated devbox identifier.""" + """Associated devbox identifier. + + Returns: + str: Devbox ID where the command executed. + """ return self._devbox_id @property def execution_id(self) -> str: - """Underlying execution identifier.""" + """Underlying execution identifier. + + Returns: + str: Unique execution ID. + """ return self._result.execution_id @property def exit_code(self) -> int | None: - """Process exit code, or ``None`` if unavailable.""" + """Process exit code, or ``None`` if unavailable. + + Returns: + int | None: Exit status code. + """ return self._result.exit_status @property def success(self) -> bool: - """Whether the process exited successfully (exit code ``0``).""" + """Whether the process exited successfully (exit code ``0``). + + Returns: + bool: ``True`` if the exit code is ``0``. + """ return self.exit_code == 0 @property def failed(self) -> bool: - """Whether the process exited with a non-zero exit code.""" + """Whether the process exited with a non-zero exit code. + + Returns: + bool: ``True`` if the exit code is non-zero. + """ exit_code = self.exit_code return exit_code is not None and exit_code != 0 @@ -85,7 +105,17 @@ def _get_output( num_lines: Optional[int], stream_fn: Callable[[], Stream[ExecutionUpdateChunk]], ) -> str: - """Common logic for getting output with optional line limiting and streaming.""" + """Common helper for fetching buffered or streamed output. + + Args: + current_output: Cached output string from the API. + is_truncated: Whether ``current_output`` is truncated. + num_lines: Optional number of tail lines to return. + stream_fn: Callable returning a streaming iterator for full output. + + Returns: + str: Output string honoring ``num_lines`` if provided. + """ # Check if we have enough lines already if num_lines is not None and (not is_truncated or self._count_non_empty_lines(current_output) >= num_lines): return self._get_last_n_lines(current_output, num_lines) @@ -103,10 +133,10 @@ def stdout(self, num_lines: Optional[int] = None) -> str: Return captured standard output, streaming full output if truncated. Args: - num_lines: Optional number of lines to return from the end (most recent) + num_lines: Optional number of lines to return from the end (most recent). Returns: - stdout content, optionally limited to last N lines + str: Stdout content, optionally limited to the last ``num_lines`` lines. """ return self._get_output( self._result.stdout or "", @@ -122,10 +152,10 @@ def stderr(self, num_lines: Optional[int] = None) -> str: Return captured standard error, streaming full output if truncated. Args: - num_lines: Optional number of lines to return from the end (most recent) + num_lines: Optional number of lines to return from the end (most recent). Returns: - stderr content, optionally limited to last N lines + str: Stderr content, optionally limited to the last ``num_lines`` lines. """ return self._get_output( self._result.stderr or "", @@ -138,5 +168,9 @@ def stderr(self, num_lines: Optional[int] = None) -> str: @property def raw(self) -> DevboxAsyncExecutionDetailView: - """Access the underlying API response.""" + """Access the underlying API response. + + Returns: + DevboxAsyncExecutionDetailView: Raw API payload. + """ return self._result diff --git a/src/runloop_api_client/sdk/snapshot.py b/src/runloop_api_client/sdk/snapshot.py index 81ae563a7..0b6d0b5ea 100644 --- a/src/runloop_api_client/sdk/snapshot.py +++ b/src/runloop_api_client/sdk/snapshot.py @@ -18,15 +18,19 @@ class Snapshot: - """ - Wrapper around snapshot operations. - """ + """Wrapper around synchronous snapshot operations.""" def __init__( self, client: Runloop, snapshot_id: str, ) -> None: + """Initialize the wrapper. + + Args: + client: Generated Runloop client. + snapshot_id: Snapshot identifier returned by the API. + """ self._client = client self._id = snapshot_id @@ -36,12 +40,25 @@ def __repr__(self) -> str: @property def id(self) -> str: + """Return the snapshot identifier. + + Returns: + str: Unique snapshot ID. + """ return self._id def get_info( self, **options: Unpack[RequestOptions], ) -> DevboxSnapshotAsyncStatusView: + """Retrieve the latest snapshot status. + + Args: + **options: Optional request configuration. + + Returns: + DevboxSnapshotAsyncStatusView: Snapshot state payload. + """ return self._client.devboxes.disk_snapshots.query_status( self._id, **options, @@ -51,6 +68,14 @@ def update( self, **params: Unpack[SDKDiskSnapshotUpdateParams], ) -> DevboxSnapshotView: + """Update snapshot metadata. + + Args: + **params: Fields to update on the snapshot. + + Returns: + DevboxSnapshotView: Updated snapshot details. + """ return self._client.devboxes.disk_snapshots.update( self._id, **params, @@ -60,6 +85,14 @@ def delete( self, **options: Unpack[LongRequestOptions], ) -> object: + """Delete the snapshot. + + Args: + **options: Optional long-running request configuration. + + Returns: + object: API response acknowledging deletion. + """ return self._client.devboxes.disk_snapshots.delete( self._id, **options, @@ -69,6 +102,14 @@ def await_completed( self, **options: Unpack[PollingRequestOptions], ) -> DevboxSnapshotAsyncStatusView: + """Block until the snapshot operation finishes. + + Args: + **options: Polling configuration (timeouts, intervals). + + Returns: + DevboxSnapshotAsyncStatusView: Final snapshot status. + """ return self._client.devboxes.disk_snapshots.await_completed( self._id, **options, @@ -78,6 +119,14 @@ def create_devbox( self, **params: Unpack[SDKDevboxExtraCreateParams], ) -> "Devbox": + """Create a devbox restored from this snapshot. + + Args: + **params: Creation parameters to forward to the devbox API. + + Returns: + Devbox: Wrapper bound to the running devbox. + """ devbox_view = self._client.devboxes.create_and_await_running( snapshot_id=self._id, **params, diff --git a/src/runloop_api_client/sdk/storage_object.py b/src/runloop_api_client/sdk/storage_object.py index c4f3f954f..c14991156 100644 --- a/src/runloop_api_client/sdk/storage_object.py +++ b/src/runloop_api_client/sdk/storage_object.py @@ -11,11 +11,16 @@ class StorageObject: - """ - Wrapper around storage object operations, including uploads and downloads. - """ + """Wrapper around storage object operations, including uploads and downloads.""" def __init__(self, client: Runloop, object_id: str, upload_url: str | None) -> None: + """Initialize the wrapper. + + Args: + client: Generated Runloop client. + object_id: Storage object identifier returned by the API. + upload_url: Pre-signed upload URL, if the object is in draft state. + """ self._client = client self._id = object_id self._upload_url = upload_url @@ -26,16 +31,34 @@ def __repr__(self) -> str: @property def id(self) -> str: + """Return the storage object identifier. + + Returns: + str: Unique object ID. + """ return self._id @property def upload_url(self) -> str | None: + """Return the pre-signed upload URL, if available. + + Returns: + str | None: Upload URL when the object is pending completion. + """ return self._upload_url def refresh( self, **options: Unpack[RequestOptions], ) -> ObjectView: + """Fetch the latest metadata for the object. + + Args: + **options: Optional request configuration. + + Returns: + ObjectView: Updated object metadata. + """ return self._client.objects.retrieve( self._id, **options, @@ -45,6 +68,14 @@ def complete( self, **options: Unpack[LongRequestOptions], ) -> ObjectView: + """Mark the object as fully uploaded. + + Args: + **options: Optional long-running request configuration. + + Returns: + ObjectView: Finalized object metadata. + """ result = self._client.objects.complete( self._id, **options, @@ -56,6 +87,14 @@ def get_download_url( self, **params: Unpack[SDKObjectDownloadParams], ) -> ObjectDownloadURLView: + """Request a signed download URL for the object. + + Args: + **params: Parameters controlling the download URL (e.g., expiry). + + Returns: + ObjectDownloadURLView: URL + metadata describing the download. + """ return self._client.objects.download( self._id, **params, @@ -65,6 +104,14 @@ def download_as_bytes( self, **params: Unpack[SDKObjectDownloadParams], ) -> bytes: + """Download the object contents as bytes. + + Args: + **params: Parameters forwarded to ``get_download_url``. + + Returns: + bytes: Entire object payload. + """ url_view = self.get_download_url( **params, ) @@ -76,6 +123,14 @@ def download_as_text( self, **params: Unpack[SDKObjectDownloadParams], ) -> str: + """Download the object contents as UTF-8 text. + + Args: + **params: Parameters forwarded to ``get_download_url``. + + Returns: + str: Entire object payload decoded as UTF-8. + """ url_view = self.get_download_url( **params, ) @@ -88,17 +143,42 @@ def delete( self, **options: Unpack[LongRequestOptions], ) -> ObjectView: + """Delete the storage object. + + Args: + **options: Optional long-running request configuration. + + Returns: + ObjectView: API response for the deleted object. + """ return self._client.objects.delete( self._id, **options, ) def upload_content(self, content: str | bytes) -> None: + """Upload content to the object's pre-signed URL. + + Args: + content: Bytes or text payload to upload. + + Raises: + RuntimeError: If no upload URL is available. + httpx.HTTPStatusError: Propagated from the underlying ``httpx`` client when the upload fails. + """ url = self._ensure_upload_url() response = self._client._client.put(url, content=content) response.raise_for_status() def _ensure_upload_url(self) -> str: + """Return the upload URL, ensuring it is present. + + Returns: + str: Upload URL ready for use. + + Raises: + RuntimeError: If no upload URL is available. + """ if not self._upload_url: raise RuntimeError("No upload URL available. Create a new object before uploading content.") return self._upload_url diff --git a/src/runloop_api_client/sdk/sync.py b/src/runloop_api_client/sdk/sync.py index 25e3b3ef2..9e5f7cc42 100644 --- a/src/runloop_api_client/sdk/sync.py +++ b/src/runloop_api_client/sdk/sync.py @@ -32,22 +32,36 @@ class DevboxClient: """High-level manager for creating and managing Devbox instances. - Accessed via sdk.devbox, provides methods to create devboxes from scratch, - blueprints, or snapshots, and to list existing devboxes. + Accessed via ``runloop.devbox`` from :class:`RunloopSDK`, provides methods to + create devboxes from scratch, blueprints, or snapshots, and to list + existing devboxes. Example: - >>> sdk = RunloopSDK() - >>> devbox = sdk.devbox.create(name="my-devbox") - >>> devboxes = sdk.devbox.list(limit=10) + >>> runloop = RunloopSDK() + >>> devbox = runloop.devbox.create(name="my-devbox") + >>> devboxes = runloop.devbox.list(limit=10) """ def __init__(self, client: Runloop) -> None: + """Initialize the manager. + + Args: + client: Generated Runloop client to wrap. + """ self._client = client def create( self, **params: Unpack[SDKDevboxCreateParams], ) -> Devbox: + """Provision a new devbox and wait until it reaches ``running`` state. + + Args: + **params: Keyword arguments forwarded to the devbox creation API. + + Returns: + Devbox: Wrapper bound to the newly created devbox. + """ devbox_view = self._client.devboxes.create_and_await_running( **params, ) @@ -58,6 +72,15 @@ def create_from_blueprint_id( blueprint_id: str, **params: Unpack[SDKDevboxExtraCreateParams], ) -> Devbox: + """Create a devbox from an existing blueprint by identifier. + + Args: + blueprint_id: Blueprint ID to create from. + **params: Additional creation parameters (metadata, launch parameters, etc.). + + Returns: + Devbox: Wrapper bound to the newly created devbox. + """ devbox_view = self._client.devboxes.create_and_await_running( blueprint_id=blueprint_id, **params, @@ -69,6 +92,15 @@ def create_from_blueprint_name( blueprint_name: str, **params: Unpack[SDKDevboxExtraCreateParams], ) -> Devbox: + """Create a devbox from the latest blueprint with the given name. + + Args: + blueprint_name: Blueprint name to create from. + **params: Additional creation parameters (metadata, launch parameters, etc.). + + Returns: + Devbox: Wrapper bound to the newly created devbox. + """ devbox_view = self._client.devboxes.create_and_await_running( blueprint_name=blueprint_name, **params, @@ -80,6 +112,15 @@ def create_from_snapshot( snapshot_id: str, **params: Unpack[SDKDevboxExtraCreateParams], ) -> Devbox: + """Create a devbox initialized from a snapshot. + + Args: + snapshot_id: Snapshot ID to create from. + **params: Additional creation parameters (metadata, launch parameters, etc.). + + Returns: + Devbox: Wrapper bound to the newly created devbox. + """ devbox_view = self._client.devboxes.create_and_await_running( snapshot_id=snapshot_id, **params, @@ -87,6 +128,14 @@ def create_from_snapshot( return Devbox(self._client, devbox_view.id) def from_id(self, devbox_id: str) -> Devbox: + """Attach to an existing devbox by ID. + + Args: + devbox_id: Existing devbox ID. + + Returns: + Devbox: Wrapper bound to the requested devbox. + """ self._client.devboxes.await_running(devbox_id) return Devbox(self._client, devbox_id) @@ -94,6 +143,14 @@ def list( self, **params: Unpack[SDKDevboxListParams], ) -> list[Devbox]: + """List devboxes accessible to the caller. + + Args: + **params: Filtering and pagination parameters. + + Returns: + list[Devbox]: Collection of devbox wrappers. + """ page = self._client.devboxes.list( **params, ) @@ -103,62 +160,109 @@ def list( class SnapshotClient: """High-level manager for working with disk snapshots. - Accessed via sdk.snapshot, provides methods to list snapshots and access - snapshot details. + Accessed via ``runloop.snapshot`` from :class:`RunloopSDK`, provides methods + to list snapshots and access snapshot details. Example: - >>> sdk = RunloopSDK() - >>> snapshots = sdk.snapshot.list(devbox_id="dev-123") - >>> snapshot = sdk.snapshot.from_id("snap-123") + >>> runloop = RunloopSDK() + >>> snapshots = runloop.snapshot.list(devbox_id="dev-123") + >>> snapshot = runloop.snapshot.from_id("snap-123") """ def __init__(self, client: Runloop) -> None: + """Initialize the manager with the generated Runloop client.""" self._client = client def list( self, **params: Unpack[SDKDiskSnapshotListParams], ) -> list[Snapshot]: + """List snapshots created from devboxes. + + Args: + **params: Filtering and pagination parameters. + + Returns: + list[Snapshot]: Snapshot wrappers for each record. + """ page = self._client.devboxes.disk_snapshots.list( **params, ) return [Snapshot(self._client, item.id) for item in page.snapshots] def from_id(self, snapshot_id: str) -> Snapshot: + """Return a snapshot wrapper for the given ID. + + Args: + snapshot_id: Snapshot ID to wrap. + + Returns: + Snapshot: Wrapper for the snapshot resource. + """ return Snapshot(self._client, snapshot_id) class BlueprintClient: """High-level manager for creating and managing blueprints. - Accessed via sdk.blueprint, provides methods to create blueprints with - Dockerfiles and system setup commands, and to list existing blueprints. + Accessed via ``runloop.blueprint`` from :class:`RunloopSDK`, provides methods + to create blueprints with Dockerfiles and system setup commands, and to + list existing blueprints. Example: - >>> sdk = RunloopSDK() - >>> blueprint = sdk.blueprint.create(name="my-blueprint", dockerfile="FROM ubuntu:22.04\\nRUN apt-get update") - >>> blueprints = sdk.blueprint.list() + >>> runloop = RunloopSDK() + >>> blueprint = runloop.blueprint.create(name="my-blueprint", dockerfile="FROM ubuntu:22.04\\nRUN apt-get update") + >>> blueprints = runloop.blueprint.list() """ def __init__(self, client: Runloop) -> None: + """Initialize the manager. + + Args: + client: Generated Runloop client to wrap. + """ self._client = client def create( self, **params: Unpack[SDKBlueprintCreateParams], ) -> Blueprint: + """Create a blueprint and wait for the build to finish. + + Args: + **params: Blueprint definition (Dockerfile, metadata, etc.). + + Returns: + Blueprint: Wrapper bound to the finished blueprint. + """ blueprint = self._client.blueprints.create_and_await_build_complete( **params, ) return Blueprint(self._client, blueprint.id) def from_id(self, blueprint_id: str) -> Blueprint: + """Return a blueprint wrapper for the given ID. + + Args: + blueprint_id: Blueprint ID to wrap. + + Returns: + Blueprint: Wrapper for the blueprint resource. + """ return Blueprint(self._client, blueprint_id) def list( self, **params: Unpack[SDKBlueprintListParams], ) -> list[Blueprint]: + """List available blueprints. + + Args: + **params: Filtering and pagination parameters. + + Returns: + list[Blueprint]: Blueprint wrappers for each record. + """ page = self._client.blueprints.list( **params, ) @@ -168,33 +272,59 @@ def list( class StorageObjectClient: """High-level manager for creating and managing storage objects. - Accessed via sdk.storage_object, provides methods to create, upload, download, - and list storage objects with convenient helpers for file and text uploads. + Accessed via ``runloop.storage_object`` from :class:`RunloopSDK`, provides + methods to create, upload, download, and list storage objects with convenient + helpers for file and text uploads. Example: - >>> sdk = RunloopSDK() - >>> obj = sdk.storage_object.upload_from_text("Hello!", "greeting.txt") + >>> runloop = RunloopSDK() + >>> obj = runloop.storage_object.upload_from_text("Hello!", "greeting.txt") >>> content = obj.download_as_text() - >>> objects = sdk.storage_object.list() + >>> objects = runloop.storage_object.list() """ def __init__(self, client: Runloop) -> None: + """Initialize the manager with the generated Runloop client.""" self._client = client def create( self, **params: Unpack[SDKObjectCreateParams], ) -> StorageObject: + """Create a storage object and obtain an upload URL. + + Args: + **params: Object creation parameters (name, content type, metadata). + + Returns: + StorageObject: Wrapper with upload URL set for immediate uploads. + """ obj = self._client.objects.create(**params) return StorageObject(self._client, obj.id, upload_url=obj.upload_url) def from_id(self, object_id: str) -> StorageObject: + """Return a storage object wrapper by identifier. + + Args: + object_id: Storage object identifier to wrap. + + Returns: + StorageObject: Wrapper for the storage object resource. + """ return StorageObject(self._client, object_id, upload_url=None) def list( self, **params: Unpack[SDKObjectListParams], ) -> list[StorageObject]: + """List storage objects owned by the caller. + + Args: + **params: Filtering and pagination parameters. + + Returns: + list[StorageObject]: Storage object wrappers for each record. + """ page = self._client.objects.list( **params, ) @@ -209,6 +339,21 @@ def upload_from_file( metadata: Optional[Dict[str, str]] = None, **options: Unpack[LongRequestOptions], ) -> StorageObject: + """Create and upload an object from a local file path. + + Args: + file_path: Local filesystem path to read. + name: Optional object name; defaults to the file name. + content_type: Optional MIME type to apply to the object. + metadata: Optional key-value metadata. + **options: Additional request configuration. + + Returns: + StorageObject: Wrapper for the uploaded object. + + Raises: + OSError: If the local file cannot be read. + """ path = Path(file_path) try: @@ -231,6 +376,17 @@ def upload_from_text( metadata: Optional[Dict[str, str]] = None, **options: Unpack[LongRequestOptions], ) -> StorageObject: + """Create and upload an object from a text payload. + + Args: + text: Text content to upload. + name: Object display name. + metadata: Optional key-value metadata. + **options: Additional request configuration. + + Returns: + StorageObject: Wrapper for the uploaded object. + """ obj = self.create(name=name, content_type="text", metadata=metadata, **options) obj.upload_content(text) obj.complete() @@ -245,6 +401,18 @@ def upload_from_bytes( metadata: Optional[Dict[str, str]] = None, **options: Unpack[LongRequestOptions], ) -> StorageObject: + """Create and upload an object from a bytes payload. + + Args: + data: Binary payload to upload. + name: Object display name. + content_type: MIME type describing the payload. + metadata: Optional key-value metadata. + **options: Additional request configuration. + + Returns: + StorageObject: Wrapper for the uploaded object. + """ obj = self.create(name=name, content_type=content_type, metadata=metadata, **options) obj.upload_content(data) obj.complete() @@ -266,10 +434,11 @@ class RunloopSDK: storage_object: High-level interface for storage object management. Example: - >>> sdk = RunloopSDK() # Uses RUNLOOP_API_KEY env var - >>> with sdk.devbox.create(name="my-devbox") as devbox: - ... result = devbox.cmd.exec("echo 'hello'") - ... print(result.stdout()) + >>> runloop = RunloopSDK() # Uses RUNLOOP_API_KEY env var + >>> devbox = runloop.devbox.create(name="my-devbox") + >>> result = devbox.cmd.exec(command="echo 'hello'") + >>> print(result.stdout()) + >>> devbox.shutdown() """ api: Runloop @@ -289,6 +458,17 @@ def __init__( default_query: Mapping[str, object] | None = None, http_client: httpx.Client | None = None, ) -> None: + """Configure the synchronous SDK wrapper. + + Args: + bearer_token: API token; falls back to ``RUNLOOP_API_KEY`` env var. + base_url: Override the API base URL. + timeout: Request timeout (seconds) or ``Timeout`` object. + max_retries: Maximum automatic retry attempts. + default_headers: Headers merged into every request. + default_query: Default query parameters merged into every request. + http_client: Custom ``httpx.Client`` instance to reuse. + """ self.api = Runloop( bearer_token=bearer_token, base_url=base_url, @@ -305,10 +485,17 @@ def __init__( self.storage_object = StorageObjectClient(self.api) def close(self) -> None: + """Close the underlying HTTP client and release resources.""" self.api.close() def __enter__(self) -> "RunloopSDK": + """Allow ``with RunloopSDK() as runloop`` usage. + + Returns: + RunloopSDK: The active SDK instance. + """ return self def __exit__(self, *_exc_info: object) -> None: + """Ensure the API client closes when leaving the context manager.""" self.close() From 649504e71ee508a14be6adbbbf4da0e56f50b3a0 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 18 Nov 2025 12:15:02 -0800 Subject: [PATCH 47/56] formatting fixes --- src/runloop_api_client/sdk/async_.py | 2 +- src/runloop_api_client/sdk/sync.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/runloop_api_client/sdk/async_.py b/src/runloop_api_client/sdk/async_.py index 7f297f923..f7eff48cb 100644 --- a/src/runloop_api_client/sdk/async_.py +++ b/src/runloop_api_client/sdk/async_.py @@ -444,7 +444,7 @@ class AsyncRunloopSDK: storage_object: High-level async interface for storage object management. Example: - >>> runloop = AsyncRunloopSDK() # Uses RUNLOOP_API_KEY env var + >>> runloop = AsyncRunloopSDK() # Uses RUNLOOP_API_KEY env var >>> devbox = await runloop.devbox.create(name="my-devbox") >>> result = await devbox.cmd.exec(command="echo 'hello'") >>> print(await result.stdout()) diff --git a/src/runloop_api_client/sdk/sync.py b/src/runloop_api_client/sdk/sync.py index 9e5f7cc42..2c9d43768 100644 --- a/src/runloop_api_client/sdk/sync.py +++ b/src/runloop_api_client/sdk/sync.py @@ -211,7 +211,9 @@ class BlueprintClient: Example: >>> runloop = RunloopSDK() - >>> blueprint = runloop.blueprint.create(name="my-blueprint", dockerfile="FROM ubuntu:22.04\\nRUN apt-get update") + >>> blueprint = runloop.blueprint.create( + ... name="my-blueprint", dockerfile="FROM ubuntu:22.04\\nRUN apt-get update" + ... ) >>> blueprints = runloop.blueprint.list() """ From 0ed6886d7cac3b28bced36320bf703bfa3fa635a Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 18 Nov 2025 13:53:47 -0800 Subject: [PATCH 48/56] rename ExecutionResult .raw to .result --- src/runloop_api_client/sdk/async_execution_result.py | 6 +++--- src/runloop_api_client/sdk/execution_result.py | 6 +++--- tests/sdk/test_async_execution_result.py | 6 +++--- tests/sdk/test_execution_result.py | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/runloop_api_client/sdk/async_execution_result.py b/src/runloop_api_client/sdk/async_execution_result.py index bac277b7a..d93de2bbb 100644 --- a/src/runloop_api_client/sdk/async_execution_result.py +++ b/src/runloop_api_client/sdk/async_execution_result.py @@ -168,10 +168,10 @@ async def stderr(self, num_lines: Optional[int] = None) -> str: ) @property - def raw(self) -> DevboxAsyncExecutionDetailView: - """Access the underlying API response. + def result(self) -> DevboxAsyncExecutionDetailView: + """Get the raw execution result. Returns: - DevboxAsyncExecutionDetailView: Raw API payload. + DevboxAsyncExecutionDetailView: Raw execution result. """ return self._result diff --git a/src/runloop_api_client/sdk/execution_result.py b/src/runloop_api_client/sdk/execution_result.py index 5715e46cb..d7a45d1c0 100644 --- a/src/runloop_api_client/sdk/execution_result.py +++ b/src/runloop_api_client/sdk/execution_result.py @@ -167,10 +167,10 @@ def stderr(self, num_lines: Optional[int] = None) -> str: ) @property - def raw(self) -> DevboxAsyncExecutionDetailView: - """Access the underlying API response. + def result(self) -> DevboxAsyncExecutionDetailView: + """Get the raw execution result. Returns: - DevboxAsyncExecutionDetailView: Raw API payload. + DevboxAsyncExecutionDetailView: Raw execution result. """ return self._result diff --git a/tests/sdk/test_async_execution_result.py b/tests/sdk/test_async_execution_result.py index f73a1e2bb..a4df5bac9 100644 --- a/tests/sdk/test_async_execution_result.py +++ b/tests/sdk/test_async_execution_result.py @@ -152,10 +152,10 @@ async def test_stderr_empty(self, mock_async_client: AsyncMock, execution_view: result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] assert await result.stderr() == "" - def test_raw_property(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: - """Test raw property.""" + def test_result_property(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: + """Test result property.""" result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] - assert result.raw == execution_view + assert result.result == execution_view @pytest.mark.asyncio async def test_stdout_with_truncation_and_streaming( diff --git a/tests/sdk/test_execution_result.py b/tests/sdk/test_execution_result.py index 2960208ac..60d51827f 100644 --- a/tests/sdk/test_execution_result.py +++ b/tests/sdk/test_execution_result.py @@ -146,10 +146,10 @@ def test_stderr_empty(self, mock_client: Mock, execution_view: MockExecutionView result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] assert result.stderr() == "" - def test_raw_property(self, mock_client: Mock, execution_view: MockExecutionView) -> None: - """Test raw property.""" + def test_result_property(self, mock_client: Mock, execution_view: MockExecutionView) -> None: + """Test result property.""" result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] - assert result.raw == execution_view + assert result.result == execution_view def test_stdout_with_truncation_and_streaming(self, mock_client: Mock, mock_stream: Mock) -> None: """Test stdout streams full output when truncated.""" From 081edc970831d05aadc0d7b4b841bd79d3803cd3 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 18 Nov 2025 14:20:13 -0800 Subject: [PATCH 49/56] declare terminal states for devboxes.await_suspended --- src/runloop_api_client/resources/devboxes/devboxes.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/runloop_api_client/resources/devboxes/devboxes.py b/src/runloop_api_client/resources/devboxes/devboxes.py index 4da1ecc7f..9b13767af 100644 --- a/src/runloop_api_client/resources/devboxes/devboxes.py +++ b/src/runloop_api_client/resources/devboxes/devboxes.py @@ -112,6 +112,7 @@ __all__ = ["DevboxesResource", "AsyncDevboxesResource", "DevboxRequestArgs"] DEVBOX_BOOTING_STATES = frozenset(("provisioning", "initializing")) +DEVBOX_TERMINAL_STATES = frozenset(("suspended", "failure", "shutdown")) # Type for request arguments that combine polling config with additional request options @@ -449,7 +450,7 @@ def await_suspended( def wait_for_devbox_status() -> DevboxView: return self._post( f"/v1/devboxes/{id}/wait_for_status", - body={"statuses": ["suspended", "failure", "shutdown"]}, + body={"statuses": list(DEVBOX_TERMINAL_STATES)}, cast_to=DevboxView, ) @@ -461,7 +462,7 @@ def handle_timeout_error(error: Exception) -> DevboxView: raise error def is_terminal_state(devbox: DevboxView) -> bool: - return devbox.status in {"suspended", "failure", "shutdown"} + return devbox.status in DEVBOX_TERMINAL_STATES devbox = poll_until(wait_for_devbox_status, is_terminal_state, polling_config, handle_timeout_error) @@ -1996,7 +1997,7 @@ async def wait_for_devbox_status() -> DevboxView: try: return await self._post( f"/v1/devboxes/{id}/wait_for_status", - body={"statuses": ["suspended", "failure", "shutdown"]}, + body={"statuses": list(DEVBOX_TERMINAL_STATES)}, cast_to=DevboxView, ) except (APITimeoutError, APIStatusError) as error: @@ -2005,7 +2006,7 @@ async def wait_for_devbox_status() -> DevboxView: raise def is_terminal_state(devbox: DevboxView) -> bool: - return devbox.status in {"suspended", "failure", "shutdown"} + return devbox.status in DEVBOX_TERMINAL_STATES devbox = await async_poll_until(wait_for_devbox_status, is_terminal_state, polling_config) From 62394f81823e8907b519e063506c1c5b3b1cdc1a Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 18 Nov 2025 14:21:50 -0800 Subject: [PATCH 50/56] visual separation of sync vs async devbox protocols, examples for async execution interface --- src/runloop_api_client/sdk/protocols.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/runloop_api_client/sdk/protocols.py b/src/runloop_api_client/sdk/protocols.py index f0b29bda1..af31e49f4 100644 --- a/src/runloop_api_client/sdk/protocols.py +++ b/src/runloop_api_client/sdk/protocols.py @@ -26,6 +26,10 @@ from .execution_result import ExecutionResult from .async_execution_result import AsyncExecutionResult +# ============================================================================== +# Synchronous Interfaces +# ============================================================================== + @runtime_checkable class CommandInterface(Protocol): @@ -102,6 +106,11 @@ def remove_tunnel( ) -> object: ... +# ============================================================================== +# Asynchronous Interfaces +# ============================================================================== + + @runtime_checkable class AsyncCommandInterface(Protocol): """Async interface for executing commands on a devbox. @@ -112,6 +121,20 @@ class AsyncCommandInterface(Protocol): Important: All streaming callbacks (stdout, stderr, output) must be synchronous functions, not async functions. The devbox operations are async, but the callbacks themselves are called synchronously. + + Examples: + >>> # Async execution (waits for completion) + >>> result = await devbox.cmd.exec(command="ls -la") + >>> print(await result.stdout()) + + >>> # Async non-blocking execution + >>> execution = await devbox.cmd.exec_async(command="npm run dev") + >>> result = await execution.result() # Waits for completion + + >>> # Callbacks must still be synchronous! + >>> def stdout_callback(line: str) -> None: # Not async! + ... print(f">> {line}") + >>> await devbox.cmd.exec(command="tail -f /var/log/app.log", stdout=stdout_callback) """ async def exec( From 51a5c744a8162a3e12383bb2ff1d5648f7e0ab8c Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 18 Nov 2025 14:23:17 -0800 Subject: [PATCH 51/56] update maintenance instructions for DevboxCreateParams --- src/runloop_api_client/types/devbox_create_params.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/runloop_api_client/types/devbox_create_params.py b/src/runloop_api_client/types/devbox_create_params.py index 33a2488e0..2d27d6546 100644 --- a/src/runloop_api_client/types/devbox_create_params.py +++ b/src/runloop_api_client/types/devbox_create_params.py @@ -11,14 +11,15 @@ __all__ = ["DevboxCreateParams"] -# We split up the original DevboxCreateParams into two nested classes to enable us to +# We split up the original DevboxCreateParams into two nested types to enable us to # omit blueprint_id, blueprint_name, and snapshot_id when we unpack the TypedDict # params for methods like create_from_blueprint_id, create_from_blueprint_name, and # create_from_snapshot, which shouldn't allow you to specify creation source kwargs. +# These should be updated whenever DevboxCreateParams is changed in the OpenAPI spec. # DevboxBaseCreateParams should contain all the fields that are common to all the -# create methods. Any updates to the OpenAPI spec should be reflected here. +# create methods. class DevboxBaseCreateParams(TypedDict, total=False): code_mounts: Optional[Iterable[CodeMountParameters]] """A list of code mounts to be included in the Devbox.""" From 2f568e6e41e1799b9b60e46d96f5185ecdfda085 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 18 Nov 2025 14:28:58 -0800 Subject: [PATCH 52/56] change manager class suffixes from Client to Ops --- src/runloop_api_client/sdk/__init__.py | 26 ++++++------- src/runloop_api_client/sdk/async_.py | 24 ++++++------ src/runloop_api_client/sdk/sync.py | 24 ++++++------ tests/sdk/test_async_clients.py | 52 +++++++++++++------------- tests/sdk/test_clients.py | 52 +++++++++++++------------- 5 files changed, 89 insertions(+), 89 deletions(-) diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py index 175fe5aa5..e0a003693 100644 --- a/src/runloop_api_client/sdk/__init__.py +++ b/src/runloop_api_client/sdk/__init__.py @@ -5,13 +5,13 @@ from __future__ import annotations -from .sync import RunloopSDK, DevboxClient, SnapshotClient, BlueprintClient, StorageObjectClient +from .sync import RunloopSDK, DevboxOps, SnapshotOps, BlueprintOps, StorageObjectOps from .async_ import ( AsyncRunloopSDK, - AsyncDevboxClient, - AsyncSnapshotClient, - AsyncBlueprintClient, - AsyncStorageObjectClient, + AsyncDevboxOps, + AsyncSnapshotOps, + AsyncBlueprintOps, + AsyncStorageObjectOps, ) from .devbox import Devbox from .snapshot import Snapshot @@ -31,14 +31,14 @@ "RunloopSDK", "AsyncRunloopSDK", # Management interfaces - "DevboxClient", - "AsyncDevboxClient", - "BlueprintClient", - "AsyncBlueprintClient", - "SnapshotClient", - "AsyncSnapshotClient", - "StorageObjectClient", - "AsyncStorageObjectClient", + "DevboxOps", + "AsyncDevboxOps", + "BlueprintOps", + "AsyncBlueprintOps", + "SnapshotOps", + "AsyncSnapshotOps", + "StorageObjectOps", + "AsyncStorageObjectOps", # Resource classes "Devbox", "AsyncDevbox", diff --git a/src/runloop_api_client/sdk/async_.py b/src/runloop_api_client/sdk/async_.py index f7eff48cb..43c948bca 100644 --- a/src/runloop_api_client/sdk/async_.py +++ b/src/runloop_api_client/sdk/async_.py @@ -29,7 +29,7 @@ from ..types.object_create_params import ContentType -class AsyncDevboxClient: +class AsyncDevboxOps: """High-level async manager for creating and managing AsyncDevbox instances. Accessed via ``runloop.devbox`` from :class:`AsyncRunloopSDK`, provides @@ -156,7 +156,7 @@ async def list( return [AsyncDevbox(self._client, item.id) for item in page.devboxes] -class AsyncSnapshotClient: +class AsyncSnapshotOps: """High-level async manager for working with disk snapshots. Accessed via ``runloop.snapshot`` from :class:`AsyncRunloopSDK`, provides @@ -205,7 +205,7 @@ def from_id(self, snapshot_id: str) -> AsyncSnapshot: return AsyncSnapshot(self._client, snapshot_id) -class AsyncBlueprintClient: +class AsyncBlueprintOps: """High-level async manager for creating and managing blueprints. Accessed via ``runloop.blueprint`` from :class:`AsyncRunloopSDK`, provides @@ -275,7 +275,7 @@ async def list( return [AsyncBlueprint(self._client, item.id) for item in page.blueprints] -class AsyncStorageObjectClient: +class AsyncStorageObjectOps: """High-level async manager for creating and managing storage objects. Accessed via ``runloop.storage_object`` from :class:`AsyncRunloopSDK`, provides @@ -452,10 +452,10 @@ class AsyncRunloopSDK: """ api: AsyncRunloop - devbox: AsyncDevboxClient - blueprint: AsyncBlueprintClient - snapshot: AsyncSnapshotClient - storage_object: AsyncStorageObjectClient + devbox: AsyncDevboxOps + blueprint: AsyncBlueprintOps + snapshot: AsyncSnapshotOps + storage_object: AsyncStorageObjectOps def __init__( self, @@ -489,10 +489,10 @@ def __init__( http_client=http_client, ) - self.devbox = AsyncDevboxClient(self.api) - self.blueprint = AsyncBlueprintClient(self.api) - self.snapshot = AsyncSnapshotClient(self.api) - self.storage_object = AsyncStorageObjectClient(self.api) + self.devbox = AsyncDevboxOps(self.api) + self.blueprint = AsyncBlueprintOps(self.api) + self.snapshot = AsyncSnapshotOps(self.api) + self.storage_object = AsyncStorageObjectOps(self.api) async def aclose(self) -> None: """Close the underlying HTTP client and release resources.""" diff --git a/src/runloop_api_client/sdk/sync.py b/src/runloop_api_client/sdk/sync.py index 2c9d43768..d514fb6f9 100644 --- a/src/runloop_api_client/sdk/sync.py +++ b/src/runloop_api_client/sdk/sync.py @@ -29,7 +29,7 @@ from ..types.object_create_params import ContentType -class DevboxClient: +class DevboxOps: """High-level manager for creating and managing Devbox instances. Accessed via ``runloop.devbox`` from :class:`RunloopSDK`, provides methods to @@ -157,7 +157,7 @@ def list( return [Devbox(self._client, item.id) for item in page.devboxes] -class SnapshotClient: +class SnapshotOps: """High-level manager for working with disk snapshots. Accessed via ``runloop.snapshot`` from :class:`RunloopSDK`, provides methods @@ -202,7 +202,7 @@ def from_id(self, snapshot_id: str) -> Snapshot: return Snapshot(self._client, snapshot_id) -class BlueprintClient: +class BlueprintOps: """High-level manager for creating and managing blueprints. Accessed via ``runloop.blueprint`` from :class:`RunloopSDK`, provides methods @@ -271,7 +271,7 @@ def list( return [Blueprint(self._client, item.id) for item in page.blueprints] -class StorageObjectClient: +class StorageObjectOps: """High-level manager for creating and managing storage objects. Accessed via ``runloop.storage_object`` from :class:`RunloopSDK`, provides @@ -444,10 +444,10 @@ class RunloopSDK: """ api: Runloop - devbox: DevboxClient - blueprint: BlueprintClient - snapshot: SnapshotClient - storage_object: StorageObjectClient + devbox: DevboxOps + blueprint: BlueprintOps + snapshot: SnapshotOps + storage_object: StorageObjectOps def __init__( self, @@ -481,10 +481,10 @@ def __init__( http_client=http_client, ) - self.devbox = DevboxClient(self.api) - self.blueprint = BlueprintClient(self.api) - self.snapshot = SnapshotClient(self.api) - self.storage_object = StorageObjectClient(self.api) + self.devbox = DevboxOps(self.api) + self.blueprint = BlueprintOps(self.api) + self.snapshot = SnapshotOps(self.api) + self.storage_object = StorageObjectOps(self.api) def close(self) -> None: """Close the underlying HTTP client and release resources.""" diff --git a/tests/sdk/test_async_clients.py b/tests/sdk/test_async_clients.py index 882e54e7d..1655d0cdb 100644 --- a/tests/sdk/test_async_clients.py +++ b/tests/sdk/test_async_clients.py @@ -18,10 +18,10 @@ from runloop_api_client.sdk import AsyncDevbox, AsyncSnapshot, AsyncBlueprint, AsyncStorageObject from runloop_api_client.sdk.async_ import ( AsyncRunloopSDK, - AsyncDevboxClient, - AsyncSnapshotClient, - AsyncBlueprintClient, - AsyncStorageObjectClient, + AsyncDevboxOps, + AsyncSnapshotOps, + AsyncBlueprintOps, + AsyncStorageObjectOps, ) from runloop_api_client.lib.polling import PollingConfig @@ -34,7 +34,7 @@ async def test_create(self, mock_async_client: AsyncMock, devbox_view: MockDevbo """Test create method.""" mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) - client = AsyncDevboxClient(mock_async_client) + client = AsyncDevboxOps(mock_async_client) devbox = await client.create( name="test-devbox", metadata={"key": "value"}, @@ -50,7 +50,7 @@ async def test_create_from_blueprint_id(self, mock_async_client: AsyncMock, devb """Test create_from_blueprint_id method.""" mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) - client = AsyncDevboxClient(mock_async_client) + client = AsyncDevboxOps(mock_async_client) devbox = await client.create_from_blueprint_id( "bp_123", name="test-devbox", @@ -65,7 +65,7 @@ async def test_create_from_blueprint_name(self, mock_async_client: AsyncMock, de """Test create_from_blueprint_name method.""" mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) - client = AsyncDevboxClient(mock_async_client) + client = AsyncDevboxOps(mock_async_client) devbox = await client.create_from_blueprint_name( "my-blueprint", name="test-devbox", @@ -80,7 +80,7 @@ async def test_create_from_snapshot(self, mock_async_client: AsyncMock, devbox_v """Test create_from_snapshot method.""" mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) - client = AsyncDevboxClient(mock_async_client) + client = AsyncDevboxOps(mock_async_client) devbox = await client.create_from_snapshot( "snap_123", name="test-devbox", @@ -92,7 +92,7 @@ async def test_create_from_snapshot(self, mock_async_client: AsyncMock, devbox_v def test_from_id(self, mock_async_client: AsyncMock) -> None: """Test from_id method.""" - client = AsyncDevboxClient(mock_async_client) + client = AsyncDevboxOps(mock_async_client) devbox = client.from_id("dev_123") assert isinstance(devbox, AsyncDevbox) @@ -107,7 +107,7 @@ async def test_list(self, mock_async_client: AsyncMock, devbox_view: MockDevboxV page = SimpleNamespace(devboxes=[devbox_view]) mock_async_client.devboxes.list = AsyncMock(return_value=page) - client = AsyncDevboxClient(mock_async_client) + client = AsyncDevboxOps(mock_async_client) devboxes = await client.list( limit=10, status="running", @@ -129,7 +129,7 @@ async def test_list(self, mock_async_client: AsyncMock, snapshot_view: MockSnaps page = SimpleNamespace(snapshots=[snapshot_view]) mock_async_client.devboxes.disk_snapshots.list = AsyncMock(return_value=page) - client = AsyncSnapshotClient(mock_async_client) + client = AsyncSnapshotOps(mock_async_client) snapshots = await client.list( devbox_id="dev_123", limit=10, @@ -143,7 +143,7 @@ async def test_list(self, mock_async_client: AsyncMock, snapshot_view: MockSnaps def test_from_id(self, mock_async_client: AsyncMock) -> None: """Test from_id method.""" - client = AsyncSnapshotClient(mock_async_client) + client = AsyncSnapshotOps(mock_async_client) snapshot = client.from_id("snap_123") assert isinstance(snapshot, AsyncSnapshot) @@ -158,7 +158,7 @@ async def test_create(self, mock_async_client: AsyncMock, blueprint_view: MockBl """Test create method.""" mock_async_client.blueprints.create_and_await_build_complete = AsyncMock(return_value=blueprint_view) - client = AsyncBlueprintClient(mock_async_client) + client = AsyncBlueprintOps(mock_async_client) blueprint = await client.create( name="test-blueprint", polling_config=PollingConfig(timeout_seconds=60.0), @@ -170,7 +170,7 @@ async def test_create(self, mock_async_client: AsyncMock, blueprint_view: MockBl def test_from_id(self, mock_async_client: AsyncMock) -> None: """Test from_id method.""" - client = AsyncBlueprintClient(mock_async_client) + client = AsyncBlueprintOps(mock_async_client) blueprint = client.from_id("bp_123") assert isinstance(blueprint, AsyncBlueprint) @@ -182,7 +182,7 @@ async def test_list(self, mock_async_client: AsyncMock, blueprint_view: MockBlue page = SimpleNamespace(blueprints=[blueprint_view]) mock_async_client.blueprints.list = AsyncMock(return_value=page) - client = AsyncBlueprintClient(mock_async_client) + client = AsyncBlueprintOps(mock_async_client) blueprints = await client.list( limit=10, name="test", @@ -203,7 +203,7 @@ async def test_create(self, mock_async_client: AsyncMock, object_view: MockObjec """Test create method.""" mock_async_client.objects.create = AsyncMock(return_value=object_view) - client = AsyncStorageObjectClient(mock_async_client) + client = AsyncStorageObjectOps(mock_async_client) obj = await client.create(name="test.txt", content_type="text", metadata={"key": "value"}) assert isinstance(obj, AsyncStorageObject) @@ -217,7 +217,7 @@ async def test_create(self, mock_async_client: AsyncMock, object_view: MockObjec def test_from_id(self, mock_async_client: AsyncMock) -> None: """Test from_id method.""" - client = AsyncStorageObjectClient(mock_async_client) + client = AsyncStorageObjectOps(mock_async_client) obj = client.from_id("obj_123") assert isinstance(obj, AsyncStorageObject) @@ -230,7 +230,7 @@ async def test_list(self, mock_async_client: AsyncMock, object_view: MockObjectV page = SimpleNamespace(objects=[object_view]) mock_async_client.objects.list = AsyncMock(return_value=page) - client = AsyncStorageObjectClient(mock_async_client) + client = AsyncStorageObjectOps(mock_async_client) objects = await client.list( content_type="text", limit=10, @@ -261,7 +261,7 @@ async def test_upload_from_file( http_client.put = AsyncMock(return_value=mock_response) mock_async_client._client = http_client - client = AsyncStorageObjectClient(mock_async_client) + client = AsyncStorageObjectOps(mock_async_client) obj = await client.upload_from_file(temp_file, name="test.txt") assert isinstance(obj, AsyncStorageObject) @@ -281,7 +281,7 @@ async def test_upload_from_text(self, mock_async_client: AsyncMock, object_view: http_client.put = AsyncMock(return_value=mock_response) mock_async_client._client = http_client - client = AsyncStorageObjectClient(mock_async_client) + client = AsyncStorageObjectOps(mock_async_client) obj = await client.upload_from_text("test content", "test.txt", metadata={"key": "value"}) assert isinstance(obj, AsyncStorageObject) @@ -305,7 +305,7 @@ async def test_upload_from_bytes(self, mock_async_client: AsyncMock, object_view http_client.put = AsyncMock(return_value=mock_response) mock_async_client._client = http_client - client = AsyncStorageObjectClient(mock_async_client) + client = AsyncStorageObjectOps(mock_async_client) obj = await client.upload_from_bytes(b"test content", "test.bin", content_type="binary") assert isinstance(obj, AsyncStorageObject) @@ -321,7 +321,7 @@ async def test_upload_from_bytes(self, mock_async_client: AsyncMock, object_view @pytest.mark.asyncio async def test_upload_from_file_missing_path(self, mock_async_client: AsyncMock, tmp_path: Path) -> None: """upload_from_file should raise when file cannot be read.""" - client = AsyncStorageObjectClient(mock_async_client) + client = AsyncStorageObjectOps(mock_async_client) missing_file = tmp_path / "missing.txt" with pytest.raises(OSError, match="Failed to read file"): @@ -335,10 +335,10 @@ def test_init(self) -> None: """Test AsyncRunloopSDK initialization.""" sdk = AsyncRunloopSDK(bearer_token="test-token") assert sdk.api is not None - assert isinstance(sdk.devbox, AsyncDevboxClient) - assert isinstance(sdk.snapshot, AsyncSnapshotClient) - assert isinstance(sdk.blueprint, AsyncBlueprintClient) - assert isinstance(sdk.storage_object, AsyncStorageObjectClient) + assert isinstance(sdk.devbox, AsyncDevboxOps) + assert isinstance(sdk.snapshot, AsyncSnapshotOps) + assert isinstance(sdk.blueprint, AsyncBlueprintOps) + assert isinstance(sdk.storage_object, AsyncStorageObjectOps) @pytest.mark.asyncio async def test_aclose(self) -> None: diff --git a/tests/sdk/test_clients.py b/tests/sdk/test_clients.py index 5b7a34d6f..b2b362de3 100644 --- a/tests/sdk/test_clients.py +++ b/tests/sdk/test_clients.py @@ -18,10 +18,10 @@ from runloop_api_client.sdk import Devbox, Snapshot, Blueprint, StorageObject from runloop_api_client.sdk.sync import ( RunloopSDK, - DevboxClient, - SnapshotClient, - BlueprintClient, - StorageObjectClient, + DevboxOps, + SnapshotOps, + BlueprintOps, + StorageObjectOps, ) from runloop_api_client.lib.polling import PollingConfig @@ -33,7 +33,7 @@ def test_create(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: """Test create method.""" mock_client.devboxes.create_and_await_running.return_value = devbox_view - client = DevboxClient(mock_client) + client = DevboxOps(mock_client) devbox = client.create( name="test-devbox", metadata={"key": "value"}, @@ -48,7 +48,7 @@ def test_create_from_blueprint_id(self, mock_client: Mock, devbox_view: MockDevb """Test create_from_blueprint_id method.""" mock_client.devboxes.create_and_await_running.return_value = devbox_view - client = DevboxClient(mock_client) + client = DevboxOps(mock_client) devbox = client.create_from_blueprint_id( "bp_123", name="test-devbox", @@ -64,7 +64,7 @@ def test_create_from_blueprint_name(self, mock_client: Mock, devbox_view: MockDe """Test create_from_blueprint_name method.""" mock_client.devboxes.create_and_await_running.return_value = devbox_view - client = DevboxClient(mock_client) + client = DevboxOps(mock_client) devbox = client.create_from_blueprint_name( "my-blueprint", name="test-devbox", @@ -78,7 +78,7 @@ def test_create_from_snapshot(self, mock_client: Mock, devbox_view: MockDevboxVi """Test create_from_snapshot method.""" mock_client.devboxes.create_and_await_running.return_value = devbox_view - client = DevboxClient(mock_client) + client = DevboxOps(mock_client) devbox = client.create_from_snapshot( "snap_123", name="test-devbox", @@ -92,7 +92,7 @@ def test_from_id(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: """Test from_id method waits for running.""" mock_client.devboxes.await_running.return_value = devbox_view - client = DevboxClient(mock_client) + client = DevboxOps(mock_client) devbox = client.from_id("dev_123") assert isinstance(devbox, Devbox) @@ -104,7 +104,7 @@ def test_list(self, mock_client: Mock, devbox_view: MockDevboxView) -> None: page = SimpleNamespace(devboxes=[devbox_view]) mock_client.devboxes.list.return_value = page - client = DevboxClient(mock_client) + client = DevboxOps(mock_client) devboxes = client.list( limit=10, status="running", @@ -125,7 +125,7 @@ def test_list(self, mock_client: Mock, snapshot_view: MockSnapshotView) -> None: page = SimpleNamespace(snapshots=[snapshot_view]) mock_client.devboxes.disk_snapshots.list.return_value = page - client = SnapshotClient(mock_client) + client = SnapshotOps(mock_client) snapshots = client.list( devbox_id="dev_123", limit=10, @@ -139,7 +139,7 @@ def test_list(self, mock_client: Mock, snapshot_view: MockSnapshotView) -> None: def test_from_id(self, mock_client: Mock) -> None: """Test from_id method.""" - client = SnapshotClient(mock_client) + client = SnapshotOps(mock_client) snapshot = client.from_id("snap_123") assert isinstance(snapshot, Snapshot) @@ -153,7 +153,7 @@ def test_create(self, mock_client: Mock, blueprint_view: MockBlueprintView) -> N """Test create method.""" mock_client.blueprints.create_and_await_build_complete.return_value = blueprint_view - client = BlueprintClient(mock_client) + client = BlueprintOps(mock_client) blueprint = client.create( name="test-blueprint", polling_config=PollingConfig(timeout_seconds=60.0), @@ -165,7 +165,7 @@ def test_create(self, mock_client: Mock, blueprint_view: MockBlueprintView) -> N def test_from_id(self, mock_client: Mock) -> None: """Test from_id method.""" - client = BlueprintClient(mock_client) + client = BlueprintOps(mock_client) blueprint = client.from_id("bp_123") assert isinstance(blueprint, Blueprint) @@ -176,7 +176,7 @@ def test_list(self, mock_client: Mock, blueprint_view: MockBlueprintView) -> Non page = SimpleNamespace(blueprints=[blueprint_view]) mock_client.blueprints.list.return_value = page - client = BlueprintClient(mock_client) + client = BlueprintOps(mock_client) blueprints = client.list( limit=10, name="test", @@ -196,7 +196,7 @@ def test_create(self, mock_client: Mock, object_view: MockObjectView) -> None: """Test create method.""" mock_client.objects.create.return_value = object_view - client = StorageObjectClient(mock_client) + client = StorageObjectOps(mock_client) obj = client.create(name="test.txt", content_type="text", metadata={"key": "value"}) assert isinstance(obj, StorageObject) @@ -206,7 +206,7 @@ def test_create(self, mock_client: Mock, object_view: MockObjectView) -> None: def test_from_id(self, mock_client: Mock) -> None: """Test from_id method.""" - client = StorageObjectClient(mock_client) + client = StorageObjectOps(mock_client) obj = client.from_id("obj_123") assert isinstance(obj, StorageObject) @@ -218,7 +218,7 @@ def test_list(self, mock_client: Mock, object_view: MockObjectView) -> None: page = SimpleNamespace(objects=[object_view]) mock_client.objects.list.return_value = page - client = StorageObjectClient(mock_client) + client = StorageObjectOps(mock_client) objects = client.list( content_type="text", limit=10, @@ -245,7 +245,7 @@ def test_upload_from_file(self, mock_client: Mock, object_view: MockObjectView, http_client.put.return_value = mock_response mock_client._client = http_client - client = StorageObjectClient(mock_client) + client = StorageObjectOps(mock_client) obj = client.upload_from_file(temp_file, name="test.txt") assert isinstance(obj, StorageObject) @@ -263,7 +263,7 @@ def test_upload_from_text(self, mock_client: Mock, object_view: MockObjectView) http_client.put.return_value = mock_response mock_client._client = http_client - client = StorageObjectClient(mock_client) + client = StorageObjectOps(mock_client) obj = client.upload_from_text("test content", "test.txt", metadata={"key": "value"}) assert isinstance(obj, StorageObject) @@ -285,7 +285,7 @@ def test_upload_from_bytes(self, mock_client: Mock, object_view: MockObjectView) http_client.put.return_value = mock_response mock_client._client = http_client - client = StorageObjectClient(mock_client) + client = StorageObjectOps(mock_client) obj = client.upload_from_bytes(b"test content", "test.bin", content_type="binary") assert isinstance(obj, StorageObject) @@ -300,7 +300,7 @@ def test_upload_from_bytes(self, mock_client: Mock, object_view: MockObjectView) def test_upload_from_file_missing_path(self, mock_client: Mock, tmp_path: Path) -> None: """upload_from_file should raise when file cannot be read.""" - client = StorageObjectClient(mock_client) + client = StorageObjectOps(mock_client) missing_file = tmp_path / "missing.txt" with pytest.raises(OSError, match="Failed to read file"): @@ -314,10 +314,10 @@ def test_init(self) -> None: """Test RunloopSDK initialization.""" sdk = RunloopSDK(bearer_token="test-token") assert sdk.api is not None - assert isinstance(sdk.devbox, DevboxClient) - assert isinstance(sdk.snapshot, SnapshotClient) - assert isinstance(sdk.blueprint, BlueprintClient) - assert isinstance(sdk.storage_object, StorageObjectClient) + assert isinstance(sdk.devbox, DevboxOps) + assert isinstance(sdk.snapshot, SnapshotOps) + assert isinstance(sdk.blueprint, BlueprintOps) + assert isinstance(sdk.storage_object, StorageObjectOps) def test_init_with_max_retries(self) -> None: """Test RunloopSDK initialization with max_retries.""" From d24b3c568ee802864d6b23377410c2c6b9b5ea55 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 18 Nov 2025 14:56:52 -0800 Subject: [PATCH 53/56] remove unnecessary tests from blueprint and snapshot smoke tests --- tests/smoketests/sdk/test_async_blueprint.py | 39 +------ tests/smoketests/sdk/test_async_snapshot.py | 107 +------------------ tests/smoketests/sdk/test_blueprint.py | 37 +------ tests/smoketests/sdk/test_snapshot.py | 101 +---------------- 4 files changed, 4 insertions(+), 280 deletions(-) diff --git a/tests/smoketests/sdk/test_async_blueprint.py b/tests/smoketests/sdk/test_async_blueprint.py index d9c0a1241..09d85a5d5 100644 --- a/tests/smoketests/sdk/test_async_blueprint.py +++ b/tests/smoketests/sdk/test_async_blueprint.py @@ -221,8 +221,7 @@ async def test_create_devbox_from_blueprint(self, async_sdk_client: AsyncRunloop try: # Create devbox from the blueprint - devbox = await async_sdk_client.devbox.create_from_blueprint_id( - blueprint_id=blueprint.id, + devbox = await blueprint.create_devbox( name=unique_name("sdk-async-devbox-from-blueprint"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -244,42 +243,6 @@ async def test_create_devbox_from_blueprint(self, async_sdk_client: AsyncRunloop finally: await blueprint.delete() - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) - async def test_create_multiple_devboxes_from_blueprint(self, async_sdk_client: AsyncRunloopSDK) -> None: - """Test creating multiple devboxes from the same blueprint.""" - # Create a blueprint - blueprint = await async_sdk_client.blueprint.create( - name=unique_name("sdk-async-blueprint-multi-devbox"), - dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y curl", - ) - - try: - # Create first devbox - devbox1 = await async_sdk_client.devbox.create_from_blueprint_id( - blueprint_id=blueprint.id, - name=unique_name("sdk-async-devbox-1"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - # Create second devbox - devbox2 = await async_sdk_client.devbox.create_from_blueprint_id( - blueprint_id=blueprint.id, - name=unique_name("sdk-async-devbox-2"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - try: - assert devbox1.id != devbox2.id - info1 = await devbox1.get_info() - info2 = await devbox2.get_info() - assert info1.status == "running" - assert info2.status == "running" - finally: - await devbox1.shutdown() - await devbox2.shutdown() - finally: - await blueprint.delete() - class TestAsyncBlueprintErrorHandling: """Test async blueprint error handling scenarios.""" diff --git a/tests/smoketests/sdk/test_async_snapshot.py b/tests/smoketests/sdk/test_async_snapshot.py index f70529a0f..91eb8cff7 100644 --- a/tests/smoketests/sdk/test_async_snapshot.py +++ b/tests/smoketests/sdk/test_async_snapshot.py @@ -220,8 +220,7 @@ async def test_restore_devbox_from_snapshot(self, async_sdk_client: AsyncRunloop try: # Create new devbox from snapshot - restored_devbox = await async_sdk_client.devbox.create_from_snapshot( - snapshot_id=snapshot.id, + restored_devbox = await snapshot.create_devbox( name=unique_name("sdk-async-restored-devbox"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -242,60 +241,6 @@ async def test_restore_devbox_from_snapshot(self, async_sdk_client: AsyncRunloop finally: await source_devbox.shutdown() - @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) - async def test_multiple_devboxes_from_snapshot(self, async_sdk_client: AsyncRunloopSDK) -> None: - """Test creating multiple devboxes from the same snapshot.""" - # Create source devbox - source_devbox = await async_sdk_client.devbox.create( - name=unique_name("sdk-async-source-multi"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - try: - # Create content - await source_devbox.file.write(file_path="/tmp/async_shared.txt", contents="Async shared content") - - # Create snapshot - snapshot = await source_devbox.snapshot_disk( - name=unique_name("sdk-async-snapshot-multi"), - ) - - try: - # Create first devbox from snapshot - devbox1 = await async_sdk_client.devbox.create_from_snapshot( - snapshot_id=snapshot.id, - name=unique_name("sdk-async-restored-1"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - # Create second devbox from snapshot - devbox2 = await async_sdk_client.devbox.create_from_snapshot( - snapshot_id=snapshot.id, - name=unique_name("sdk-async-restored-2"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - try: - # Both should be running - assert devbox1.id != devbox2.id - info1 = await devbox1.get_info() - info2 = await devbox2.get_info() - assert info1.status == "running" - assert info2.status == "running" - - # Both should have the snapshot content - content1 = await devbox1.file.read(file_path="/tmp/async_shared.txt") - content2 = await devbox2.file.read(file_path="/tmp/async_shared.txt") - assert content1 == "Async shared content" - assert content2 == "Async shared content" - finally: - await devbox1.shutdown() - await devbox2.shutdown() - finally: - await snapshot.delete() - finally: - await source_devbox.shutdown() - class TestAsyncSnapshotListing: """Test async snapshot listing and retrieval operations.""" @@ -365,53 +310,3 @@ async def test_list_snapshots_by_devbox(self, async_sdk_client: AsyncRunloopSDK) await snapshot.delete() finally: await devbox.shutdown() - - -class TestAsyncSnapshotEdgeCases: - """Test async snapshot edge cases and special scenarios.""" - - @pytest.mark.timeout(FOUR_MINUTE_TIMEOUT) - async def test_snapshot_preserves_file_permissions(self, async_sdk_client: AsyncRunloopSDK) -> None: - """Test that snapshot preserves file permissions.""" - # Create devbox - devbox = await async_sdk_client.devbox.create( - name=unique_name("sdk-async-devbox-permissions"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - try: - # Create executable file - await devbox.file.write(file_path="/tmp/test_async_exec.sh", contents="#!/bin/bash\necho 'Hello'") - await devbox.cmd.exec(command="chmod +x /tmp/test_async_exec.sh") - - # Verify it's executable - result = await devbox.cmd.exec(command="test -x /tmp/test_async_exec.sh && echo 'executable'") - stdout = await result.stdout(num_lines=1) - assert "executable" in stdout - - # Create snapshot - snapshot = await devbox.snapshot_disk( - name=unique_name("sdk-async-snapshot-permissions"), - ) - - try: - # Restore from snapshot - restored_devbox = await async_sdk_client.devbox.create_from_snapshot( - snapshot_id=snapshot.id, - name=unique_name("sdk-async-restored-permissions"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - try: - # Verify file is still executable - result = await restored_devbox.cmd.exec( - command="test -x /tmp/test_async_exec.sh && echo 'still_executable'" - ) - stdout = await result.stdout(num_lines=1) - assert "still_executable" in stdout - finally: - await restored_devbox.shutdown() - finally: - await snapshot.delete() - finally: - await devbox.shutdown() diff --git a/tests/smoketests/sdk/test_blueprint.py b/tests/smoketests/sdk/test_blueprint.py index 5f63e8794..cf9e4683b 100644 --- a/tests/smoketests/sdk/test_blueprint.py +++ b/tests/smoketests/sdk/test_blueprint.py @@ -221,8 +221,7 @@ def test_create_devbox_from_blueprint(self, sdk_client: RunloopSDK) -> None: try: # Create devbox from the blueprint - devbox = sdk_client.devbox.create_from_blueprint_id( - blueprint_id=blueprint.id, + devbox = blueprint.create_devbox( name=unique_name("sdk-devbox-from-blueprint"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -244,40 +243,6 @@ def test_create_devbox_from_blueprint(self, sdk_client: RunloopSDK) -> None: finally: blueprint.delete() - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) - def test_create_multiple_devboxes_from_blueprint(self, sdk_client: RunloopSDK) -> None: - """Test creating multiple devboxes from the same blueprint.""" - # Create a blueprint - blueprint = sdk_client.blueprint.create( - name=unique_name("sdk-blueprint-multi-devbox"), - dockerfile="FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y curl", - ) - - try: - # Create first devbox - devbox1 = sdk_client.devbox.create_from_blueprint_id( - blueprint_id=blueprint.id, - name=unique_name("sdk-devbox-1"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - # Create second devbox - devbox2 = sdk_client.devbox.create_from_blueprint_id( - blueprint_id=blueprint.id, - name=unique_name("sdk-devbox-2"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - try: - assert devbox1.id != devbox2.id - assert devbox1.get_info().status == "running" - assert devbox2.get_info().status == "running" - finally: - devbox1.shutdown() - devbox2.shutdown() - finally: - blueprint.delete() - class TestBlueprintErrorHandling: """Test blueprint error handling scenarios.""" diff --git a/tests/smoketests/sdk/test_snapshot.py b/tests/smoketests/sdk/test_snapshot.py index 143e2105b..de3c06ea6 100644 --- a/tests/smoketests/sdk/test_snapshot.py +++ b/tests/smoketests/sdk/test_snapshot.py @@ -219,8 +219,7 @@ def test_restore_devbox_from_snapshot(self, sdk_client: RunloopSDK) -> None: try: # Create new devbox from snapshot - restored_devbox = sdk_client.devbox.create_from_snapshot( - snapshot_id=snapshot.id, + restored_devbox = snapshot.create_devbox( name=unique_name("sdk-restored-devbox"), launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, ) @@ -241,58 +240,6 @@ def test_restore_devbox_from_snapshot(self, sdk_client: RunloopSDK) -> None: finally: source_devbox.shutdown() - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) - def test_multiple_devboxes_from_snapshot(self, sdk_client: RunloopSDK) -> None: - """Test creating multiple devboxes from the same snapshot.""" - # Create source devbox - source_devbox = sdk_client.devbox.create( - name=unique_name("sdk-source-multi"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - try: - # Create content - source_devbox.file.write(file_path="/tmp/shared.txt", contents="Shared content") - - # Create snapshot - snapshot = source_devbox.snapshot_disk( - name=unique_name("sdk-snapshot-multi"), - ) - - try: - # Create first devbox from snapshot - devbox1 = sdk_client.devbox.create_from_snapshot( - snapshot_id=snapshot.id, - name=unique_name("sdk-restored-1"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - # Create second devbox from snapshot - devbox2 = sdk_client.devbox.create_from_snapshot( - snapshot_id=snapshot.id, - name=unique_name("sdk-restored-2"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - try: - # Both should be running - assert devbox1.id != devbox2.id - assert devbox1.get_info().status == "running" - assert devbox2.get_info().status == "running" - - # Both should have the snapshot content - content1 = devbox1.file.read(file_path="/tmp/shared.txt") - content2 = devbox2.file.read(file_path="/tmp/shared.txt") - assert content1 == "Shared content" - assert content2 == "Shared content" - finally: - devbox1.shutdown() - devbox2.shutdown() - finally: - snapshot.delete() - finally: - source_devbox.shutdown() - class TestSnapshotListing: """Test snapshot listing and retrieval operations.""" @@ -362,49 +309,3 @@ def test_list_snapshots_by_devbox(self, sdk_client: RunloopSDK) -> None: snapshot.delete() finally: devbox.shutdown() - - -class TestSnapshotEdgeCases: - """Test snapshot edge cases and special scenarios.""" - - @pytest.mark.timeout(TWO_MINUTE_TIMEOUT * 2) - def test_snapshot_preserves_file_permissions(self, sdk_client: RunloopSDK) -> None: - """Test that snapshot preserves file permissions.""" - # Create devbox - devbox = sdk_client.devbox.create( - name=unique_name("sdk-devbox-permissions"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - try: - # Create executable file - devbox.file.write(file_path="/tmp/test_exec.sh", contents="#!/bin/bash\necho 'Hello'") - devbox.cmd.exec(command="chmod +x /tmp/test_exec.sh") - - # Verify it's executable - result = devbox.cmd.exec(command="test -x /tmp/test_exec.sh && echo 'executable'") - assert "executable" in result.stdout(num_lines=1) - - # Create snapshot - snapshot = devbox.snapshot_disk( - name=unique_name("sdk-snapshot-permissions"), - ) - - try: - # Restore from snapshot - restored_devbox = sdk_client.devbox.create_from_snapshot( - snapshot_id=snapshot.id, - name=unique_name("sdk-restored-permissions"), - launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5}, - ) - - try: - # Verify file is still executable - result = restored_devbox.cmd.exec(command="test -x /tmp/test_exec.sh && echo 'still_executable'") - assert "still_executable" in result.stdout(num_lines=1) - finally: - restored_devbox.shutdown() - finally: - snapshot.delete() - finally: - devbox.shutdown() From e1815ffc6bd91c60af74fbfadeb6040a583b4f90 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 18 Nov 2025 15:36:57 -0800 Subject: [PATCH 54/56] lint fixes --- src/runloop_api_client/sdk/__init__.py | 4 ++-- tests/sdk/test_async_clients.py | 2 +- tests/sdk/test_clients.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py index e0a003693..5207da1e6 100644 --- a/src/runloop_api_client/sdk/__init__.py +++ b/src/runloop_api_client/sdk/__init__.py @@ -5,10 +5,10 @@ from __future__ import annotations -from .sync import RunloopSDK, DevboxOps, SnapshotOps, BlueprintOps, StorageObjectOps +from .sync import DevboxOps, RunloopSDK, SnapshotOps, BlueprintOps, StorageObjectOps from .async_ import ( - AsyncRunloopSDK, AsyncDevboxOps, + AsyncRunloopSDK, AsyncSnapshotOps, AsyncBlueprintOps, AsyncStorageObjectOps, diff --git a/tests/sdk/test_async_clients.py b/tests/sdk/test_async_clients.py index 1655d0cdb..6fa1dd9fb 100644 --- a/tests/sdk/test_async_clients.py +++ b/tests/sdk/test_async_clients.py @@ -17,8 +17,8 @@ ) from runloop_api_client.sdk import AsyncDevbox, AsyncSnapshot, AsyncBlueprint, AsyncStorageObject from runloop_api_client.sdk.async_ import ( - AsyncRunloopSDK, AsyncDevboxOps, + AsyncRunloopSDK, AsyncSnapshotOps, AsyncBlueprintOps, AsyncStorageObjectOps, diff --git a/tests/sdk/test_clients.py b/tests/sdk/test_clients.py index b2b362de3..246715008 100644 --- a/tests/sdk/test_clients.py +++ b/tests/sdk/test_clients.py @@ -17,8 +17,8 @@ ) from runloop_api_client.sdk import Devbox, Snapshot, Blueprint, StorageObject from runloop_api_client.sdk.sync import ( - RunloopSDK, DevboxOps, + RunloopSDK, SnapshotOps, BlueprintOps, StorageObjectOps, From 6495c5cd7ab28938bfdd482d28394d957603a28a Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 18 Nov 2025 15:42:49 -0800 Subject: [PATCH 55/56] increase timeout for async sdk client smoke tests --- tests/smoketests/sdk/test_async_sdk.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/smoketests/sdk/test_async_sdk.py b/tests/smoketests/sdk/test_async_sdk.py index 9e9da410b..49f7e961d 100644 --- a/tests/smoketests/sdk/test_async_sdk.py +++ b/tests/smoketests/sdk/test_async_sdk.py @@ -8,13 +8,13 @@ pytestmark = [pytest.mark.smoketest] -FIVE_SECOND_TIMEOUT = 5 +THIRTY_SECOND_TIMEOUT = 30 class TestAsyncRunloopSDKInitialization: """Test AsyncRunloopSDK client initialization and structure.""" - @pytest.mark.timeout(FIVE_SECOND_TIMEOUT) + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) async def test_sdk_instance_creation(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test that async SDK instance is created successfully with all client properties.""" assert async_sdk_client is not None @@ -23,7 +23,7 @@ async def test_sdk_instance_creation(self, async_sdk_client: AsyncRunloopSDK) -> assert async_sdk_client.snapshot is not None assert async_sdk_client.storage_object is not None - @pytest.mark.timeout(FIVE_SECOND_TIMEOUT) + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) async def test_legacy_api_access(self, async_sdk_client: AsyncRunloopSDK) -> None: """Test that legacy API client is accessible through sdk.api.""" assert async_sdk_client.api is not None From 88405dc41da43670af2781dcf0b101cfdea14a06 Mon Sep 17 00:00:00 2001 From: Siddarth Chalasani Date: Tue, 18 Nov 2025 16:04:04 -0800 Subject: [PATCH 56/56] updated sdk readme --- README-SDK.md | 188 ++++++++++++++++++++++++++++---------------------- 1 file changed, 105 insertions(+), 83 deletions(-) diff --git a/README-SDK.md b/README-SDK.md index 5362bddc2..38764e206 100644 --- a/README-SDK.md +++ b/README-SDK.md @@ -1,9 +1,29 @@ # Runloop SDK – Python Object-Oriented Client -The `RunloopSDK` builds on top of the generated REST client and provides a Pythonic, object-oriented API for managing devboxes, blueprints, snapshots, and storage objects. The SDK exposes synchronous and asynchronous variants to match your runtime requirements. - -> **Installation** -> The SDK ships with the `runloop_api_client` package—no extra dependencies are required. +The `RunloopSDK` builds on top of the underlying REST client and provides a Pythonic, object-oriented API for managing devboxes, blueprints, snapshots, and storage objects. The SDK exposes synchronous and asynchronous variants to match your runtime requirements. + +## Table of Contents + +- [Installation](#installation) +- [Quickstart (synchronous)](#quickstart-synchronous) +- [Quickstart (asynchronous)](#quickstart-asynchronous) +- [Core Concepts](#core-concepts) +- [Devbox](#devbox) +- [Blueprint](#blueprint) +- [Snapshot](#snapshot) +- [StorageObject](#storageobject) +- [Mounting Storage Objects to Devboxes](#mounting-storage-objects-to-devboxes) +- [Accessing the Underlying REST Client](#accessing-the-underlying-rest-client) +- [Error Handling](#error-handling) +- [Advanced Configuration](#advanced-configuration) +- [Async Usage](#async-usage) +- [Polling Configuration](#polling-configuration) +- [Complete API Reference](#complete-api-reference) +- [Feedback](#feedback) + +## Installation + +The SDK ships with the `runloop_api_client` package—no extra dependencies are required. ```bash pip install runloop_api_client @@ -14,29 +34,28 @@ pip install runloop_api_client ```python from runloop_api_client import RunloopSDK -sdk = RunloopSDK() +runloop = RunloopSDK() # Create a ready-to-use devbox -with sdk.devbox.create(name="my-devbox") as devbox: - result = devbox.cmd.exec("echo 'Hello from Runloop!'") +with runloop.devbox.create(name="my-devbox") as devbox: + result = devbox.cmd.exec(command="echo 'Hello from Runloop!'") print(result.stdout()) # Stream stdout in real time devbox.cmd.exec( - "ls -la", + command="ls -la", stdout=lambda line: print("stdout:", line), - output=lambda line: print("combined:", line), ) # Blueprints -blueprint = sdk.blueprint.create( +blueprint = runloop.blueprint.create( name="my-blueprint", dockerfile="FROM ubuntu:22.04\nRUN echo 'Hello' > /hello.txt\n", ) devbox = blueprint.create_devbox(name="dev-from-blueprint") # Storage objects -obj = sdk.storage_object.upload_from_text("Hello world!", name="greeting.txt") +obj = runloop.storage_object.upload_from_text("Hello world!", name="greeting.txt") print(obj.download_as_text()) ``` @@ -47,15 +66,15 @@ import asyncio from runloop_api_client import AsyncRunloopSDK async def main(): - sdk = AsyncRunloopSDK() - async with await sdk.devbox.create(name="async-devbox") as devbox: - result = await devbox.cmd.exec("pwd") + runloop = AsyncRunloopSDK() + async with await runloop.devbox.create(name="async-devbox") as devbox: + result = await devbox.cmd.exec(command="pwd") print(await result.stdout()) def capture(line: str) -> None: print(">>", line) - await devbox.cmd.exec("ls", stdout=capture) + await devbox.cmd.exec(command="ls", stdout=capture) asyncio.run(main()) ``` @@ -69,7 +88,7 @@ The main SDK class that provides access to all Runloop functionality: ```python from runloop_api_client import RunloopSDK -sdk = RunloopSDK( +runloop = RunloopSDK( bearer_token="your-api-key", # defaults to RUNLOOP_API_KEY env var # ... other options ) @@ -79,43 +98,43 @@ sdk = RunloopSDK( The SDK provides object-oriented interfaces for all major Runloop resources: -- **`sdk.devbox`** - Devbox management (create, list, execute commands, file operations) -- **`sdk.blueprint`** - Blueprint management (create, list, build blueprints) -- **`sdk.snapshot`** - Snapshot management (list disk snapshots) -- **`sdk.storage_object`** - Storage object management (upload, download, list objects) -- **`sdk.api`** - Direct access to the generated REST API client +- **`runloop.devbox`** - Devbox management (create, list, execute commands, file operations) +- **`runloop.blueprint`** - Blueprint management (create, list, build blueprints) +- **`runloop.snapshot`** - Snapshot management (list disk snapshots) +- **`runloop.storage_object`** - Storage object management (upload, download, list objects) +- **`runloop.api`** - Direct access to the underlying REST API client ### Devbox -Object-oriented interface for working with devboxes. Created via `sdk.devbox.create()`, `sdk.devbox.create_from_blueprint_id()`, `sdk.devbox.create_from_blueprint_name()`, `sdk.devbox.create_from_snapshot()`, or `sdk.devbox.from_id()`: +Object-oriented interface for working with devboxes. Created via `runloop.devbox.create()`, `runloop.devbox.create_from_blueprint_id()`, `runloop.devbox.create_from_blueprint_name()`, `runloop.devbox.create_from_snapshot()`, or `runloop.devbox.from_id()`: ```python # Create a new devbox -devbox = sdk.devbox.create(name="my-devbox") +devbox = runloop.devbox.create(name="my-devbox") # Create a devbox from a blueprint ID -devbox_from_blueprint = sdk.devbox.create_from_blueprint_id( - "bpt-123", +devbox_from_blueprint = runloop.devbox.create_from_blueprint_id( + blueprint_id="bpt_123", name="my-devbox-from-blueprint", ) # Create a devbox from a blueprint name -devbox_from_name = sdk.devbox.create_from_blueprint_name( - "my-blueprint-name", +devbox_from_name = runloop.devbox.create_from_blueprint_name( + blueprint_name="my-blueprint-name", name="my-devbox-from-blueprint", ) # Create a devbox from a snapshot -devbox_from_snapshot = sdk.devbox.create_from_snapshot( - "snp-123", +devbox_from_snapshot = runloop.devbox.create_from_snapshot( + snapshot_id="snp_123", name="my-devbox-from-snapshot", ) # Or get an existing one (waits for it to be running) -existing_devbox = sdk.devbox.from_id("dev-123") +existing_devbox = runloop.devbox.from_id(devbox_id="dbx_123") # List all devboxes -devboxes = sdk.devbox.list(limit=10) +devboxes = runloop.devbox.list(limit=10) # Get devbox information info = devbox.get_info() @@ -128,13 +147,13 @@ Execute commands synchronously or asynchronously: ```python # Synchronous command execution (waits for completion) -result = devbox.cmd.exec("ls -la") +result = devbox.cmd.exec(command="ls -la") print("Output:", result.stdout()) print("Exit code:", result.exit_code) print("Success:", result.success) # Asynchronous command execution (returns immediately) -execution = devbox.cmd.exec_async("npm run dev") +execution = devbox.cmd.exec_async(command="npm run dev") # Check execution status state = execution.get_state() @@ -154,7 +173,7 @@ The `Execution` object provides fine-grained control over asynchronous command e ```python # Start a long-running process -execution = devbox.cmd.exec_async("python train_model.py") +execution = devbox.cmd.exec_async(command="python train_model.py") # Get the execution ID print("Execution ID:", execution.execution_id) @@ -163,7 +182,7 @@ print("Devbox ID:", execution.devbox_id) # Poll for current state state = execution.get_state() print("Status:", state.status) # "running", "completed", etc. -print("Exit code:", state.exit_status) +print("Exit code:", state.exit_status) # only set when execution has completed # Wait for completion and get results result = execution.result() @@ -189,10 +208,10 @@ The `ExecutionResult` object contains the output and exit status of a completed ```python # From synchronous execution -result = devbox.cmd.exec("ls -la /tmp") +result = devbox.cmd.exec(command="ls -la /tmp") # Or from asynchronous execution -execution = devbox.cmd.exec_async("echo 'test'") +execution = devbox.cmd.exec_async(command="echo 'test'") result = execution.result() # Access execution results @@ -222,6 +241,8 @@ print("Raw result:", raw_result) #### Streaming Command Output +> **Callback requirement:** All callbacks (`stdout`, `stderr`, `output`) must be synchronous functions. Even when using `AsyncDevbox`, callbacks cannot be async. Use thread-safe queues or other coordination primitives if you need to bridge into async code. + Pass callbacks into `cmd.exec` / `cmd.exec_async` to process logs in real time: ```python @@ -229,7 +250,7 @@ def handle_output(line: str) -> None: print("LOG:", line) result = devbox.cmd.exec( - "python train.py", + command="python train.py", stdout=handle_output, stderr=lambda line: print("ERR:", line), output=lambda line: print("ANY:", line), @@ -237,8 +258,6 @@ result = devbox.cmd.exec( print("exit code:", result.exit_code) ``` -**Note on Callbacks:** All callbacks (`stdout`, `stderr`, `output`) must be synchronous functions. Even when using `AsyncDevbox`, callbacks cannot be async functions. If you need to perform async operations with the output, use thread-safe queues and process them separately. - Async example (note that the callback itself is still synchronous): ```python @@ -248,7 +267,7 @@ def capture(line: str) -> None: log_queue.put_nowait(line) await devbox.cmd.exec( - "tail -f /var/log/app.log", + command="tail -f /var/log/app.log", stdout=capture, ) ``` @@ -343,14 +362,14 @@ Devboxes support context managers for automatic cleanup: ```python # Synchronous -with sdk.devbox.create(name="temp-devbox") as devbox: - result = devbox.cmd.exec("echo 'Hello'") +with runloop.devbox.create(name="temp-devbox") as devbox: + result = devbox.cmd.exec(command="echo 'Hello'") print(result.stdout()) # devbox is automatically shutdown when exiting the context # Asynchronous -async with await sdk.devbox.create(name="temp-devbox") as devbox: - result = await devbox.cmd.exec("echo 'Hello'") +async with await runloop.devbox.create(name="temp-devbox") as devbox: + result = await devbox.cmd.exec(command="echo 'Hello'") print(await result.stdout()) # devbox is automatically shutdown when exiting the context ``` @@ -378,21 +397,21 @@ async with await sdk.devbox.create(name="temp-devbox") as devbox: ### Blueprint -Object-oriented interface for working with blueprints. Created via `sdk.blueprint.create()` or `sdk.blueprint.from_id()`: +Object-oriented interface for working with blueprints. Created via `runloop.blueprint.create()` or `runloop.blueprint.from_id()`: ```python # Create a new blueprint -blueprint = sdk.blueprint.create( +blueprint = runloop.blueprint.create( name="my-blueprint", dockerfile="FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y python3\n", system_setup_commands=["pip install numpy pandas"], ) # Or get an existing one -blueprint = sdk.blueprint.from_id("bp-123") +blueprint = runloop.blueprint.from_id(blueprint_id="bpt_123") # List all blueprints -blueprints = sdk.blueprint.list() +blueprints = runloop.blueprint.list() # Get blueprint details and build logs info = blueprint.get_info() @@ -414,17 +433,17 @@ blueprint.delete() ### Snapshot -Object-oriented interface for working with disk snapshots. Created via `sdk.snapshot.from_id()`: +Object-oriented interface for working with disk snapshots. Created via `runloop.snapshot.from_id()`: ```python # Get an existing snapshot -snapshot = sdk.snapshot.from_id("snp-123") +snapshot = runloop.snapshot.from_id(snapshot_id="snp_123") # List all snapshots -snapshots = sdk.snapshot.list() +snapshots = runloop.snapshot.list() # List snapshots for a specific devbox -devbox_snapshots = sdk.snapshot.list(devbox_id="dev-123") +devbox_snapshots = runloop.snapshot.list(devbox_id="dbx_123") # Get snapshot details and check status info = snapshot.get_info() @@ -456,11 +475,11 @@ snapshot.delete() ### StorageObject -Object-oriented interface for working with storage objects. Created via `sdk.storage_object.create()` or `sdk.storage_object.from_id()`: +Object-oriented interface for working with storage objects. Created via `runloop.storage_object.create()` or `runloop.storage_object.from_id()`: ```python # Create a new storage object -storage_object = sdk.storage_object.create( +storage_object = runloop.storage_object.create( name="my-file.txt", content_type="text", metadata={"project": "demo"}, @@ -472,20 +491,20 @@ storage_object.complete() # Upload from file from pathlib import Path -uploaded = sdk.storage_object.upload_from_file( +uploaded = runloop.storage_object.upload_from_file( Path("/path/to/file.txt"), name="my-file.txt", ) # Upload text content directly -uploaded = sdk.storage_object.upload_from_text( +uploaded = runloop.storage_object.upload_from_text( "Hello, World!", name="my-text.txt", metadata={"source": "text"}, ) # Upload from bytes -uploaded = sdk.storage_object.upload_from_bytes( +uploaded = runloop.storage_object.upload_from_bytes( b"binary content", name="my-file.bin", content_type="binary", @@ -500,7 +519,7 @@ text_content = storage_object.download_as_text() binary_content = storage_object.download_as_bytes() # List all storage objects -objects = sdk.storage_object.list() +objects = runloop.storage_object.list() # Delete when done storage_object.delete() @@ -514,10 +533,13 @@ The storage helpers manage the multi-step upload flow (create → PUT to presign from pathlib import Path # Upload local file with content-type detection -obj = sdk.storage_object.upload_from_file(Path("./report.csv")) +obj = runloop.storage_object.upload_from_file(file_path=Path("./report.csv")) # Manual control -obj = sdk.storage_object.create("data.bin", content_type="binary") +obj = runloop.storage_object.create( + name="data.bin", + content_type="binary", +) obj.upload_content(b"\xDE\xAD\xBE\xEF") obj.complete() ``` @@ -534,9 +556,9 @@ obj.complete() **Static upload methods:** -- `sdk.storage_object.upload_from_file()` - Upload from filesystem -- `sdk.storage_object.upload_from_text()` - Upload text content directly -- `sdk.storage_object.upload_from_bytes()` - Upload from bytes +- `runloop.storage_object.upload_from_file()` - Upload from filesystem +- `runloop.storage_object.upload_from_text()` - Upload text content directly +- `runloop.storage_object.upload_from_bytes()` - Upload from bytes ### Mounting Storage Objects to Devboxes @@ -544,13 +566,13 @@ You can mount storage objects to devboxes to access their contents: ```python # Create a storage object first -storage_object = sdk.storage_object.upload_from_text( +storage_object = runloop.storage_object.upload_from_text( "Hello, World!", name="my-data.txt", ) # Create a devbox and mount the storage object -devbox = sdk.devbox.create( +devbox = runloop.devbox.create( name="my-devbox", mounts=[ { @@ -562,16 +584,16 @@ devbox = sdk.devbox.create( ) # The storage object is now accessible at /home/user/data.txt in the devbox -result = devbox.cmd.exec("cat /home/user/data.txt") +result = devbox.cmd.exec(command="cat /home/user/data.txt") print(result.stdout()) # "Hello, World!" # Mount archived objects (tar, tgz, gzip) - they get extracted to a directory -archive_object = sdk.storage_object.upload_from_file( +archive_object = runloop.storage_object.upload_from_file( Path("./project.tar.gz"), name="project.tar.gz", ) -devbox_with_archive = sdk.devbox.create( +devbox_with_archive = runloop.devbox.create( name="archive-devbox", mounts=[ { @@ -583,17 +605,17 @@ devbox_with_archive = sdk.devbox.create( ) # Access extracted archive contents -result = devbox_with_archive.cmd.exec("ls -la /home/user/project/") +result = devbox_with_archive.cmd.exec(command="ls -la /home/user/project/") print(result.stdout()) ``` -## Accessing the Generated REST Client +## Accessing the Underlying REST Client -The SDK always exposes the underlying generated client through the `.api` attribute: +The SDK always exposes the underlying client through the `.api` attribute: ```python -sdk = RunloopSDK() -raw_devbox = sdk.api.devboxes.create() +runloop = RunloopSDK() +raw_devbox = runloop.api.devboxes.create() ``` This makes it straightforward to mix high-level helpers with low-level calls whenever you need advanced control. @@ -606,11 +628,11 @@ The SDK provides comprehensive error handling with typed exceptions: from runloop_api_client import RunloopSDK import runloop_api_client -sdk = RunloopSDK() +runloop = RunloopSDK() try: - devbox = sdk.devbox.create() - result = devbox.cmd.exec("invalid-command") + devbox = runloop.devbox.create(name="example-devbox") + result = devbox.cmd.exec(command="invalid-command") except runloop_api_client.APIConnectionError as e: print("The server could not be reached") print(e.__cause__) # an underlying Exception, likely raised within httpx. @@ -641,7 +663,7 @@ Error codes are as follows: import httpx from runloop_api_client import RunloopSDK, DefaultHttpxClient -sdk = RunloopSDK( +runloop = RunloopSDK( bearer_token="your-api-key", # defaults to RUNLOOP_API_KEY env var base_url="https://api.runloop.ai", # or use RUNLOOP_BASE_URL env var timeout=60.0, # 60 second timeout (default is 30) @@ -666,18 +688,18 @@ import asyncio from runloop_api_client import AsyncRunloopSDK async def main(): - sdk = AsyncRunloopSDK() + runloop = AsyncRunloopSDK() # All the same operations, but with await - async with await sdk.devbox.create(name="async-devbox") as devbox: - result = await devbox.cmd.exec("pwd") + async with await runloop.devbox.create(name="async-devbox") as devbox: + result = await devbox.cmd.exec(command="pwd") print(await result.stdout()) # Streaming (note: callbacks must be synchronous) def capture(line: str) -> None: print(">>", line) - await devbox.cmd.exec("ls", stdout=capture) + await devbox.cmd.exec(command="ls", stdout=capture) # Async file operations await devbox.file.write(path="/tmp/test.txt", contents="Hello") @@ -698,7 +720,7 @@ Many operations that wait for state changes accept a `polling_config` parameter: from runloop_api_client.lib.polling import PollingConfig # Create devbox with custom polling -devbox = sdk.devbox.create( +devbox = runloop.devbox.create( name="my-devbox", polling_config=PollingConfig( timeout_seconds=300.0, # Wait up to 5 minutes