diff --git a/.github/workflows/smoketests.yml b/.github/workflows/smoketests.yml index 0a7dc2338..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 2 -m smoketest" + PYTEST_ADDOPTS: "-n 5 -m smoketest" run: | uv run pytest -q -vv tests/smoketests diff --git a/.gitignore b/.gitignore index 898a822ad..7160b2fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,13 @@ dist codegen.log Brewfile.lock.json -.DS_Store \ No newline at end of file +.DS_Store + +# Coverage files +.coverage +.coverage.* +htmlcov/ +coverage.json +coverage-summary.json +*.cover +.pytest_cache/ \ No newline at end of file diff --git a/README-SDK.md b/README-SDK.md new file mode 100644 index 000000000..38764e206 --- /dev/null +++ b/README-SDK.md @@ -0,0 +1,749 @@ +# Runloop SDK – Python Object-Oriented Client + +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 +``` + +## Quickstart (synchronous) + +```python +from runloop_api_client import RunloopSDK + +runloop = RunloopSDK() + +# Create a ready-to-use devbox +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( + command="ls -la", + stdout=lambda line: print("stdout:", line), + ) + +# Blueprints +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 = runloop.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(): + 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(command="ls", stdout=capture) + +asyncio.run(main()) +``` + +## Core Concepts + +### RunloopSDK + +The main SDK class that provides access to all Runloop functionality: + +```python +from runloop_api_client import RunloopSDK + +runloop = RunloopSDK( + bearer_token="your-api-key", # defaults to RUNLOOP_API_KEY env var + # ... other options +) +``` + +### Available Resources + +The SDK provides object-oriented interfaces for all major Runloop resources: + +- **`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 `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 = runloop.devbox.create(name="my-devbox") + +# Create a devbox from a blueprint ID +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 = 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 = 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 = runloop.devbox.from_id(devbox_id="dbx_123") + +# List all devboxes +devboxes = runloop.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(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(command="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(command="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) # only set when execution has completed + +# 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(command="ls -la /tmp") + +# Or from asynchronous execution +execution = devbox.cmd.exec_async(command="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 + +> **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 +def handle_output(line: str) -> None: + print("LOG:", line) + +result = devbox.cmd.exec( + command="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 (note that the callback itself is still synchronous): + +```python +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( + command="tail -f /var/log/app.log", + stdout=capture, +) +``` + +#### 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 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 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 +``` + +**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 `runloop.blueprint.create()` or `runloop.blueprint.from_id()`: + +```python +# Create a new blueprint +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 = runloop.blueprint.from_id(blueprint_id="bpt_123") + +# List all blueprints +blueprints = runloop.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 `runloop.snapshot.from_id()`: + +```python +# Get an existing snapshot +snapshot = runloop.snapshot.from_id(snapshot_id="snp_123") + +# List all snapshots +snapshots = runloop.snapshot.list() + +# List snapshots for a specific devbox +devbox_snapshots = runloop.snapshot.list(devbox_id="dbx_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 `runloop.storage_object.create()` or `runloop.storage_object.from_id()`: + +```python +# Create a new storage object +storage_object = runloop.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 = runloop.storage_object.upload_from_file( + Path("/path/to/file.txt"), + name="my-file.txt", +) + +# Upload text content directly +uploaded = runloop.storage_object.upload_from_text( + "Hello, World!", + name="my-text.txt", + metadata={"source": "text"}, +) + +# Upload from bytes +uploaded = runloop.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 = runloop.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): + +```python +from pathlib import Path + +# Upload local file with content-type detection +obj = runloop.storage_object.upload_from_file(file_path=Path("./report.csv")) + +# Manual control +obj = runloop.storage_object.create( + name="data.bin", + content_type="binary", +) +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:** + +- `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 + +You can mount storage objects to devboxes to access their contents: + +```python +# Create a storage object first +storage_object = runloop.storage_object.upload_from_text( + "Hello, World!", + name="my-data.txt", +) + +# Create a devbox and mount the storage object +devbox = runloop.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(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 = runloop.storage_object.upload_from_file( + Path("./project.tar.gz"), + name="project.tar.gz", +) + +devbox_with_archive = runloop.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(command="ls -la /home/user/project/") +print(result.stdout()) +``` + +## Accessing the Underlying REST Client + +The SDK always exposes the underlying client through the `.api` attribute: + +```python +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. + +## Error Handling + +The SDK provides comprehensive error handling with typed exceptions: + +```python +from runloop_api_client import RunloopSDK +import runloop_api_client + +runloop = RunloopSDK() + +try: + 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. +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 + +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) + 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(): + runloop = AsyncRunloopSDK() + + # All the same operations, but with await + 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(command="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 = runloop.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 97ca6da88..fe6ab8302 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,27 @@ 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 +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/pyproject.toml b/pyproject.toml index cce8ca58f..f797d1a12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,8 @@ dev = [ "importlib-metadata>=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/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/resources/devboxes/devboxes.py b/src/runloop_api_client/resources/devboxes/devboxes.py index 7a3ddf741..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 @@ -426,6 +427,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": list(DEVBOX_TERMINAL_STATES)}, + 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 DEVBOX_TERMINAL_STATES + + 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 +1973,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": list(DEVBOX_TERMINAL_STATES)}, + 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 DEVBOX_TERMINAL_STATES + + 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, diff --git a/src/runloop_api_client/resources/devboxes/disk_snapshots.py b/src/runloop_api_client/resources/devboxes/disk_snapshots.py index 3575b41f0..cf6cb54e8 100644 --- a/src/runloop_api_client/resources/devboxes/disk_snapshots.py +++ b/src/runloop_api_client/resources/devboxes/disk_snapshots.py @@ -17,8 +17,11 @@ 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 @@ -239,6 +242,38 @@ def query_status( cast_to=DevboxSnapshotAsyncStatusView, ) + def await_completed( + self, + id: str, + *, + polling_config: PollingConfig | None = None, + 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.""" + + 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, 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" + raise RunloopError(f"Snapshot {id} failed: {message}") + + return status + class AsyncDiskSnapshotsResource(AsyncAPIResource): @cached_property @@ -454,6 +489,38 @@ async def query_status( cast_to=DevboxSnapshotAsyncStatusView, ) + async def await_completed( + self, + id: str, + *, + polling_config: PollingConfig | None = None, + 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.""" + + 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, 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" + raise RunloopError(f"Snapshot {id} failed: {message}") + + return status + class DiskSnapshotsResourceWithRawResponse: def __init__(self, disk_snapshots: DiskSnapshotsResource) -> None: diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py new file mode 100644 index 000000000..5207da1e6 --- /dev/null +++ b/src/runloop_api_client/sdk/__init__.py @@ -0,0 +1,55 @@ +"""Runloop SDK - Object-oriented Python interface for Runloop. + +Provides both sync (`RunloopSDK`) and async (`AsyncRunloopSDK`) interfaces. +""" + +from __future__ import annotations + +from .sync import DevboxOps, RunloopSDK, SnapshotOps, BlueprintOps, StorageObjectOps +from .async_ import ( + AsyncDevboxOps, + AsyncRunloopSDK, + AsyncSnapshotOps, + AsyncBlueprintOps, + AsyncStorageObjectOps, +) +from .devbox import Devbox +from .snapshot import Snapshot +from .blueprint import Blueprint +from .execution import Execution +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 +from .async_execution_result import AsyncExecutionResult + +__all__ = [ + # Main SDK entry points + "RunloopSDK", + "AsyncRunloopSDK", + # Management interfaces + "DevboxOps", + "AsyncDevboxOps", + "BlueprintOps", + "AsyncBlueprintOps", + "SnapshotOps", + "AsyncSnapshotOps", + "StorageObjectOps", + "AsyncStorageObjectOps", + # Resource classes + "Devbox", + "AsyncDevbox", + "Execution", + "AsyncExecution", + "ExecutionResult", + "AsyncExecutionResult", + "Blueprint", + "AsyncBlueprint", + "Snapshot", + "AsyncSnapshot", + "StorageObject", + "AsyncStorageObject", +] diff --git a/src/runloop_api_client/sdk/_helpers.py b/src/runloop_api_client/sdk/_helpers.py new file mode 100644 index 000000000..45d6682f2 --- /dev/null +++ b/src/runloop_api_client/sdk/_helpers.py @@ -0,0 +1,49 @@ +"""SDK helper types and utility functions.""" + +from __future__ import annotations + +from typing import Any, Dict, Type, Mapping, TypeVar +from pathlib import Path + +from ..types.object_create_params import ContentType + +_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") + + +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 new file mode 100644 index 000000000..43c948bca --- /dev/null +++ b/src/runloop_api_client/sdk/async_.py @@ -0,0 +1,511 @@ +"""Asynchronous SDK entry points and management interfaces.""" + +from __future__ import annotations + +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 .._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.object_create_params import ContentType + + +class AsyncDevboxOps: + """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, + ) + return AsyncDevbox(self._client, devbox_view.id) + + async def create_from_blueprint_id( + self, + 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, + ) + return AsyncDevbox(self._client, devbox_view.id) + + async def create_from_blueprint_name( + self, + 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, + ) + return AsyncDevbox(self._client, devbox_view.id) + + async def create_from_snapshot( + self, + 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, + ) + 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, + ) + return [AsyncDevbox(self._client, item.id) for item in page.devboxes] + + +class AsyncSnapshotOps: + """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 AsyncBlueprintOps: + """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, + ) + return [AsyncBlueprint(self._client, item.id) for item in page.blueprints] + + +class AsyncStorageObjectOps: + """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, + ) + return [AsyncStorageObject(self._client, item.id, upload_url=item.upload_url) for item in page.objects] + + async def upload_from_file( + self, + file_path: str | Path, + name: str | None = None, + *, + content_type: ContentType | None = None, + 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: + 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 + + async def upload_from_text( + self, + text: str, + name: str, + *, + 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() + return obj + + async def upload_from_bytes( + self, + data: bytes, + name: str, + *, + content_type: ContentType, + 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() + return obj + + +class AsyncRunloopSDK: + """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 + devbox: AsyncDevboxOps + blueprint: AsyncBlueprintOps + snapshot: AsyncSnapshotOps + storage_object: AsyncStorageObjectOps + + def __init__( + self, + *, + bearer_token: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + 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: + """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, + timeout=timeout, + max_retries=max_retries, + default_headers=default_headers, + default_query=default_query, + http_client=http_client, + ) + + 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.""" + 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 new file mode 100644 index 000000000..5d52e4904 --- /dev/null +++ b/src/runloop_api_client/sdk/async_blueprint.py @@ -0,0 +1,111 @@ +"""Blueprint resource class for asynchronous operations.""" + +from __future__ import annotations + +from typing_extensions import Unpack, override + +from ..types import BlueprintView +from ._types import RequestOptions, LongRequestOptions, SDKDevboxExtraCreateParams +from .._client import AsyncRunloop +from .async_devbox import AsyncDevbox +from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView + + +class AsyncBlueprint: + """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 + + @override + def __repr__(self) -> str: + return f"" + + @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, + ) + + 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, + ) + + 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, + ) + + 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, + ) + 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 new file mode 100644 index 000000000..1282443fb --- /dev/null +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -0,0 +1,644 @@ +"""Asynchronous devbox resource class.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence, Awaitable, cast +from typing_extensions import Unpack, override + +from ..types import ( + DevboxView, + DevboxTunnelView, + DevboxExecutionDetailView, + DevboxCreateSSHKeyResponse, +) +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 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 + +StreamFactory = Callable[[], Awaitable[AsyncStream[ExecutionUpdateChunk]]] + +if TYPE_CHECKING: + from .async_snapshot import AsyncSnapshot + + +class AsyncDevbox: + """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__) + + @override + 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: + self._logger.exception("failed to shutdown async devbox %s on context exit", self._id) + + @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, + ) + + 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, + ) + + 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, + ) + + 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, + ) + + 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), + ) + snapshot = self._snapshot_from_id(snapshot_data.id) + await snapshot.await_completed(**filter_params(params, PollingRequestOptions)) + return snapshot + + 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, + ) + 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) + + # ------------------------------------------------------------------ # + # 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, + *, + stdout: Optional[LogCallback] = None, + stderr: Optional[LogCallback] = None, + output: Optional[LogCallback] = 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: StreamFactory, + callbacks: Sequence[LogCallback], + ) -> None: + logger = self._logger + try: + stream = await stream_factory() + async with stream: + async for chunk in stream: + text = chunk.output + for callback in callbacks: + try: + callback(text) + 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: + """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 + + 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 + + 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, + devbox_id=devbox.id, + polling_config=params.get("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 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) + + 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 + + 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), + ) + + return AsyncExecution(client, devbox.id, execution, streaming_group) + + +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 + + 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, + ) + + async def write( + self, + **params: Unpack[SDKDevboxWriteFileContentsParams], + ) -> DevboxExecutionDetailView: + """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, + ) + + 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, + ) + return await response.read() + + 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, + ) + + +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 + + 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, + ) + + 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, + ) + + 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 new file mode 100644 index 000000000..b2c730b37 --- /dev/null +++ b/src/runloop_api_client/sdk/async_execution.py @@ -0,0 +1,155 @@ +"""Async execution management for async commands.""" + +from __future__ import annotations + +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 .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(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(tuple(results)) + + 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) + + +class AsyncExecution: + """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__( + 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._initial_result = execution + self._streaming_group = streaming_group + + @override + def __repr__(self) -> str: + return f"" + + @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( + self._execution_id, + devbox_id=self._devbox_id, + statuses=["completed"], + **options, + ) + ] + 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): + raise command_result + + if self._streaming_group is not None: + self._streaming_group = None + + # 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, **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, + **options, + ) + + 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, + **options, + ) 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..d93de2bbb --- /dev/null +++ b/src/runloop_api_client/sdk/async_execution_result.py @@ -0,0 +1,177 @@ +"""Async execution result wrapper for completed commands.""" + +from __future__ import annotations + +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 + + +class AsyncExecutionResult: + """Completed asynchronous command execution result. + + Provides convenient helpers to inspect process exit status and captured + output. + """ + + def __init__( + self, + client: AsyncRunloop, + devbox_id: str, + result: DevboxAsyncExecutionDetailView, + ) -> None: + self._client = client + self._devbox_id = devbox_id + self._result = result + + @override + def __repr__(self) -> str: + return f"" + + @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 + + 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 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) + + # 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: + """ + Return captured standard output, streaming full output if truncated. + + Args: + num_lines: Optional number of lines to return from the end (most recent). + + Returns: + str: Stdout content, optionally limited to the last ``num_lines`` 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 + ), + ) + + 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). + + Returns: + str: Stderr content, optionally limited to the last ``num_lines`` 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 result(self) -> DevboxAsyncExecutionDetailView: + """Get the raw execution result. + + Returns: + DevboxAsyncExecutionDetailView: Raw execution result. + """ + return self._result 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..765eddaad --- /dev/null +++ b/src/runloop_api_client/sdk/async_snapshot.py @@ -0,0 +1,134 @@ +"""Snapshot resource class for asynchronous operations.""" + +from __future__ import annotations + +from typing_extensions import Unpack, override + +from ._types import ( + RequestOptions, + LongRequestOptions, + PollingRequestOptions, + SDKDevboxExtraCreateParams, + SDKDiskSnapshotUpdateParams, +) +from .._client import AsyncRunloop +from .async_devbox import AsyncDevbox +from ..types.devbox_snapshot_view import DevboxSnapshotView +from ..types.devboxes.devbox_snapshot_async_status_view import DevboxSnapshotAsyncStatusView + + +class AsyncSnapshot: + """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 + + @override + def __repr__(self) -> str: + return f"" + + @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, + ) + + 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, + ) + + 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, + ) + + 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, + ) + + 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, + ) + 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 new file mode 100644 index 000000000..6f3df03b4 --- /dev/null +++ b/src/runloop_api_client/sdk/async_storage_object.py @@ -0,0 +1,184 @@ +"""Storage object resource class for asynchronous operations.""" + +from __future__ import annotations + +from typing_extensions import Unpack, override + +from ._types import RequestOptions, LongRequestOptions, SDKObjectDownloadParams +from .._client import AsyncRunloop +from ..types.object_view import ObjectView +from ..types.object_download_url_view import ObjectDownloadURLView + + +class AsyncStorageObject: + """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 + + @override + def __repr__(self) -> str: + return f"" + + @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, + ) + + 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, + ) + self._upload_url = None + return result + + 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, + ) + + 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, + ) + response = await self._client._client.get(url_view.download_url) + response.raise_for_status() + return response.content + + 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, + ) + response = await self._client._client.get(url_view.download_url) + response.raise_for_status() + response.encoding = "utf-8" + return response.text + + 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 new file mode 100644 index 000000000..a3e8d16f6 --- /dev/null +++ b/src/runloop_api_client/sdk/blueprint.py @@ -0,0 +1,111 @@ +"""Blueprint resource class for synchronous operations.""" + +from __future__ import annotations + +from typing_extensions import Unpack, override + +from ..types import BlueprintView +from ._types import RequestOptions, LongRequestOptions, SDKDevboxExtraCreateParams +from .devbox import Devbox +from .._client import Runloop +from ..types.blueprint_build_logs_list_view import BlueprintBuildLogsListView + + +class Blueprint: + """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 + + @override + def __repr__(self) -> str: + return f"" + + @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, + ) + + 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, + ) + + 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, + ) + + 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, + ) + 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 new file mode 100644 index 000000000..31474b6f0 --- /dev/null +++ b/src/runloop_api_client/sdk/devbox.py @@ -0,0 +1,657 @@ +"""Synchronous devbox resource class.""" + +from __future__ import annotations + +import logging +import threading +from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence +from typing_extensions import Unpack, override + +from ..types import ( + DevboxView, + DevboxTunnelView, + DevboxExecutionDetailView, + DevboxCreateSSHKeyResponse, +) +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 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 + +if TYPE_CHECKING: + from .snapshot import Snapshot + + +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). + file: File operations interface (read, write, upload, download). + net: Network operations interface (SSH keys, tunnels). + """ + + 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__) + + @override + 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: + self._logger.exception("failed to shutdown devbox %s on context exit", self._id) + + @property + def id(self) -> str: + """Return the devbox identifier. + + Returns: + str: Unique devbox ID. + """ + return self._id + + 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 self._client.devboxes.retrieve( + self._id, + **options, + ) + + 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: Devbox state info after it reaches running status. + """ + 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: Devbox state info after it reaches suspended status. + """ + return self._client.devboxes.await_suspended(self._id, polling_config=polling_config) + + def shutdown( + self, + **options: Unpack[LongRequestOptions], + ) -> DevboxView: + """Shutdown the devbox, terminating all processes and releasing resources. + + Args: + **options: Long-running request configuration (timeouts, retries, etc.). + + Returns: + DevboxView: Final devbox state info. + """ + return self._client.devboxes.shutdown( + self._id, + **options, + ) + + def suspend( + self, + **options: Unpack[LongPollingRequestOptions], + ) -> 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: + **options: Optional long-running request and polling configuration. + + Returns: + DevboxView: Suspended devbox state info. + """ + self._client.devboxes.suspend( + self._id, + **filter_params(options, LongRequestOptions), + ) + return self._client.devboxes.await_suspended(self._id, polling_config=options.get("polling_config")) + + def resume( + self, + **options: Unpack[LongPollingRequestOptions], + ) -> DevboxView: + """Resume a suspended devbox, restoring it to running state. + + Waits for the devbox to reach running state before returning. + + Args: + **options: Optional long-running request and polling configuration. + + Returns: + DevboxView: Resumed devbox state info. + """ + self._client.devboxes.resume( + self._id, + **filter_params(options, LongRequestOptions), + ) + return self._client.devboxes.await_running(self._id, polling_config=options.get("polling_config")) + + 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 self._client.devboxes.keep_alive( + self._id, + **options, + ) + + def snapshot_disk( + self, + **params: Unpack[SDKDevboxSnapshotDiskParams], + ) -> "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: + **params: Snapshot metadata, naming, and polling configuration. + + Returns: + Snapshot: Wrapper representing the completed snapshot. + """ + snapshot_data = self._client.devboxes.snapshot_disk_async( + self._id, + **filter_params(params, SDKDevboxSnapshotDiskAsyncParams), + ) + snapshot = self._snapshot_from_id(snapshot_data.id) + snapshot.await_completed(**filter_params(params, PollingRequestOptions)) + return snapshot + + def snapshot_disk_async( + self, + **params: Unpack[SDKDevboxSnapshotDiskAsyncParams], + ) -> "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: + **params: Snapshot metadata and naming options. + + Returns: + Snapshot: Wrapper representing the snapshot (may still be processing). + """ + snapshot_data = self._client.devboxes.snapshot_disk_async( + self._id, + **params, + ) + 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) + + # --------------------------------------------------------------------- # + # 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, + *, + stdout: Optional[LogCallback] = None, + 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( + 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, + ) + ) + + # 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( + 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 = 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: + """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 + + def exec( + self, + **params: Unpack[SDKDevboxExecuteParams], + ) -> ExecutionResult: + """Execute a command synchronously and wait for completion. + + Args: + **params: Command parameters, streaming callbacks, and polling config. + + Returns: + ExecutionResult: Wrapper with exit status and output helpers. + + Example: + >>> result = devbox.cmd.exec(command="ls -la") + >>> print(result.stdout()) + >>> print(f"Exit code: {result.exit_code}") + """ + devbox = self._devbox + client = devbox._client + + 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, + devbox_id=devbox.id, + polling_config=params.get("polling_config"), + ) + + if streaming_group is not None: + # Ensure log streaming has completed before returning the result. + streaming_group.join() + + return ExecutionResult(client, devbox.id, final) + + def exec_async( + self, + **params: Unpack[SDKDevboxExecuteAsyncParams], + ) -> 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: + **params: Command parameters and streaming callbacks. + Returns: + Execution: Handle for managing the running process. + + Example: + >>> execution = devbox.cmd.exec_async(command="sleep 10") + >>> state = execution.get_state() + >>> print(f"Status: {state.status}") + >>> execution.kill() # Terminate early if needed + """ + devbox = self._devbox + client = devbox._client + + 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), + ) + + return Execution(client, devbox.id, execution, streaming_group) + + +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 + + 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 = devbox.file.read("/home/user/data.txt") + >>> print(content) + """ + return self._devbox._client.devboxes.read_file_contents( + self._devbox.id, + **params, + ) + + def write( + self, + **params: Unpack[SDKDevboxWriteFileContentsParams], + ) -> DevboxExecutionDetailView: + """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: + >>> devbox.file.write(file_path="/home/user/config.json", contents='{"key": "value"}') + """ + return self._devbox._client.devboxes.write_file_contents( + self._devbox.id, + **params, + ) + + 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 = 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, + **params, + ) + return response.read() + + 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 + >>> devbox.file.upload("/home/user/data.csv", Path("local_data.csv")) + """ + return self._devbox._client.devboxes.upload_file( + self._devbox.id, + **params, + ) + + +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 + + 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 = devbox.net.create_ssh_key() + >>> print(f"SSH URL: {ssh_key.url}") + """ + return self._devbox._client.devboxes.create_ssh_key( + self._devbox.id, + **options, + ) + + 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 = devbox.net.create_tunnel(port=8080) + >>> print(f"Public URL: {tunnel.url}") + """ + return self._devbox._client.devboxes.create_tunnel( + self._devbox.id, + **params, + ) + + 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: + >>> devbox.net.remove_tunnel(port=8080) + """ + return self._devbox._client.devboxes.remove_tunnel( + self._devbox.id, + **params, + ) diff --git a/src/runloop_api_client/sdk/execution.py b/src/runloop_api_client/sdk/execution.py new file mode 100644 index 000000000..458efa30c --- /dev/null +++ b/src/runloop_api_client/sdk/execution.py @@ -0,0 +1,143 @@ +"""Execution management for async commands.""" + +from __future__ import annotations + +import logging +import threading +from typing import Optional +from typing_extensions import Unpack, override + +from ._types import RequestOptions, LongRequestOptions +from .._client import Runloop +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: + """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(command="python train.py") + >>> state = execution.get_state() + >>> if state.status == "running": + ... execution.kill() + >>> result = execution.result() # Wait for completion + >>> print(result.stdout()) + """ + + 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._initial_result = execution + self._streaming_group = streaming_group + + @override + def __repr__(self) -> str: + return f"" + + @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`. + + 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( + 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: + self._streaming_group.join() + self._streaming_group = None + + return ExecutionResult(self._client, self._devbox_id, final) + + def get_state(self, **options: Unpack[RequestOptions]) -> DevboxAsyncExecutionDetailView: + """Fetch the latest execution state. + + Args: + **options: Optional request configuration. + + Returns: + DevboxAsyncExecutionDetailView: Current execution metadata. + """ + return self._client.devboxes.executions.retrieve( + self._execution_id, + devbox_id=self._devbox_id, + **options, + ) + + def kill(self, **options: Unpack[LongRequestOptions]) -> None: + """Request termination of the running execution. + + Args: + **options: Optional long-running request configuration. + """ + self._client.devboxes.executions.kill( + self._execution_id, + devbox_id=self._devbox_id, + **options, + ) 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..d7a45d1c0 --- /dev/null +++ b/src/runloop_api_client/sdk/execution_result.py @@ -0,0 +1,176 @@ +"""Execution result wrapper for completed commands.""" + +from __future__ import annotations + +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 + + +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, + result: DevboxAsyncExecutionDetailView, + ) -> None: + self._client = client + self._devbox_id = devbox_id + self._result = result + + @override + def __repr__(self) -> str: + return f"" + + @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 + + 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 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) + + # 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, streaming full output if truncated. + + Args: + num_lines: Optional number of lines to return from the end (most recent). + + Returns: + str: Stdout content, optionally limited to the last ``num_lines`` 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 + ), + ) + + 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). + + Returns: + str: Stderr content, optionally limited to the last ``num_lines`` 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 result(self) -> DevboxAsyncExecutionDetailView: + """Get the raw execution result. + + Returns: + DevboxAsyncExecutionDetailView: Raw execution result. + """ + return self._result diff --git a/src/runloop_api_client/sdk/protocols.py b/src/runloop_api_client/sdk/protocols.py new file mode 100644 index 000000000..af31e49f4 --- /dev/null +++ b/src/runloop_api_client/sdk/protocols.py @@ -0,0 +1,201 @@ +"""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 Protocol +from typing_extensions import Unpack, runtime_checkable + +from ..types import DevboxTunnelView, DevboxExecutionDetailView, DevboxCreateSSHKeyResponse +from ._types import ( + LongRequestOptions, + SDKDevboxExecuteParams, + SDKDevboxUploadFileParams, + SDKDevboxCreateTunnelParams, + SDKDevboxDownloadFileParams, + SDKDevboxExecuteAsyncParams, + SDKDevboxRemoveTunnelParams, + SDKDevboxReadFileContentsParams, + SDKDevboxWriteFileContentsParams, +) +from .execution import Execution +from .async_execution import AsyncExecution +from .execution_result import ExecutionResult +from .async_execution_result import AsyncExecutionResult + +# ============================================================================== +# Synchronous Interfaces +# ============================================================================== + + +@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, + **params: Unpack[SDKDevboxExecuteParams], + ) -> "ExecutionResult": ... + + def exec_async( + self, + **params: Unpack[SDKDevboxExecuteAsyncParams], + ) -> "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, + **params: Unpack[SDKDevboxReadFileContentsParams], + ) -> str: ... + + def write( + self, + **params: Unpack[SDKDevboxWriteFileContentsParams], + ) -> DevboxExecutionDetailView: ... + + def download( + self, + **params: Unpack[SDKDevboxDownloadFileParams], + ) -> bytes: ... + + def upload( + self, + **params: Unpack[SDKDevboxUploadFileParams], + ) -> 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, + **params: Unpack[LongRequestOptions], + ) -> DevboxCreateSSHKeyResponse: ... + + def create_tunnel( + self, + **params: Unpack[SDKDevboxCreateTunnelParams], + ) -> DevboxTunnelView: ... + + def remove_tunnel( + self, + **params: Unpack[SDKDevboxRemoveTunnelParams], + ) -> object: ... + + +# ============================================================================== +# Asynchronous Interfaces +# ============================================================================== + + +@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. + + 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( + self, + **params: Unpack[SDKDevboxExecuteParams], + ) -> "AsyncExecutionResult": ... + + async def exec_async( + self, + **params: Unpack[SDKDevboxExecuteAsyncParams], + ) -> "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, + **params: Unpack[SDKDevboxReadFileContentsParams], + ) -> str: ... + + async def write( + self, + **params: Unpack[SDKDevboxWriteFileContentsParams], + ) -> DevboxExecutionDetailView: ... + + async def download( + self, + **params: Unpack[SDKDevboxDownloadFileParams], + ) -> bytes: ... + + async def upload( + self, + **params: Unpack[SDKDevboxUploadFileParams], + ) -> 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, + **params: Unpack[LongRequestOptions], + ) -> DevboxCreateSSHKeyResponse: ... + + async def create_tunnel( + self, + **params: Unpack[SDKDevboxCreateTunnelParams], + ) -> DevboxTunnelView: ... + + async def remove_tunnel( + self, + **params: Unpack[SDKDevboxRemoveTunnelParams], + ) -> object: ... diff --git a/src/runloop_api_client/sdk/snapshot.py b/src/runloop_api_client/sdk/snapshot.py new file mode 100644 index 000000000..0b6d0b5ea --- /dev/null +++ b/src/runloop_api_client/sdk/snapshot.py @@ -0,0 +1,134 @@ +"""Snapshot resource class for synchronous operations.""" + +from __future__ import annotations + +from typing_extensions import Unpack, override + +from ._types import ( + RequestOptions, + LongRequestOptions, + PollingRequestOptions, + SDKDevboxExtraCreateParams, + SDKDiskSnapshotUpdateParams, +) +from .devbox import Devbox +from .._client import Runloop +from ..types.devbox_snapshot_view import DevboxSnapshotView +from ..types.devboxes.devbox_snapshot_async_status_view import DevboxSnapshotAsyncStatusView + + +class Snapshot: + """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 + + @override + def __repr__(self) -> str: + return f"" + + @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, + ) + + 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, + ) + + 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, + ) + + 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, + ) + + 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, + ) + 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 new file mode 100644 index 000000000..c14991156 --- /dev/null +++ b/src/runloop_api_client/sdk/storage_object.py @@ -0,0 +1,184 @@ +"""Storage object resource class for synchronous operations.""" + +from __future__ import annotations + +from typing_extensions import Unpack, override + +from ._types import RequestOptions, LongRequestOptions, SDKObjectDownloadParams +from .._client import Runloop +from ..types.object_view import ObjectView +from ..types.object_download_url_view import ObjectDownloadURLView + + +class StorageObject: + """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 + + @override + def __repr__(self) -> str: + return f"" + + @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, + ) + + 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, + ) + self._upload_url = None + return result + + 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, + ) + + 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, + ) + response = self._client._client.get(url_view.download_url) + response.raise_for_status() + return response.content + + 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, + ) + response = self._client._client.get(url_view.download_url) + response.raise_for_status() + response.encoding = "utf-8" + return response.text + + 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 new file mode 100644 index 000000000..d514fb6f9 --- /dev/null +++ b/src/runloop_api_client/sdk/sync.py @@ -0,0 +1,503 @@ +"""Synchronous SDK entry points and management interfaces.""" + +from __future__ import annotations + +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 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 .storage_object import StorageObject +from ..types.object_create_params import ContentType + + +class DevboxOps: + """High-level manager for creating and managing Devbox instances. + + Accessed via ``runloop.devbox`` from :class:`RunloopSDK`, provides methods to + create devboxes from scratch, blueprints, or snapshots, and to list + existing devboxes. + + Example: + >>> 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, + ) + return Devbox(self._client, devbox_view.id) + + def create_from_blueprint_id( + self, + 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, + ) + return Devbox(self._client, devbox_view.id) + + def create_from_blueprint_name( + self, + 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, + ) + return Devbox(self._client, devbox_view.id) + + def create_from_snapshot( + self, + 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, + ) + 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) + + 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, + ) + return [Devbox(self._client, item.id) for item in page.devboxes] + + +class SnapshotOps: + """High-level manager for working with disk snapshots. + + Accessed via ``runloop.snapshot`` from :class:`RunloopSDK`, provides methods + to list snapshots and access snapshot details. + + Example: + >>> 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 BlueprintOps: + """High-level manager for creating and managing 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: + >>> 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, + ) + return [Blueprint(self._client, item.id) for item in page.blueprints] + + +class StorageObjectOps: + """High-level manager for creating and managing storage objects. + + 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: + >>> runloop = RunloopSDK() + >>> obj = runloop.storage_object.upload_from_text("Hello!", "greeting.txt") + >>> content = obj.download_as_text() + >>> 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, + ) + return [StorageObject(self._client, item.id, upload_url=item.upload_url) for item in page.objects] + + def upload_from_file( + self, + file_path: str | Path, + name: str | None = None, + *, + content_type: ContentType | None = None, + 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: + 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 + + def upload_from_text( + self, + text: str, + name: str, + *, + 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() + return obj + + def upload_from_bytes( + self, + data: bytes, + name: str, + *, + content_type: ContentType, + 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() + return obj + + +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: + >>> 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 + devbox: DevboxOps + blueprint: BlueprintOps + snapshot: SnapshotOps + storage_object: StorageObjectOps + + def __init__( + self, + *, + bearer_token: str | None = None, + base_url: str | httpx.URL | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + 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: + """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, + timeout=timeout, + max_retries=max_retries, + default_headers=default_headers, + default_query=default_query, + http_client=http_client, + ) + + 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.""" + 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() diff --git a/src/runloop_api_client/types/devbox_create_params.py b/src/runloop_api_client/types/devbox_create_params.py index c93dcca81..2d27d6546 100644 --- a/src/runloop_api_client/types/devbox_create_params.py +++ b/src/runloop_api_client/types/devbox_create_params.py @@ -11,23 +11,16 @@ __all__ = ["DevboxCreateParams"] +# 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. -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. - """ +# DevboxBaseCreateParams should contain all the fields that are common to all the +# create methods. +class DevboxBaseCreateParams(TypedDict, total=False): code_mounts: Optional[Iterable[CodeMountParameters]] """A list of code mounts to be included in the Devbox.""" @@ -67,6 +60,25 @@ class DevboxCreateParams(TypedDict, total=False): 'DB_PASS' to the value of secret 'DATABASE_PASSWORD'. """ + +# 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. + + 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..99cdff0e7 100644 --- a/src/runloop_api_client/types/object_create_params.py +++ b/src/runloop_api_client/types/object_create_params.py @@ -2,14 +2,19 @@ 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"] +# 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"] + + 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] diff --git a/tests/api_resources/devboxes/test_disk_snapshots.py b/tests/api_resources/devboxes/test_disk_snapshots.py index c04e4e971..d7ae59acd 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 @@ -11,6 +12,8 @@ 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, ) @@ -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/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/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..60dcf7fdc --- /dev/null +++ b/tests/sdk/async_devbox/test_core.py @@ -0,0 +1,294 @@ +"""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.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 "timeout" not in call_kwargs + + @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=devbox_view) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.suspend( + 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", + ) + + @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=devbox_view) + + devbox = AsyncDevbox(mock_async_client, "dev_123") + result = await devbox.resume( + 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", + ) + + @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() + 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: + """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() + 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: + """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() + 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.""" + 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..672638fcf --- /dev/null +++ b/tests/sdk/async_devbox/test_interfaces.py @@ -0,0 +1,216 @@ +"""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 + + +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_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(command="echo hello") + + assert result.exit_code == 0 + 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 "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: + """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(command="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(command="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(file_path="/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(file_path="/path/to/file", contents="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(file_path="/path/to/file", contents="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="/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(path="/remote/path", file=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 new file mode 100644 index 000000000..436c4de53 --- /dev/null +++ b/tests/sdk/conftest.py @@ -0,0 +1,272 @@ +"""Shared fixtures and utilities for SDK tests.""" + +from __future__ import annotations + +import asyncio +import threading +from typing import Any +from dataclasses import dataclass +from unittest.mock import Mock, AsyncMock + +import httpx +import pytest + +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 = "" + stdout_truncated: bool = False + stderr_truncated: bool = False + + +@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: + """ + 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 + + 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) + 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() -> MockDevboxView: + """Create a mock DevboxView.""" + return MockDevboxView() + + +@pytest.fixture +def execution_view() -> MockExecutionView: + """Create a mock DevboxAsyncExecutionDetailView.""" + return MockExecutionView() + + +@pytest.fixture +def snapshot_view() -> MockSnapshotView: + """Create a mock DevboxSnapshotView.""" + return MockSnapshotView() + + +@pytest.fixture +def blueprint_view() -> MockBlueprintView: + """Create a mock BlueprintView.""" + return MockBlueprintView() + + +@pytest.fixture +def object_view() -> MockObjectView: + """Create a mock ObjectView.""" + return MockObjectView() + + +@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. + + 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) + stream.__exit__ = Mock(return_value=None) + stream.close = Mock() + return stream + + +@pytest.fixture +def mock_async_stream() -> AsyncMock: + """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 + 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 + + +@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..4bebd823c --- /dev/null +++ b/tests/sdk/devbox/test_core.py @@ -0,0 +1,298 @@ +"""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 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 "timeout" not in call_kwargs + + 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 "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 "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 "timeout" not in call_kwargs2 + + 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 "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 "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 + + 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 "timeout" not in call_kwargs + + 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..ff2491f66 --- /dev/null +++ b/tests/sdk/devbox/test_edge_cases.py @@ -0,0 +1,148 @@ +"""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 + +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") + + 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.read_text()) + obj.upload_content(temp_file.read_bytes()) + + assert http_client.put.call_count == 2 diff --git a/tests/sdk/devbox/test_interfaces.py b/tests/sdk/devbox/test_interfaces.py new file mode 100644 index 000000000..f1c3bc59c --- /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 + + +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_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(command="echo hello") + + assert result.exit_code == 0 + assert result.stdout(num_lines=10) == "output" + call_kwargs = mock_client.devboxes.execute_async.call_args[1] + assert call_kwargs["command"] == "echo hello" + 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.""" + 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(command="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(command="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(command="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( + command="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(command="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(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 "timeout" not in call_kwargs + + 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(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 "timeout" not in call_kwargs + + 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(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 "timeout" not in call_kwargs + + 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="/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 "timeout" not in call_kwargs + + 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(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 "timeout" not in call_kwargs + + +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 new file mode 100644 index 000000000..8f638c18f --- /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 tests.sdk.conftest import MockDevboxView, MockBlueprintView +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: MockBlueprintView) -> 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.""" + 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 # 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: MockDevboxView) -> 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..6fa1dd9fb --- /dev/null +++ b/tests/sdk/test_async_clients.py @@ -0,0 +1,363 @@ +"""Comprehensive tests for async client classes.""" + +from __future__ import annotations + +from types import SimpleNamespace +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from tests.sdk.conftest import ( + MockDevboxView, + MockObjectView, + MockSnapshotView, + MockBlueprintView, + create_mock_httpx_response, +) +from runloop_api_client.sdk import AsyncDevbox, AsyncSnapshot, AsyncBlueprint, AsyncStorageObject +from runloop_api_client.sdk.async_ import ( + AsyncDevboxOps, + AsyncRunloopSDK, + AsyncSnapshotOps, + AsyncBlueprintOps, + AsyncStorageObjectOps, +) +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: MockDevboxView) -> None: + """Test create method.""" + mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) + + client = AsyncDevboxOps(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: MockDevboxView) -> None: + """Test create_from_blueprint_id method.""" + mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) + + client = AsyncDevboxOps(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: MockDevboxView) -> None: + """Test create_from_blueprint_name method.""" + mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) + + client = AsyncDevboxOps(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: MockDevboxView) -> None: + """Test create_from_snapshot method.""" + mock_async_client.devboxes.create_and_await_running = AsyncMock(return_value=devbox_view) + + client = AsyncDevboxOps(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 = AsyncDevboxOps(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: MockDevboxView) -> None: + """Test list method.""" + page = SimpleNamespace(devboxes=[devbox_view]) + mock_async_client.devboxes.list = AsyncMock(return_value=page) + + client = AsyncDevboxOps(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: MockSnapshotView) -> None: + """Test list method.""" + page = SimpleNamespace(snapshots=[snapshot_view]) + mock_async_client.devboxes.disk_snapshots.list = AsyncMock(return_value=page) + + client = AsyncSnapshotOps(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 = AsyncSnapshotOps(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: MockBlueprintView) -> None: + """Test create method.""" + mock_async_client.blueprints.create_and_await_build_complete = AsyncMock(return_value=blueprint_view) + + client = AsyncBlueprintOps(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 = AsyncBlueprintOps(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: MockBlueprintView) -> None: + """Test list method.""" + page = SimpleNamespace(blueprints=[blueprint_view]) + mock_async_client.blueprints.list = AsyncMock(return_value=page) + + client = AsyncBlueprintOps(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: MockObjectView) -> None: + """Test create method.""" + mock_async_client.objects.create = AsyncMock(return_value=object_view) + + client = AsyncStorageObjectOps(mock_async_client) + 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_awaited_once_with( + name="test.txt", + content_type="text", + metadata={"key": "value"}, + ) + + def test_from_id(self, mock_async_client: AsyncMock) -> None: + """Test from_id method.""" + client = AsyncStorageObjectOps(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: MockObjectView) -> None: + """Test list method.""" + page = SimpleNamespace(objects=[object_view]) + mock_async_client.objects.list = AsyncMock(return_value=page) + + client = AsyncStorageObjectOps(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_awaited_once() + + @pytest.mark.asyncio + 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) + + temp_file = tmp_path / "test_file.txt" + temp_file.write_text("test content") + + http_client = AsyncMock() + mock_response = create_mock_httpx_response() + http_client.put = AsyncMock(return_value=mock_response) + mock_async_client._client = http_client + + client = AsyncStorageObjectOps(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_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: + """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) + + http_client = AsyncMock() + mock_response = create_mock_httpx_response() + http_client.put = AsyncMock(return_value=mock_response) + mock_async_client._client = http_client + + client = AsyncStorageObjectOps(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_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: + """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) + + http_client = AsyncMock() + mock_response = create_mock_httpx_response() + http_client.put = AsyncMock(return_value=mock_response) + mock_async_client._client = http_client + + client = AsyncStorageObjectOps(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() + + @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 = AsyncStorageObjectOps(mock_async_client) + missing_file = tmp_path / "missing.txt" + + with pytest.raises(OSError, match="Failed to read file"): + await client.upload_from_file(missing_file) + + +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, 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: + """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_execution.py b/tests/sdk/test_async_execution.py new file mode 100644 index 000000000..b33b4cf1f --- /dev/null +++ b/tests/sdk/test_async_execution.py @@ -0,0 +1,267 @@ +"""Comprehensive tests for AsyncExecution class.""" + +from __future__ import annotations + +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 + +# Legacy aliases for backward compatibility +SHORT_SLEEP = TASK_COMPLETION_SHORT +LONG_SLEEP = TASK_COMPLETION_LONG + + +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: MockExecutionView) -> None: + """Test AsyncExecution initialization.""" + 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._initial_result == execution_view + + @pytest.mark.asyncio + async def test_init_with_streaming_group( + self, + mock_async_client: AsyncMock, + execution_view: MockExecutionView, + async_task_cleanup: list[asyncio.Task[Any]], + ) -> None: + """Test AsyncExecution initialization with streaming group.""" + + 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) # type: ignore[arg-type] + assert execution._streaming_group is streaming_group + + def test_properties(self, mock_async_client: AsyncMock, execution_view: MockExecutionView) -> None: + """Test AsyncExecution properties.""" + 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" + + 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(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: + """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="", + stdout_truncated=False, + stderr_truncated=False, + ) + + 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(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: + """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.wait_for_command = 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) # type: ignore[arg-type] + result = await execution.result() + + 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: + """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) # type: ignore[arg-type] + result = await execution.get_state() + + assert result == updated_execution + 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: + """Test kill method.""" + 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() + + mock_async_client.devboxes.executions.kill.assert_awaited_once_with( + "exec_123", + devbox_id="dev_123", + ) diff --git a/tests/sdk/test_async_execution_result.py b/tests/sdk/test_async_execution_result.py new file mode 100644 index 000000000..a4df5bac9 --- /dev/null +++ b/tests/sdk/test_async_execution_result.py @@ -0,0 +1,326 @@ +"""Comprehensive tests for AsyncExecutionResult class.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock, AsyncMock + +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: MockExecutionView) -> None: + """Test AsyncExecutionResult initialization.""" + 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: MockExecutionView) -> None: + """Test devbox_id property.""" + 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: MockExecutionView) -> None: + """Test execution_id property.""" + 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: MockExecutionView) -> None: + """Test exit_code property.""" + 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: + """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="", + stdout_truncated=False, + stderr_truncated=False, + ) + 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: MockExecutionView) -> None: + """Test success property.""" + 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: + """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", + stdout_truncated=False, + stderr_truncated=False, + ) + 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: MockExecutionView) -> None: + """Test failed property when exit code is zero.""" + 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: + """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", + stdout_truncated=False, + stderr_truncated=False, + ) + 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: + """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="", + stdout_truncated=False, + stderr_truncated=False, + ) + 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: MockExecutionView) -> None: + """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: + """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="", + stdout_truncated=False, + stderr_truncated=False, + ) + result = AsyncExecutionResult(mock_async_client, "dev_123", execution) # type: ignore[arg-type] + 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", + stdout_truncated=False, + stderr_truncated=False, + ) + 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: + """Test stderr method when stderr is None.""" + result = AsyncExecutionResult(mock_async_client, "dev_123", execution_view) # type: ignore[arg-type] + assert await result.stderr() == "" + + 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.result == 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_async_snapshot.py b/tests/sdk/test_async_snapshot.py new file mode 100644 index 000000000..7bca2ad95 --- /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 tests.sdk.conftest import MockDevboxView, MockSnapshotView +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: MockSnapshotView) -> 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.""" + 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 # 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: 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) + + 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: MockDevboxView) -> 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..b4623a95a --- /dev/null +++ b/tests/sdk/test_async_storage_object.py @@ -0,0 +1,212 @@ +"""Comprehensive tests for async StorageObject class.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from tests.sdk.conftest import MockObjectView, 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: MockObjectView) -> 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 + 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") + 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( + duration_seconds=3600, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == b"file content" + http_client.get.assert_awaited_once_with("https://download.example.com/obj_123") + mock_response.raise_for_status.assert_called_once() + + @pytest.mark.asyncio + 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="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" + 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: + """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 + async def test_upload_content_string(self, mock_async_client: AsyncMock) -> None: + """Test upload_content with string.""" + mock_response = create_mock_httpx_response() + 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") + + 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 + async def test_upload_content_bytes(self, mock_async_client: AsyncMock) -> None: + """Test upload_content with bytes.""" + mock_response = create_mock_httpx_response() + 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") + + 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 + 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..2c6bc6580 --- /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 tests.sdk.conftest import MockDevboxView, MockBlueprintView +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: MockBlueprintView) -> 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.""" + 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 # Verify return value is propagated + 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: MockDevboxView) -> 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..246715008 --- /dev/null +++ b/tests/sdk/test_clients.py @@ -0,0 +1,345 @@ +"""Comprehensive tests for sync client classes.""" + +from __future__ import annotations + +from types import SimpleNamespace +from pathlib import Path +from unittest.mock import Mock + +import pytest + +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 ( + DevboxOps, + RunloopSDK, + SnapshotOps, + BlueprintOps, + StorageObjectOps, +) +from runloop_api_client.lib.polling import PollingConfig + + +class TestDevboxClient: + """Tests for DevboxClient class.""" + + 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 = DevboxOps(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: MockDevboxView) -> None: + """Test create_from_blueprint_id method.""" + mock_client.devboxes.create_and_await_running.return_value = devbox_view + + client = DevboxOps(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: MockDevboxView) -> None: + """Test create_from_blueprint_name method.""" + mock_client.devboxes.create_and_await_running.return_value = devbox_view + + client = DevboxOps(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: MockDevboxView) -> None: + """Test create_from_snapshot method.""" + mock_client.devboxes.create_and_await_running.return_value = devbox_view + + client = DevboxOps(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: MockDevboxView) -> None: + """Test from_id method waits for running.""" + mock_client.devboxes.await_running.return_value = devbox_view + + client = DevboxOps(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: MockDevboxView) -> None: + """Test list method.""" + page = SimpleNamespace(devboxes=[devbox_view]) + mock_client.devboxes.list.return_value = page + + client = DevboxOps(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: MockSnapshotView) -> None: + """Test list method.""" + page = SimpleNamespace(snapshots=[snapshot_view]) + mock_client.devboxes.disk_snapshots.list.return_value = page + + client = SnapshotOps(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 = SnapshotOps(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: MockBlueprintView) -> None: + """Test create method.""" + mock_client.blueprints.create_and_await_build_complete.return_value = blueprint_view + + client = BlueprintOps(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 = BlueprintOps(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: MockBlueprintView) -> None: + """Test list method.""" + page = SimpleNamespace(blueprints=[blueprint_view]) + mock_client.blueprints.list.return_value = page + + client = BlueprintOps(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: MockObjectView) -> None: + """Test create method.""" + mock_client.objects.create.return_value = object_view + + client = StorageObjectOps(mock_client) + obj = client.create(name="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_from_id(self, mock_client: Mock) -> None: + """Test from_id method.""" + client = StorageObjectOps(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: MockObjectView) -> None: + """Test list method.""" + page = SimpleNamespace(objects=[object_view]) + mock_client.objects.list.return_value = page + + client = StorageObjectOps(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: MockObjectView, tmp_path: Path) -> None: + """Test upload_from_file method.""" + mock_client.objects.create.return_value = object_view + + temp_file = tmp_path / "test_file.txt" + temp_file.write_text("test content") + + http_client = Mock() + mock_response = create_mock_httpx_response() + http_client.put.return_value = mock_response + mock_client._client = http_client + + client = StorageObjectOps(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() + 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 + + http_client = Mock() + mock_response = create_mock_httpx_response() + http_client.put.return_value = mock_response + mock_client._client = http_client + + client = StorageObjectOps(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_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 + + http_client = Mock() + mock_response = create_mock_httpx_response() + http_client.put.return_value = mock_response + mock_client._client = http_client + + client = StorageObjectOps(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 = StorageObjectOps(mock_client) + missing_file = tmp_path / "missing.txt" + + with pytest.raises(OSError, match="Failed to read file"): + client.upload_from_file(missing_file) + + +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, 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.""" + 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_execution.py b/tests/sdk/test_execution.py new file mode 100644 index 000000000..fa2aaca2f --- /dev/null +++ b/tests/sdk/test_execution.py @@ -0,0 +1,244 @@ +"""Comprehensive tests for Execution class.""" + +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, + TASK_COMPLETION_SHORT, + MockExecutionView, +) +from runloop_api_client.sdk.execution import Execution, _StreamingGroup + +# Legacy aliases for backward compatibility during transition +SHORT_SLEEP = THREAD_STARTUP_DELAY +MEDIUM_SLEEP = TASK_COMPLETION_SHORT * 10 # 0.2 + + +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: MockExecutionView) -> None: + """Test Execution initialization.""" + 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._initial_result == execution_view + + 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) # type: ignore[arg-type] + assert execution._streaming_group is streaming_group + + def test_properties(self, mock_client: Mock, execution_view: MockExecutionView) -> None: + """Test Execution properties.""" + 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_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 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(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 to poll for completion.""" + 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="", + stdout_truncated=False, + stderr_truncated=False, + ) + + 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(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_with_streaming_group(self, mock_client: Mock) -> None: + """Test result waits for streaming group to finish.""" + 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 = Mock() + mock_client.devboxes.wait_for_command.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) # type: ignore[arg-type] + result = execution.result() + + 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.""" + updated_execution = SimpleNamespace( + execution_id="exec_123", + 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._initial_result == execution_view + mock_client.devboxes.executions.retrieve.assert_called_once_with( + "exec_123", + devbox_id="dev_123", + ) + + 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) # type: ignore[arg-type] + execution.kill() + + mock_client.devboxes.executions.kill.assert_called_once_with( + "exec_123", + devbox_id="dev_123", + ) diff --git a/tests/sdk/test_execution_result.py b/tests/sdk/test_execution_result.py new file mode 100644 index 000000000..60d51827f --- /dev/null +++ b/tests/sdk/test_execution_result.py @@ -0,0 +1,299 @@ +"""Comprehensive tests for ExecutionResult class.""" + +from __future__ import annotations + +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: MockExecutionView) -> None: + """Test ExecutionResult initialization.""" + 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: MockExecutionView) -> None: + """Test devbox_id property.""" + 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: MockExecutionView) -> None: + """Test execution_id property.""" + 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: MockExecutionView) -> None: + """Test exit_code property.""" + 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: + """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="", + stdout_truncated=False, + stderr_truncated=False, + ) + 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: MockExecutionView) -> None: + """Test success property.""" + 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: + """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", + stdout_truncated=False, + stderr_truncated=False, + ) + 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: MockExecutionView) -> None: + """Test failed property when exit code is zero.""" + 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: + """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", + stdout_truncated=False, + stderr_truncated=False, + ) + 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: + """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="", + stdout_truncated=False, + stderr_truncated=False, + ) + 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: MockExecutionView) -> None: + """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.""" + execution = SimpleNamespace( + execution_id="exec_123", + devbox_id="dev_123", + status="completed", + 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() == "" + + 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", + stdout_truncated=False, + stderr_truncated=False, + ) + 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.""" + result = ExecutionResult(mock_client, "dev_123", execution_view) # type: ignore[arg-type] + assert result.stderr() == "" + + 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.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 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_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_snapshot.py b/tests/sdk/test_snapshot.py new file mode 100644 index 000000000..383e812cc --- /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 tests.sdk.conftest import MockDevboxView, MockSnapshotView +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: MockSnapshotView) -> 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.""" + 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 # Verify return value is propagated + 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: MockSnapshotView) -> 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: MockDevboxView) -> 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..ed2a90477 --- /dev/null +++ b/tests/sdk/test_storage_object.py @@ -0,0 +1,278 @@ +"""Comprehensive tests for sync StorageObject class.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from tests.sdk.conftest import MockObjectView, create_mock_httpx_response +from runloop_api_client.sdk import StorageObject + + +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: MockObjectView) -> 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, + ) + + 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") + 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( + duration_seconds=3600, + extra_headers={"X-Custom": "value"}, + extra_query={"param": "value"}, + extra_body={"key": "value"}, + timeout=30.0, + ) + + assert result == b"file content" + http_client.get.assert_called_once_with("https://download.example.com/obj_123") + mock_response.raise_for_status.assert_called_once() + + 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="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" + 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.""" + 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", + ) + + def test_upload_content_string(self, mock_client: Mock) -> None: + """Test upload_content with string.""" + mock_response = create_mock_httpx_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") + + http_client.put.assert_called_once_with("https://upload.example.com", content="test content") + mock_response.raise_for_status.assert_called_once() + + def test_upload_content_bytes(self, mock_client: Mock) -> None: + """Test upload_content with bytes.""" + mock_response = create_mock_httpx_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") + + 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: + """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 + + 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) + + http_client.put.assert_called_once_with("https://upload.example.com", content=large_content) + + +class TestStorageObjectPythonSpecific: + """Tests for Python-specific StorageObject behavior.""" + + def test_upload_data_types(self, mock_client: Mock) -> None: + """Test Python supports more upload data types.""" + 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") + + # String + obj.upload_content("string content") + + # Bytes + obj.upload_content(b"bytes content") + + assert http_client.put.call_count == 2 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..09d85a5d5 --- /dev/null +++ b/tests/smoketests/sdk/test_async_blueprint.py @@ -0,0 +1,266 @@ +"""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 blueprint.create_devbox( + 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(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: + 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..892aa1e9c --- /dev/null +++ b/tests/smoketests/sdk/test_async_devbox.py @@ -0,0 +1,684 @@ +"""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 +FOUR_MINUTE_TIMEOUT = 240 + + +@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(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(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(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(command="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(num_lines=2) + 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( + 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) + 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( + 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.""" + output_lines: list[str] = [] + + def output_callback(line: str) -> None: + output_lines.append(line) + + result = await shared_devbox.cmd.exec( + 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) + 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( + command='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 + + 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) + 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=file_path, contents=content) + + # Read file + read_content = await shared_devbox.file.read(file_path=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=file_path, contents=content.decode("utf-8")) + + # Read and verify + read_content = await shared_devbox.file.read(file_path=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=file_path, contents=content) + + # Download file + downloaded = await shared_devbox.file.download(path=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(path=remote_path, file=Path(tmp_path)) + + # Verify by reading + content = await shared_devbox.file.read(file_path=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() + 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 + info = await devbox.get_info() + assert info.status == "suspended" + + # 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 + 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=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=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( + file_path="/tmp/test_async_snapshot.txt", contents="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=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(file_path="/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(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( + 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(file_path="/tmp/async_snapshot_test.txt", contents="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() + + +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_async_sdk.py b/tests/smoketests/sdk/test_async_sdk.py new file mode 100644 index 000000000..49f7e961d --- /dev/null +++ b/tests/smoketests/sdk/test_async_sdk.py @@ -0,0 +1,32 @@ +"""Asynchronous SDK smoke tests for AsyncRunloopSDK initialization.""" + +from __future__ import annotations + +import pytest + +from runloop_api_client.sdk import AsyncRunloopSDK + +pytestmark = [pytest.mark.smoketest] + +THIRTY_SECOND_TIMEOUT = 30 + + +class TestAsyncRunloopSDKInitialization: + """Test AsyncRunloopSDK client initialization and structure.""" + + @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 + 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(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 + 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..91eb8cff7 --- /dev/null +++ b/tests/smoketests/sdk/test_async_snapshot.py @@ -0,0 +1,312 @@ +"""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] + +TWO_MINUTE_TIMEOUT = 120 +FOUR_MINUTE_TIMEOUT = 240 + + +class TestAsyncSnapshotLifecycle: + """Test basic async snapshot lifecycle operations.""" + + @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 + 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( + file_path="/tmp/async_snapshot_marker.txt", contents="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(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( + 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(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( + 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(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( + 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 == "deleted" + finally: + await devbox.shutdown() + + +class TestAsyncSnapshotCompletion: + """Test async snapshot completion and status tracking.""" + + @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( + 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(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( + 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(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 + 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(file_path="/tmp/test_async_restore.txt", contents=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 snapshot.create_devbox( + 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(file_path="/tmp/test_async_restore.txt") + assert restored_content == test_content + finally: + await restored_devbox.shutdown() + finally: + await snapshot.delete() + finally: + await source_devbox.shutdown() + + +class TestAsyncSnapshotListing: + """Test async snapshot listing and retrieval operations.""" + + @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) + + assert isinstance(snapshots, list) + # List might be empty, that's okay + assert len(snapshots) >= 0 + + @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 + 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(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 + 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() 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..b8d7b546b --- /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(duration_seconds=120) + 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(duration_seconds=120) + 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(duration_seconds=150) + 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(duration_seconds=90) + 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(duration_seconds=120) + 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(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(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() + 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(duration_seconds=120) + 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(duration_seconds=120) + 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(duration_seconds=60) + 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(duration_seconds=120) + 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(file_path="/home/user/async-workflow-data") + assert content == "Async initial content" + + # Verify we can work with the file + 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() + 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..cf9e4683b --- /dev/null +++ b/tests/smoketests/sdk/test_blueprint.py @@ -0,0 +1,266 @@ +"""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 = blueprint.create_devbox( + 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(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: + 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..69e605d79 --- /dev/null +++ b/tests/smoketests/sdk/test_devbox.py @@ -0,0 +1,679 @@ +"""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 +FOUR_MINUTE_TIMEOUT = 240 + + +@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(command="echo 'Hello from SDK!'") + + assert result is not None + assert result.exit_code == 0 + assert result.success is True + + 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(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(command="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(num_lines=2) + 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( + 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) + 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( + 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.""" + output_lines: list[str] = [] + + def output_callback(line: str) -> None: + output_lines.append(line) + + result = shared_devbox.cmd.exec( + 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) + 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( + command='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 + + 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) + 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=file_path, contents=content) + + # Read file + read_content = shared_devbox.file.read(file_path=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=file_path, contents=content.decode("utf-8")) + + # Read and verify + read_content = shared_devbox.file.read(file_path=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=file_path, contents=content) + + # Download file + downloaded = shared_devbox.file.download(path=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(path=remote_path, file=Path(tmp_path)) + + # Verify by reading + content = shared_devbox.file.read(file_path=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( + polling_config=PollingConfig(timeout_seconds=120.0, interval_seconds=5.0), + ) + assert suspended_info.status == "suspended" + + # Verify suspended state + info = devbox.get_info() + assert info.status == "suspended" + + # Resume the devbox + resumed_info = devbox.resume( + polling_config=PollingConfig(timeout_seconds=120.0, interval_seconds=5.0), + ) + 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=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=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(file_path="/tmp/test_snapshot.txt", contents="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=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(file_path="/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(FOUR_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(file_path="/tmp/snapshot_test.txt", contents="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() + + +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. diff --git a/tests/smoketests/sdk/test_sdk.py b/tests/smoketests/sdk/test_sdk.py new file mode 100644 index 000000000..b55a98112 --- /dev/null +++ b/tests/smoketests/sdk/test_sdk.py @@ -0,0 +1,32 @@ +"""Synchronous SDK smoke tests for RunloopSDK initialization.""" + +from __future__ import annotations + +import pytest + +from runloop_api_client.sdk import RunloopSDK + +pytestmark = [pytest.mark.smoketest] + +THIRTY_SECOND_TIMEOUT = 30 + + +class TestRunloopSDKInitialization: + """Test RunloopSDK client initialization and structure.""" + + @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 + 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(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 + 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..de3c06ea6 --- /dev/null +++ b/tests/smoketests/sdk/test_snapshot.py @@ -0,0 +1,311 @@ +"""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(file_path="/tmp/snapshot_marker.txt", contents="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(TWO_MINUTE_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 == "deleted" + 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(file_path="/tmp/test_restore.txt", contents=test_content) + + # Create snapshot + snapshot = source_devbox.snapshot_disk( + name=unique_name("sdk-snapshot-restore"), + ) + + try: + # Create new devbox from snapshot + restored_devbox = snapshot.create_devbox( + 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(file_path="/tmp/test_restore.txt") + assert restored_content == test_content + finally: + restored_devbox.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() diff --git a/tests/smoketests/sdk/test_storage_object.py b/tests/smoketests/sdk/test_storage_object.py new file mode 100644 index 000000000..cb0ce557e --- /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(duration_seconds=120) + 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(duration_seconds=120) + 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(duration_seconds=150) + 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(duration_seconds=120) + 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(duration_seconds=120) + 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(file_path="/home/user/mounted-file") + assert content == "Content to mount and access" + + # Verify file exists via command + 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: + 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(duration_seconds=120) + 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(duration_seconds=120) + 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(duration_seconds=90) + 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(duration_seconds=120) + 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(file_path="/home/user/workflow-data") + assert content == "Initial content" + + # Verify we can work with the file + result = devbox.cmd.exec(command="cat /home/user/workflow-data") + assert "Initial content" in result.stdout(num_lines=1) + finally: + devbox.shutdown() + finally: + obj.delete() diff --git a/tests/smoketests/test_devboxes.py b/tests/smoketests/test_devboxes.py index b26207a41..d7bbba3c3 100644 --- a/tests/smoketests/test_devboxes.py +++ b/tests/smoketests/test_devboxes.py @@ -86,3 +86,28 @@ 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) diff --git a/uv.lock b/uv.lock index 2e16ed412..f1cb1d0ea 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" @@ -993,7 +1238,7 @@ wheels = [ [[package]] name = "runloop-api-client" -version = "0.66.1" +version = "0.67.0" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -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" },