From bceb953c8486bec6bbe5bca1276ed54d625eda5d Mon Sep 17 00:00:00 2001 From: james-rl Date: Mon, 2 Mar 2026 15:32:53 -0800 Subject: [PATCH 1/8] feat(documentation): added llms.txt, examples and referenced these artifacts via README (#748) --- .github/workflows/ci.yml | 3 + .github/workflows/smoketests.yml | 4 + EXAMPLES.md | 72 ++++++ README.md | 17 ++ examples/.keep | 4 - examples/__init__.py | 1 + examples/_harness.py | 172 +++++++++++++ examples/devbox_from_blueprint_lifecycle.py | 82 ++++++ examples/example_types.py | 64 +++++ examples/mcp_github_tools.py | 184 ++++++++++++++ examples/registry.py | 39 +++ llms.txt | 57 +++++ pyproject.toml | 1 + scripts/generate_examples_md.py | 263 ++++++++++++++++++++ tests/smoketests/examples/__init__.py | 1 + tests/smoketests/examples/test_examples.py | 107 ++++++++ uv.lock | 89 ++++++- 17 files changed, 1155 insertions(+), 5 deletions(-) create mode 100644 EXAMPLES.md delete mode 100644 examples/.keep create mode 100644 examples/__init__.py create mode 100644 examples/_harness.py create mode 100644 examples/devbox_from_blueprint_lifecycle.py create mode 100644 examples/example_types.py create mode 100644 examples/mcp_github_tools.py create mode 100644 examples/registry.py create mode 100644 llms.txt create mode 100644 scripts/generate_examples_md.py create mode 100644 tests/smoketests/examples/__init__.py create mode 100644 tests/smoketests/examples/test_examples.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c652335f..0e5b32a4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - name: Run lints run: ./scripts/lint + - name: Verify generated examples docs + run: uv run python scripts/generate_examples_md.py --check + build: if: github.event_name == 'push' || github.event.pull_request.head.repo.fork timeout-minutes: 10 diff --git a/.github/workflows/smoketests.yml b/.github/workflows/smoketests.yml index ba29508b5..f6846f473 100644 --- a/.github/workflows/smoketests.yml +++ b/.github/workflows/smoketests.yml @@ -45,6 +45,10 @@ jobs: fi echo "DEBUG=false" >> $GITHUB_ENV echo "PYTHONPATH=${{ github.workspace }}/src" >> $GITHUB_ENV + echo "RUN_EXAMPLE_LIVE_TESTS=1" >> $GITHUB_ENV + + - name: Verify generated example artifacts + run: uv run python scripts/generate_examples_md.py --check - name: Run smoke tests (pytest via uv) env: diff --git a/EXAMPLES.md b/EXAMPLES.md new file mode 100644 index 000000000..9377b08bb --- /dev/null +++ b/EXAMPLES.md @@ -0,0 +1,72 @@ +# Examples + +> This file is auto-generated from metadata in `examples/*.py`. +> Do not edit this file manually. Run `uv run python scripts/generate_examples_md.py` instead. + +Runnable examples live in [`examples/`](./examples). + +## Table of Contents + +- [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle) +- [MCP Hub + Claude Code + GitHub](#mcp-github-tools) + + +## Devbox From Blueprint (Run Command, Shutdown) + +**Use case:** Create a devbox from a blueprint, run a command, validate output, and cleanly tear everything down. + +**Tags:** `devbox`, `blueprint`, `commands`, `cleanup` + +### Workflow +- Create a blueprint +- Create a devbox from the blueprint +- Execute a command in the devbox +- Validate exit code and stdout +- Shutdown devbox and delete blueprint + +### Prerequisites +- `RUNLOOP_API_KEY` + +### Run +```sh +uv run python -m examples.devbox_from_blueprint_lifecycle +``` + +### Test +```sh +uv run pytest -m smoketest tests/smoketests/examples/ +``` + +**Source:** [`examples/devbox_from_blueprint_lifecycle.py`](./examples/devbox_from_blueprint_lifecycle.py) + + +## MCP Hub + Claude Code + GitHub + +**Use case:** Connect Claude Code running in a devbox to GitHub tools through MCP Hub without exposing raw GitHub credentials to the devbox. + +**Tags:** `mcp`, `devbox`, `github`, `commands`, `cleanup` + +### Workflow +- Create an MCP config for GitHub +- Store GitHub token as a Runloop secret +- Launch a devbox with MCP Hub wiring +- Install Claude Code and register MCP endpoint +- Run a Claude prompt through MCP tools +- Shutdown devbox and clean up cloud resources + +### Prerequisites +- `RUNLOOP_API_KEY` +- `GITHUB_TOKEN (GitHub PAT with repo scope)` +- `ANTHROPIC_API_KEY` + +### Run +```sh +GITHUB_TOKEN=ghp_xxx ANTHROPIC_API_KEY=sk-ant-xxx uv run python -m examples.mcp_github_tools +``` + +### Test +```sh +uv run pytest -m smoketest tests/smoketests/examples/ +``` + +**Source:** [`examples/mcp_github_tools.py`](./examples/mcp_github_tools.py) diff --git a/README.md b/README.md index 14e808a10..1907bf12b 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,23 @@ asyncio.run(main()) Functionality between the synchronous and asynchronous clients is otherwise identical. +## Examples + +Workflow-oriented runnable examples are documented in [`EXAMPLES.md`](./EXAMPLES.md). + +`EXAMPLES.md` is generated from metadata in `examples/*.py` and should not be edited manually. +Regenerate it with: + +```sh +uv run python scripts/generate_examples_md.py +``` + +## Agent Guidance + +Detailed agent-specific instructions for developing using this package live in [`llms.txt`](./llms.txt). Consolidated recipes for frequent tasks are in [`EXAMPLES.md`](./EXAMPLES.md). + +After completing any modifications to this project, ensure `llms.txt` and `README.md` are kept in sync. + ### With aiohttp By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. diff --git a/examples/.keep b/examples/.keep deleted file mode 100644 index d8c73e937..000000000 --- a/examples/.keep +++ /dev/null @@ -1,4 +0,0 @@ -File generated from our OpenAPI spec by Stainless. - -This directory can be used to store example files demonstrating usage of this SDK. -It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. \ No newline at end of file diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 000000000..9f6967a61 --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +# Examples package for Runloop Python SDK diff --git a/examples/_harness.py b/examples/_harness.py new file mode 100644 index 000000000..dec6dfdf4 --- /dev/null +++ b/examples/_harness.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import sys +import json +import time +import asyncio +from typing import Any, TypeVar, Callable, Awaitable +from dataclasses import asdict + +from .example_types import ( + ExampleCheck, + RecipeOutput, + ExampleResult, + RecipeContext, + ExampleCleanupStatus, + ExampleCleanupFailure, +) + +T = TypeVar("T") + + +def unique_name(prefix: str) -> str: + """Generate a unique name with timestamp and random suffix.""" + return f"{prefix}-{int(time.time())}-{hex(int(time.time() * 1000) % 0xFFFFFF)[2:]}" + + +class _CleanupTracker: + """Tracks cleanup actions and executes them in LIFO order.""" + + def __init__(self, status: ExampleCleanupStatus) -> None: + self._status = status + self._actions: list[tuple[str, Callable[[], Any]]] = [] + + def add(self, resource: str, action: Callable[[], Any]) -> None: + """Register a cleanup action for a resource.""" + self._actions.append((resource, action)) + + async def run(self) -> None: + """Execute all cleanup actions in reverse order.""" + while self._actions: + resource, action = self._actions.pop() + self._status.attempted.append(resource) + try: + result = action() + if asyncio.iscoroutine(result): + await result + self._status.succeeded.append(resource) + except Exception as e: + self._status.failed.append(ExampleCleanupFailure(resource, str(e))) + + if self._status.attempted: + if not self._status.failed: + print("Cleanup completed.") # noqa: T201 + else: + print("Cleanup finished with errors.") # noqa: T201 + + +def _should_fail_process(result: ExampleResult) -> bool: + """Determine if the process should exit with failure.""" + has_failed_checks = any(not check.passed for check in result.checks) + return result.skipped or has_failed_checks or len(result.cleanup_status.failed) > 0 + + +def _run_recipe_impl( + recipe_call: Callable[[], RecipeOutput | Awaitable[RecipeOutput]], + cleanup: _CleanupTracker, + cleanup_status: ExampleCleanupStatus, +) -> ExampleResult: + """Shared implementation for running recipes with cleanup.""" + + async def _run_async() -> RecipeOutput: + try: + result = recipe_call() + if asyncio.iscoroutine(result): + output: RecipeOutput = await result + return output + return result # type: ignore[return-value] + finally: + await cleanup.run() + + loop = asyncio.new_event_loop() + try: + output = loop.run_until_complete(_run_async()) + return ExampleResult( + resources_created=output.resources_created, + checks=output.checks, + cleanup_status=cleanup_status, + ) + finally: + loop.close() + + +def wrap_recipe( + recipe: Callable[[RecipeContext], RecipeOutput] | Callable[[RecipeContext], Awaitable[RecipeOutput]], + validate_env: Callable[[], tuple[bool, list[ExampleCheck]]] | None = None, +) -> Callable[[], ExampleResult]: + """Wrap a recipe function with cleanup tracking and result handling. + + Args: + recipe: The recipe function to wrap. Can be sync or async. + validate_env: Optional function to validate environment before running. + Returns (skip, checks) tuple. + + Returns: + A callable that runs the recipe and returns ExampleResult. + """ + + def run() -> ExampleResult: + cleanup_status = ExampleCleanupStatus() + cleanup = _CleanupTracker(cleanup_status) + + if validate_env is not None: + skip, checks = validate_env() + if skip: + return ExampleResult( + resources_created=[], + checks=checks, + cleanup_status=cleanup_status, + skipped=True, + ) + + ctx = RecipeContext(cleanup=cleanup) + return _run_recipe_impl(lambda: recipe(ctx), cleanup, cleanup_status) + + return run + + +def wrap_recipe_with_options( + recipe: Callable[[RecipeContext, T], RecipeOutput] | Callable[[RecipeContext, T], Awaitable[RecipeOutput]], + validate_env: Callable[[T], tuple[bool, list[ExampleCheck]]] | None = None, +) -> Callable[[T], ExampleResult]: + """Wrap a recipe function that takes options with cleanup tracking. + + Args: + recipe: The recipe function to wrap. Can be sync or async. Takes options parameter. + validate_env: Optional function to validate environment before running. + Takes options and returns (skip, checks) tuple. + + Returns: + A callable that runs the recipe with options and returns ExampleResult. + """ + + def run(options: T) -> ExampleResult: + cleanup_status = ExampleCleanupStatus() + cleanup = _CleanupTracker(cleanup_status) + + if validate_env is not None: + skip, checks = validate_env(options) + if skip: + return ExampleResult( + resources_created=[], + checks=checks, + cleanup_status=cleanup_status, + skipped=True, + ) + + ctx = RecipeContext(cleanup=cleanup) + return _run_recipe_impl(lambda: recipe(ctx, options), cleanup, cleanup_status) + + return run + + +def run_as_cli(run: Callable[[], ExampleResult]) -> None: + """Run an example and exit with appropriate status code.""" + try: + result = run() + print(json.dumps(asdict(result), indent=2)) # noqa: T201 + if _should_fail_process(result): + sys.exit(1) + except Exception as e: + print(f"Error: {e}") # noqa: T201 + sys.exit(1) diff --git a/examples/devbox_from_blueprint_lifecycle.py b/examples/devbox_from_blueprint_lifecycle.py new file mode 100644 index 000000000..9dc98c4ed --- /dev/null +++ b/examples/devbox_from_blueprint_lifecycle.py @@ -0,0 +1,82 @@ +#!/usr/bin/env -S uv run python +""" +--- +title: Devbox From Blueprint (Run Command, Shutdown) +slug: devbox-from-blueprint-lifecycle +use_case: Create a devbox from a blueprint, run a command, validate output, and cleanly tear everything down. +workflow: + - Create a blueprint + - Create a devbox from the blueprint + - Execute a command in the devbox + - Validate exit code and stdout + - Shutdown devbox and delete blueprint +tags: + - devbox + - blueprint + - commands + - cleanup +prerequisites: + - RUNLOOP_API_KEY +run: uv run python -m examples.devbox_from_blueprint_lifecycle +test: uv run pytest -m smoketest tests/smoketests/examples/ +--- +""" + +from __future__ import annotations + +from runloop_api_client import RunloopSDK +from runloop_api_client.lib.polling import PollingConfig + +from ._harness import run_as_cli, unique_name, wrap_recipe +from .example_types import ExampleCheck, RecipeOutput, RecipeContext + +BLUEPRINT_POLL_TIMEOUT_S = 10 * 60 + + +def recipe(ctx: RecipeContext) -> RecipeOutput: + """Create a devbox from a blueprint, run a command, and clean up.""" + cleanup = ctx.cleanup + + sdk = RunloopSDK() + + blueprint = sdk.blueprint.create( + name=unique_name("example-blueprint"), + dockerfile='FROM ubuntu:22.04\nRUN echo "Hello from your blueprint"', + polling_config=PollingConfig(timeout_seconds=BLUEPRINT_POLL_TIMEOUT_S), + ) + cleanup.add(f"blueprint:{blueprint.id}", blueprint.delete) + + devbox = blueprint.create_devbox( + name=unique_name("example-devbox"), + launch_parameters={ + "resource_size_request": "X_SMALL", + "keep_alive_time_seconds": 60 * 5, + }, + ) + cleanup.add(f"devbox:{devbox.id}", devbox.shutdown) + + result = devbox.cmd.exec('echo "Hello from your devbox"') + stdout = result.stdout() + + return RecipeOutput( + resources_created=[f"blueprint:{blueprint.id}", f"devbox:{devbox.id}"], + checks=[ + ExampleCheck( + name="command exits successfully", + passed=result.exit_code == 0, + details=f"exitCode={result.exit_code}", + ), + ExampleCheck( + name="command output contains expected text", + passed="Hello from your devbox" in stdout, + details=stdout.strip(), + ), + ], + ) + + +run_devbox_from_blueprint_lifecycle_example = wrap_recipe(recipe) + + +if __name__ == "__main__": + run_as_cli(run_devbox_from_blueprint_lifecycle_example) diff --git a/examples/example_types.py b/examples/example_types.py new file mode 100644 index 000000000..46e8931c8 --- /dev/null +++ b/examples/example_types.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Any, Union, Callable, Protocol, Awaitable +from dataclasses import field, dataclass + + +@dataclass +class ExampleCheck: + """Result of a single validation check in an example.""" + + name: str + passed: bool + details: str | None = None + + +@dataclass +class ExampleCleanupFailure: + """Record of a cleanup action that failed.""" + + resource: str + reason: str + + +@dataclass +class ExampleCleanupStatus: + """Tracks cleanup operations during example execution.""" + + attempted: list[str] = field(default_factory=lambda: []) + succeeded: list[str] = field(default_factory=lambda: []) + failed: list[ExampleCleanupFailure] = field(default_factory=lambda: []) + + +@dataclass +class ExampleResult: + """Full result of running an example, including checks and cleanup status.""" + + resources_created: list[str] + checks: list[ExampleCheck] + cleanup_status: ExampleCleanupStatus + skipped: bool = False + + +@dataclass +class RecipeOutput: + """Output from a recipe function before cleanup runs.""" + + resources_created: list[str] + checks: list[ExampleCheck] + + +CleanupAction = Callable[[], Union[None, Awaitable[None]]] + + +class CleanupTracker(Protocol): + """Protocol for tracking cleanup actions.""" + + def add(self, resource: str, action: CleanupAction) -> None: ... + + +@dataclass +class RecipeContext: + """Context passed to recipe functions.""" + + cleanup: Any # CleanupTracker, but using Any to avoid circular typing issues diff --git a/examples/mcp_github_tools.py b/examples/mcp_github_tools.py new file mode 100644 index 000000000..b2850c7af --- /dev/null +++ b/examples/mcp_github_tools.py @@ -0,0 +1,184 @@ +#!/usr/bin/env -S uv run python +""" +--- +title: MCP Hub + Claude Code + GitHub +slug: mcp-github-tools +use_case: Connect Claude Code running in a devbox to GitHub tools through MCP Hub without exposing raw GitHub credentials to the devbox. +workflow: + - Create an MCP config for GitHub + - Store GitHub token as a Runloop secret + - Launch a devbox with MCP Hub wiring + - Install Claude Code and register MCP endpoint + - Run a Claude prompt through MCP tools + - Shutdown devbox and clean up cloud resources +tags: + - mcp + - devbox + - github + - commands + - cleanup +prerequisites: + - RUNLOOP_API_KEY + - GITHUB_TOKEN (GitHub PAT with repo scope) + - ANTHROPIC_API_KEY +run: GITHUB_TOKEN=ghp_xxx ANTHROPIC_API_KEY=sk-ant-xxx uv run python -m examples.mcp_github_tools +test: uv run pytest -m smoketest tests/smoketests/examples/ +--- +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +from runloop_api_client import RunloopSDK + +from ._harness import run_as_cli, unique_name, wrap_recipe_with_options +from .example_types import ExampleCheck, RecipeOutput, RecipeContext + +GITHUB_MCP_ENDPOINT = "https://api.githubcopilot.com/mcp/" + + +@dataclass +class McpExampleOptions: + """Options for the MCP GitHub tools example.""" + + skip_if_missing_credentials: bool = False + + +def recipe(ctx: RecipeContext, options: McpExampleOptions) -> RecipeOutput: # noqa: ARG001 + """Connect Claude Code to GitHub tools via MCP Hub.""" + cleanup = ctx.cleanup + + sdk = RunloopSDK() + resources_created: list[str] = [] + checks: list[ExampleCheck] = [] + + github_token = os.environ.get("GITHUB_TOKEN") + anthropic_key = os.environ.get("ANTHROPIC_API_KEY") + + if not github_token: + raise RuntimeError("Set GITHUB_TOKEN to a GitHub PAT with repo scope.") + if not anthropic_key: + raise RuntimeError("Set ANTHROPIC_API_KEY for Claude Code.") + + # Register GitHub's MCP server with Runloop + mcp_config = sdk.api.mcp_configs.create( + name=unique_name("github-example"), + endpoint=GITHUB_MCP_ENDPOINT, + allowed_tools=[ + "get_me", + "search_pull_requests", + "get_pull_request", + "get_repository", + "get_file_contents", + ], + description="GitHub MCP server - example", + ) + resources_created.append(f"mcp_config:{mcp_config.id}") + cleanup.add(f"mcp_config:{mcp_config.id}", lambda: sdk.api.mcp_configs.delete(mcp_config.id)) + + # Store the GitHub PAT as a Runloop secret + secret_name = unique_name("example-github-mcp") + sdk.api.secrets.create(name=secret_name, value=github_token) + resources_created.append(f"secret:{secret_name}") + cleanup.add(f"secret:{secret_name}", lambda: sdk.api.secrets.delete(secret_name)) + + # Launch a devbox with MCP Hub wiring + devbox = sdk.devbox.create( + name=unique_name("mcp-claude-code"), + launch_parameters={ + "resource_size_request": "SMALL", + "keep_alive_time_seconds": 300, + }, + mcp={ + "MCP_SECRET": { + "mcp_config": mcp_config.id, + "secret": secret_name, + }, + }, + ) + resources_created.append(f"devbox:{devbox.id}") + cleanup.add(f"devbox:{devbox.id}", devbox.shutdown) + + # Install Claude Code + install_result = devbox.cmd.exec("npm install -g @anthropic-ai/claude-code") + checks.append( + ExampleCheck( + name="install Claude Code", + passed=install_result.exit_code == 0, + details="installed" if install_result.exit_code == 0 else install_result.stderr(), + ) + ) + if install_result.exit_code != 0: + return RecipeOutput(resources_created=resources_created, checks=checks) + + # Register MCP Hub endpoint with Claude Code + add_mcp_result = devbox.cmd.exec( + 'claude mcp add runloop-mcp --transport http "$RL_MCP_URL" --header "Authorization: Bearer $RL_MCP_TOKEN"', + ) + checks.append( + ExampleCheck( + name="register MCP Hub in Claude", + passed=add_mcp_result.exit_code == 0, + details="registered" if add_mcp_result.exit_code == 0 else add_mcp_result.stderr(), + ) + ) + if add_mcp_result.exit_code != 0: + return RecipeOutput(resources_created=resources_created, checks=checks) + + # Ask Claude to summarize latest PR via MCP tools + prompt = ( + "Use the MCP tools to get my last pr and describe what it does in 2-3 sentences. " + "Also detail how you collected this information" + ) + claude_result = devbox.cmd.exec( + f'ANTHROPIC_API_KEY={anthropic_key} claude -p "{prompt}" --dangerously-skip-permissions', + ) + claude_stdout = claude_result.stdout().strip() + checks.append( + ExampleCheck( + name="Claude prompt through MCP succeeds", + passed=claude_result.exit_code == 0 and len(claude_stdout) > 0, + details="non-empty response received" if claude_result.exit_code == 0 else claude_result.stderr(), + ) + ) + + return RecipeOutput(resources_created=resources_created, checks=checks) + + +def validate_env(options: McpExampleOptions) -> tuple[bool, list[ExampleCheck]]: + """Validate required environment variables.""" + checks: list[ExampleCheck] = [] + skip_if_missing = options.skip_if_missing_credentials + + github_token = os.environ.get("GITHUB_TOKEN") + if not github_token and skip_if_missing: + checks.append( + ExampleCheck( + name="GITHUB_TOKEN provided", + passed=False, + details="Skipped: missing GITHUB_TOKEN", + ) + ) + return True, checks + + anthropic_key = os.environ.get("ANTHROPIC_API_KEY") + if not anthropic_key and skip_if_missing: + checks.append( + ExampleCheck( + name="ANTHROPIC_API_KEY provided", + passed=False, + details="Skipped: missing ANTHROPIC_API_KEY", + ) + ) + return True, checks + + return False, checks + + +run_mcp_github_tools_example = wrap_recipe_with_options(recipe, validate_env) + + +if __name__ == "__main__": + run_as_cli(lambda: run_mcp_github_tools_example(McpExampleOptions())) diff --git a/examples/registry.py b/examples/registry.py new file mode 100644 index 000000000..41a4b4b51 --- /dev/null +++ b/examples/registry.py @@ -0,0 +1,39 @@ +""" +This file is auto-generated by scripts/generate_examples_md.py. +Do not edit manually. +""" + +from __future__ import annotations + +from typing import Any, Callable, cast + +from .example_types import ExampleResult +from .mcp_github_tools import run_mcp_github_tools_example +from .devbox_from_blueprint_lifecycle import run_devbox_from_blueprint_lifecycle_example + +ExampleRegistryEntry = dict[str, Any] + +example_registry: list[ExampleRegistryEntry] = [ + { + "slug": "devbox-from-blueprint-lifecycle", + "title": "Devbox From Blueprint (Run Command, Shutdown)", + "file_name": "devbox_from_blueprint_lifecycle.py", + "required_env": ["RUNLOOP_API_KEY"], + "run": run_devbox_from_blueprint_lifecycle_example, + }, + { + "slug": "mcp-github-tools", + "title": "MCP Hub + Claude Code + GitHub", + "file_name": "mcp_github_tools.py", + "required_env": ["RUNLOOP_API_KEY", "GITHUB_TOKEN", "ANTHROPIC_API_KEY"], + "run": run_mcp_github_tools_example, + }, +] + + +def get_example_runner(slug: str) -> Callable[[], ExampleResult] | None: + """Get the runner function for an example by slug.""" + for entry in example_registry: + if entry["slug"] == slug: + return cast(Callable[[], ExampleResult], entry["run"]) + return None diff --git a/llms.txt b/llms.txt new file mode 100644 index 000000000..6683b6554 --- /dev/null +++ b/llms.txt @@ -0,0 +1,57 @@ +# Runloop Python SDK + +> Python client for Runloop.ai - create cloud devboxes, execute commands, manage blueprints and snapshots. Additional platform documentation is available at [docs.runloop.ai](https://docs.runloop.ai) and [docs.runloop.ai/llms.txt](https://docs.runloop.ai/llms.txt). + +## Quick Start + +- [README.md](README.md): Installation, authentication, and quickstart example +- [README-SDK.md](README-SDK.md): Object-oriented SDK documentation +- [EXAMPLES.md](EXAMPLES.md): Consolidated workflow recipes + +## Core Patterns + +- [Devbox lifecycle example](examples/devbox_from_blueprint_lifecycle.py): Create blueprint, launch devbox, run commands, cleanup +- [MCP GitHub example](examples/mcp_github_tools.py): MCP Hub integration with Claude Code + +## API Reference + +- [SDK entry point](src/runloop_api_client/__init__.py): `AsyncRunloopSDK` and `RunloopSDK` classes +- [Type definitions](src/runloop_api_client/types/): Pydantic types for all resources + +## Key Guidance + +- **Prefer `AsyncRunloopSDK` over `RunloopSDK`** for better concurrency and performance; all SDK methods have async equivalents +- Use `async with await runloop.devbox.create()` for automatic cleanup via context manager +- For resources without SDK coverage (e.g., secrets, benchmarks), use `runloop.api.*` as a fallback +- Execute commands with `await devbox.cmd.exec('command')`, not raw API calls +- Always call `await devbox.shutdown()` to clean up resources (or use context manager) +- Streaming callbacks (`stdout`, `stderr`) must be synchronous functions even with async SDK +- In example files, focus on the `recipe` function body for SDK usage patterns; ignore test harness files (`_harness.py`, `registry.py`, `example_types.py`) + +## Async vs Sync + +The SDK provides both sync and async variants. **The async SDK (`AsyncRunloopSDK`) is recommended** because: + +- Better resource utilization when running multiple devbox operations +- Non-blocking I/O for long-running commands +- Native async/await support integrates cleanly with modern Python frameworks + +Sync SDK example: +```python +from runloop_api_client import RunloopSDK +runloop = RunloopSDK() +with runloop.devbox.create(name="my-devbox") as devbox: + result = devbox.cmd.exec("echo hello") +``` + +Async SDK example (preferred): +```python +from runloop_api_client import AsyncRunloopSDK +runloop = AsyncRunloopSDK() +async with await runloop.devbox.create(name="my-devbox") as devbox: + result = await devbox.cmd.exec("echo hello") +``` + +## Optional + +- [External docs](https://docs.runloop.ai/llms.txt): Additional agent guidance from Runloop platform documentation diff --git a/pyproject.toml b/pyproject.toml index dfd204508..e80f1b5ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ dev = [ "pytest-xdist>=3.6.1", "uuid-utils>=0.11.0", "pytest-cov>=7.0.0", + "python-frontmatter>=1.0.0", ] docs = [ "furo>=2025.9.25", diff --git a/scripts/generate_examples_md.py b/scripts/generate_examples_md.py new file mode 100644 index 000000000..bf4dc587a --- /dev/null +++ b/scripts/generate_examples_md.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python +"""Generate EXAMPLES.md and examples/registry.py from example file frontmatter. + +Usage: + uv run python scripts/generate_examples_md.py # Generate files + uv run python scripts/generate_examples_md.py --check # Check if files are up to date +""" + +from __future__ import annotations + +import re +import sys +import argparse +from typing import Any +from pathlib import Path + +import frontmatter # type: ignore[import-untyped] + +ROOT = Path(__file__).parent.parent +EXAMPLES_DIR = ROOT / "examples" +OUTPUT_FILE = ROOT / "EXAMPLES.md" +OUTPUT_REGISTRY_FILE = EXAMPLES_DIR / "registry.py" +LLMS_FILE = ROOT / "llms.txt" + +REQUIRED_FIELDS = ["title", "slug", "use_case", "workflow", "tags", "prerequisites", "run", "test"] +EXCLUDED_FILES = {"_harness.py", "example_types.py", "registry.py", "__init__.py"} + + +def parse_example(path: Path) -> dict[str, Any]: + """Parse frontmatter from a Python file's docstring.""" + content = path.read_text() + match = re.search(r'^(?:#!.*\n)?(?:\s*\n)*"""([\s\S]*?)"""', content) + if not match: + raise ValueError(f"{path}: missing docstring") + + docstring = match.group(1).strip() + if not docstring.startswith("---"): + raise ValueError(f"{path}: docstring must start with frontmatter (---)") + + try: + post = frontmatter.loads(docstring) + return dict(post.metadata) + except Exception as e: + raise ValueError(f"{path}: invalid frontmatter: {e}") from e + + +def validate_example(metadata: dict[str, Any], file_name: str, seen_slugs: set[str]) -> None: + """Validate all example metadata in one pass.""" + path = f"examples/{file_name}" + + missing = [f for f in REQUIRED_FIELDS if f not in metadata] + if missing: + raise ValueError(f"{path}: missing fields: {', '.join(missing)}") + + for field in ("workflow", "tags", "prerequisites"): + if not isinstance(metadata[field], list) or not metadata[field]: + raise ValueError(f"{path}: '{field}' must be a non-empty list") + + slug = metadata["slug"] + expected_slug = file_name.replace(".py", "").replace("_", "-") + if slug != expected_slug: + raise ValueError(f"{path}: slug '{slug}' must match '{expected_slug}'") + if slug in seen_slugs: + raise ValueError(f"{path}: duplicate slug") + seen_slugs.add(slug) + + module_name = file_name.replace(".py", "") + if f"examples.{module_name}" not in metadata["run"]: + raise ValueError(f"{path}: run command must reference 'examples.{module_name}'") + + +def ensure_llms_references(examples: list[dict[str, Any]]) -> None: + """Ensure llms.txt references at least one example file.""" + if not LLMS_FILE.exists(): + raise ValueError(f"Missing llms file: {LLMS_FILE.relative_to(ROOT)}") + + llms_text = LLMS_FILE.read_text() + referenced = set(re.findall(r"examples/([a-z0-9_]+\.py)", llms_text)) + + if not referenced: + raise ValueError(f"{LLMS_FILE.relative_to(ROOT)}: expected at least one reference to examples/*.py") + + generated = {e["file_name"] for e in examples} + for file_name in referenced: + if file_name not in generated: + raise ValueError(f"{LLMS_FILE.relative_to(ROOT)}: references unknown example file 'examples/{file_name}'") + + +def normalize_env_var(prerequisite: str) -> str | None: + """Extract environment variable name from prerequisite string.""" + match = re.match(r"^[A-Z0-9_]+", prerequisite) + return match.group(0) if match else None + + +def markdown_for_example(example: dict[str, Any]) -> str: + """Generate markdown section for a single example.""" + lines = [ + f'', + f"## {example['title']}", + "", + f"**Use case:** {example['use_case']}", + "", + f"**Tags:** {', '.join(f'`{tag}`' for tag in example['tags'])}", + "", + "### Workflow", + *[f"- {step}" for step in example["workflow"]], + "", + "### Prerequisites", + *[f"- `{item}`" for item in example["prerequisites"]], + "", + "### Run", + "```sh", + example["run"], + "```", + "", + "### Test", + "```sh", + example["test"], + "```", + "", + f"**Source:** [`examples/{example['file_name']}`](./examples/{example['file_name']})", + "", + ] + return "\n".join(lines) + + +def generate_markdown(examples: list[dict[str, Any]]) -> str: + """Generate the full EXAMPLES.md content.""" + toc = "\n".join(f"- [{e['title']}](#{e['slug']})" for e in examples) + sections = "\n".join(markdown_for_example(e) for e in examples) + + return "\n".join( + [ + "# Examples", + "", + "> This file is auto-generated from metadata in `examples/*.py`.", + "> Do not edit this file manually. Run `uv run python scripts/generate_examples_md.py` instead.", + "", + "Runnable examples live in [`examples/`](./examples).", + "", + "## Table of Contents", + "", + toc, + "", + sections.rstrip(), + "", + ] + ) + + +def generate_registry(examples: list[dict[str, Any]]) -> str: + """Generate the registry.py content.""" + imports: list[str] = [] + for example in examples: + module = example["file_name"].replace(".py", "") + runner = f"run_{module}_example" + imports.append(f"from .{module} import {runner}") + imports.sort(key=len) + + entries: list[str] = [] + for example in examples: + module = example["file_name"].replace(".py", "") + runner = f"run_{module}_example" + env_vars = [normalize_env_var(p) for p in example["prerequisites"]] + env_list = ", ".join(f'"{e}"' for e in env_vars if e) + title = example["title"].replace('"', '\\"') + entries.append(f''' {{ + "slug": "{example["slug"]}", + "title": "{title}", + "file_name": "{example["file_name"]}", + "required_env": [{env_list}], + "run": {runner}, + }},''') + + return f'''""" +This file is auto-generated by scripts/generate_examples_md.py. +Do not edit manually. +""" + +from __future__ import annotations + +from typing import Any, Callable, cast + +from .example_types import ExampleResult +{chr(10).join(imports)} + +ExampleRegistryEntry = dict[str, Any] + +example_registry: list[ExampleRegistryEntry] = [ +{chr(10).join(entries)} +] + + +def get_example_runner(slug: str) -> Callable[[], ExampleResult] | None: + """Get the runner function for an example by slug.""" + for entry in example_registry: + if entry["slug"] == slug: + return cast(Callable[[], ExampleResult], entry["run"]) + return None +''' + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser(description="Generate EXAMPLES.md and registry.py") + parser.add_argument("--check", action="store_true", help="Check if files are up to date") + args = parser.parse_args() + + seen_slugs: set[str] = set() + examples: list[dict[str, Any]] = [] + + for path in sorted(EXAMPLES_DIR.glob("*.py")): + if path.name in EXCLUDED_FILES or path.name.startswith("_"): + continue + try: + metadata = parse_example(path) + validate_example(metadata, path.name, seen_slugs) + examples.append({**metadata, "file_name": path.name}) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + examples.sort(key=lambda e: e["title"]) + + try: + ensure_llms_references(examples) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + markdown = generate_markdown(examples) + registry_source = generate_registry(examples) + + if args.check: + errors: list[str] = [] + if not OUTPUT_FILE.exists(): + errors.append(f"{OUTPUT_FILE.relative_to(ROOT)} does not exist") + elif OUTPUT_FILE.read_text() != markdown: + errors.append(f"{OUTPUT_FILE.relative_to(ROOT)} is out of date") + + if not OUTPUT_REGISTRY_FILE.exists(): + errors.append(f"{OUTPUT_REGISTRY_FILE.relative_to(ROOT)} does not exist") + elif OUTPUT_REGISTRY_FILE.read_text() != registry_source: + errors.append(f"{OUTPUT_REGISTRY_FILE.relative_to(ROOT)} is out of date") + + if errors: + for err in errors: + print(f"Error: {err}", file=sys.stderr) + print("\nRun `uv run python scripts/generate_examples_md.py` to regenerate.", file=sys.stderr) + return 1 + + print("All generated files are up to date.") + return 0 + + OUTPUT_FILE.write_text(markdown) + OUTPUT_REGISTRY_FILE.write_text(registry_source) + print(f"Wrote {OUTPUT_FILE.relative_to(ROOT)} from {len(examples)} example(s)") + print(f"Wrote {OUTPUT_REGISTRY_FILE.relative_to(ROOT)} from {len(examples)} example(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/smoketests/examples/__init__.py b/tests/smoketests/examples/__init__.py new file mode 100644 index 000000000..c0b52f6e9 --- /dev/null +++ b/tests/smoketests/examples/__init__.py @@ -0,0 +1 @@ +# Smoketests for examples diff --git a/tests/smoketests/examples/test_examples.py b/tests/smoketests/examples/test_examples.py new file mode 100644 index 000000000..7dbe82581 --- /dev/null +++ b/tests/smoketests/examples/test_examples.py @@ -0,0 +1,107 @@ +"""Smoketests for SDK examples. + +These tests run the example scripts against the live API. +Set RUN_EXAMPLE_LIVE_TESTS=1 to enable live tests. +""" + +from __future__ import annotations + +import os +import sys +from typing import Any +from pathlib import Path + +import pytest + +# Add the root directory to the path so we can import examples +sys.path.insert(0, str(Path(__file__).parents[3])) + +from examples.registry import example_registry # noqa: E402 +from examples.example_types import ExampleResult # noqa: E402 +from examples.mcp_github_tools import McpExampleOptions, run_mcp_github_tools_example # noqa: E402 + +LONG_TIMEOUT = 600 # 10 minutes for live tests +SHORT_TIMEOUT = 30 # 30 seconds for skip mode tests + + +class TestExamples: + """Tests for SDK examples.""" + + @pytest.mark.smoketest + @pytest.mark.timeout(LONG_TIMEOUT) + @pytest.mark.parametrize("entry", example_registry, ids=lambda e: e["slug"]) + def test_example_runs_with_successful_checks(self, entry: dict[str, Any]) -> None: + """Test that examples run successfully with all checks passing.""" + if not os.environ.get("RUN_EXAMPLE_LIVE_TESTS"): + pytest.skip("RUN_EXAMPLE_LIVE_TESTS not set") + + required_env: list[str] = entry["required_env"] + missing_env = [e for e in required_env if not os.environ.get(e)] + if missing_env: + pytest.skip(f"Missing env vars: {missing_env}") + + # Handle examples that need options + result: ExampleResult + if entry["slug"] == "mcp-github-tools": + result = run_mcp_github_tools_example(McpExampleOptions()) + else: + result = entry["run"]() + + assert not result.skipped, "Example was unexpectedly skipped" + assert len(result.resources_created) > 0, "No resources were created" + assert len(result.checks) > 0, "No checks were performed" + + failed_checks = [c for c in result.checks if not c.passed] + assert not failed_checks, f"Failed checks: {[c.name for c in failed_checks]}" + + assert len(result.cleanup_status.failed) == 0, ( + f"Cleanup failures: {[f.resource for f in result.cleanup_status.failed]}" + ) + + @pytest.mark.timeout(SHORT_TIMEOUT) + def test_mcp_skip_mode_for_missing_credentials(self) -> None: + """Test that mcp-github-tools example skips deterministically when credentials are missing.""" + # Save original env vars + original_github_token = os.environ.get("GITHUB_TOKEN") + original_anthropic_key = os.environ.get("ANTHROPIC_API_KEY") + + # Remove credentials + if "GITHUB_TOKEN" in os.environ: + del os.environ["GITHUB_TOKEN"] + if "ANTHROPIC_API_KEY" in os.environ: + del os.environ["ANTHROPIC_API_KEY"] + + try: + result = run_mcp_github_tools_example(McpExampleOptions(skip_if_missing_credentials=True)) + + assert result.skipped, "Example should be skipped when credentials are missing" + assert len(result.resources_created) == 0, "No resources should be created when skipped" + assert len(result.cleanup_status.attempted) == 0, "No cleanup should be attempted when skipped" + finally: + # Restore original env vars + if original_github_token is not None: + os.environ["GITHUB_TOKEN"] = original_github_token + elif "GITHUB_TOKEN" in os.environ: + del os.environ["GITHUB_TOKEN"] + + if original_anthropic_key is not None: + os.environ["ANTHROPIC_API_KEY"] = original_anthropic_key + elif "ANTHROPIC_API_KEY" in os.environ: + del os.environ["ANTHROPIC_API_KEY"] + + @pytest.mark.timeout(SHORT_TIMEOUT) + def test_example_registry_is_populated(self) -> None: + """Test that the example registry contains expected entries.""" + assert len(example_registry) >= 2, "Expected at least 2 examples in registry" + + slugs = {e["slug"] for e in example_registry} + assert "devbox-from-blueprint-lifecycle" in slugs + assert "mcp-github-tools" in slugs + + for entry in example_registry: + assert "slug" in entry + assert "title" in entry + assert "file_name" in entry + assert "required_env" in entry + assert "run" in entry + assert callable(entry["run"]) diff --git a/uv.lock b/uv.lock index 26c8d993a..4f0209e1f 100644 --- a/uv.lock +++ b/uv.lock @@ -2082,6 +2082,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-frontmatter" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256, upload-time = "2024-01-16T18:50:04.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834, upload-time = "2024-01-16T18:50:00.911Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -2267,7 +2352,7 @@ wheels = [ [[package]] name = "runloop-api-client" -version = "1.9.0" +version = "1.10.3" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -2298,6 +2383,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, + { name = "python-frontmatter" }, { name = "respx" }, { name = "rich" }, { name = "ruff" }, @@ -2343,6 +2429,7 @@ dev = [ { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-timeout" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, + { name = "python-frontmatter", specifier = ">=1.0.0" }, { name = "respx" }, { name = "rich", specifier = ">=13.7.1" }, { name = "ruff" }, From fa16c1d602ec301200e82a48fe23c2218ae82a08 Mon Sep 17 00:00:00 2001 From: james-rl Date: Mon, 2 Mar 2026 18:07:03 -0800 Subject: [PATCH 2/8] chore(documentation): correct exec advice (#752) --- llms.txt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/llms.txt b/llms.txt index 6683b6554..07971384f 100644 --- a/llms.txt +++ b/llms.txt @@ -23,9 +23,11 @@ - **Prefer `AsyncRunloopSDK` over `RunloopSDK`** for better concurrency and performance; all SDK methods have async equivalents - Use `async with await runloop.devbox.create()` for automatic cleanup via context manager - For resources without SDK coverage (e.g., secrets, benchmarks), use `runloop.api.*` as a fallback -- Execute commands with `await devbox.cmd.exec('command')`, not raw API calls -- Always call `await devbox.shutdown()` to clean up resources (or use context manager) -- Streaming callbacks (`stdout`, `stderr`) must be synchronous functions even with async SDK +- Use `await devbox.cmd.exec('command')` for most commands—blocks until completion, returns `ExecutionResult` with stdout/stderr +- Use `await devbox.cmd.exec_async('command')` for long-running or background processes (servers, watchers)—returns immediately with `Execution` handle to check status, get result, or kill +- Both `exec` and `exec_async` support streaming callbacks (`stdout`, `stderr`, `output`) for real-time output +- Call `await devbox.shutdown()` to clean up resources that are no longer in use. +- Streaming callbacks (`stdout`, `stderr`, `output`) must be synchronous functions even with async SDK - In example files, focus on the `recipe` function body for SDK usage patterns; ignore test harness files (`_harness.py`, `registry.py`, `example_types.py`) ## Async vs Sync From d2b32e9da3f6b3e59ae29a10fa361070a6c73a23 Mon Sep 17 00:00:00 2001 From: james-rl Date: Tue, 3 Mar 2026 15:29:37 -0800 Subject: [PATCH 3/8] improve(docs): blueprint build with context added to examples (#751) --- EXAMPLES.md | 31 ++++++ examples/blueprint_with_build_context.py | 130 +++++++++++++++++++++++ examples/registry.py | 8 ++ 3 files changed, 169 insertions(+) create mode 100644 examples/blueprint_with_build_context.py diff --git a/EXAMPLES.md b/EXAMPLES.md index 9377b08bb..0d3ff62cc 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -7,9 +7,40 @@ Runnable examples live in [`examples/`](./examples). ## Table of Contents +- [Blueprint with Build Context](#blueprint-with-build-context) - [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle) - [MCP Hub + Claude Code + GitHub](#mcp-github-tools) + +## Blueprint with Build Context + +**Use case:** Create a blueprint using the object store to provide docker build context files, then verify files are copied into the image. Uses the async SDK. + +**Tags:** `blueprint`, `object-store`, `build-context`, `devbox`, `cleanup`, `async` + +### Workflow +- Create a temporary directory with sample application files +- Upload the directory to object storage as build context +- Create a blueprint with a Dockerfile that copies the context files +- Create a devbox from the blueprint +- Verify the files were copied into the image +- Shutdown devbox and delete blueprint and storage object + +### Prerequisites +- `RUNLOOP_API_KEY` + +### Run +```sh +uv run python -m examples.blueprint_with_build_context +``` + +### Test +```sh +uv run pytest -m smoketest tests/smoketests/examples/ +``` + +**Source:** [`examples/blueprint_with_build_context.py`](./examples/blueprint_with_build_context.py) + ## Devbox From Blueprint (Run Command, Shutdown) diff --git a/examples/blueprint_with_build_context.py b/examples/blueprint_with_build_context.py new file mode 100644 index 000000000..3bd9395d3 --- /dev/null +++ b/examples/blueprint_with_build_context.py @@ -0,0 +1,130 @@ +#!/usr/bin/env -S uv run python +""" +--- +title: Blueprint with Build Context +slug: blueprint-with-build-context +use_case: Create a blueprint using the object store to provide docker build context files, then verify files are copied into the image. Uses the async SDK. +workflow: + - Create a temporary directory with sample application files + - Upload the directory to object storage as build context + - Create a blueprint with a Dockerfile that copies the context files + - Create a devbox from the blueprint + - Verify the files were copied into the image + - Shutdown devbox and delete blueprint and storage object +tags: + - blueprint + - object-store + - build-context + - devbox + - cleanup + - async +prerequisites: + - RUNLOOP_API_KEY +run: uv run python -m examples.blueprint_with_build_context +test: uv run pytest -m smoketest tests/smoketests/examples/ +--- +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from datetime import timedelta + +from runloop_api_client import AsyncRunloopSDK +from runloop_api_client.lib.polling import PollingConfig + +from ._harness import run_as_cli, unique_name, wrap_recipe +from .example_types import ExampleCheck, RecipeOutput, RecipeContext + +# building can take time: make sure to set a long blueprint build timeout +BLUEPRINT_POLL_TIMEOUT_S = 10 * 60 +BLUEPRINT_POLL_MAX_ATTEMPTS = 600 + +# configure object storage ttl for the build context +BUILD_CONTEXT_TTL = timedelta(hours=1) + + +async def recipe(ctx: RecipeContext) -> RecipeOutput: + """Create a blueprint with build context from object storage, then verify files in a devbox.""" + cleanup = ctx.cleanup + + sdk = AsyncRunloopSDK() + + # setup: create a temporary directory with sample application files to use as build context + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + (tmp_path / "app.py").write_text('print("Hello from app")') + (tmp_path / "config.txt").write_text("key=value") + + # upload the build context to object storage + storage_obj = await sdk.storage_object.upload_from_dir( + tmp_path, + name=unique_name("example-build-context"), + ttl=BUILD_CONTEXT_TTL, + ) + cleanup.add(f"storage_object:{storage_obj.id}", storage_obj.delete) + + # create a blueprint with the build context + blueprint = await sdk.blueprint.create( + name=unique_name("example-blueprint-context"), + dockerfile="FROM ubuntu:22.04\nWORKDIR /app\nCOPY . .", + build_context=storage_obj.as_build_context(), + polling_config=PollingConfig( + timeout_seconds=BLUEPRINT_POLL_TIMEOUT_S, + max_attempts=BLUEPRINT_POLL_MAX_ATTEMPTS, + ), + ) + cleanup.add(f"blueprint:{blueprint.id}", blueprint.delete) + + devbox = await blueprint.create_devbox( + name=unique_name("example-devbox"), + launch_parameters={ + "resource_size_request": "X_SMALL", + "keep_alive_time_seconds": 60 * 5, + }, + ) + cleanup.add(f"devbox:{devbox.id}", devbox.shutdown) + + app_result = await devbox.cmd.exec("cat /app/app.py") + app_stdout = await app_result.stdout() + + config_result = await devbox.cmd.exec("cat /app/config.txt") + config_stdout = await config_result.stdout() + + return RecipeOutput( + resources_created=[ + f"storage_object:{storage_obj.id}", + f"blueprint:{blueprint.id}", + f"devbox:{devbox.id}", + ], + checks=[ + ExampleCheck( + name="app.py exists and readable", + passed=app_result.exit_code == 0, + details=f"exitCode={app_result.exit_code}", + ), + ExampleCheck( + name="app.py contains expected content", + passed='print("Hello from app")' in app_stdout, + details=app_stdout.strip(), + ), + ExampleCheck( + name="config.txt exists and readable", + passed=config_result.exit_code == 0, + details=f"exitCode={config_result.exit_code}", + ), + ExampleCheck( + name="config.txt contains expected content", + passed="key=value" in config_stdout, + details=config_stdout.strip(), + ), + ], + ) + + +run_blueprint_with_build_context_example = wrap_recipe(recipe) + + +if __name__ == "__main__": + run_as_cli(run_blueprint_with_build_context_example) diff --git a/examples/registry.py b/examples/registry.py index 41a4b4b51..cb6b780a9 100644 --- a/examples/registry.py +++ b/examples/registry.py @@ -9,11 +9,19 @@ from .example_types import ExampleResult from .mcp_github_tools import run_mcp_github_tools_example +from .blueprint_with_build_context import run_blueprint_with_build_context_example from .devbox_from_blueprint_lifecycle import run_devbox_from_blueprint_lifecycle_example ExampleRegistryEntry = dict[str, Any] example_registry: list[ExampleRegistryEntry] = [ + { + "slug": "blueprint-with-build-context", + "title": "Blueprint with Build Context", + "file_name": "blueprint_with_build_context.py", + "required_env": ["RUNLOOP_API_KEY"], + "run": run_blueprint_with_build_context_example, + }, { "slug": "devbox-from-blueprint-lifecycle", "title": "Devbox From Blueprint (Run Command, Shutdown)", From 92004cca270fe013ec95432e579d5620a20bac55 Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 3 Mar 2026 16:33:53 -0800 Subject: [PATCH 4/8] Python client tunnel smoketest (#750) Co-authored-by: Cursor Agent Co-authored-by: albert --- tests/smoketests/sdk/test_async_devbox.py | 18 ++++++++++++------ tests/smoketests/sdk/test_devbox.py | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/tests/smoketests/sdk/test_async_devbox.py b/tests/smoketests/sdk/test_async_devbox.py index 860502cd7..cbfc5c030 100644 --- a/tests/smoketests/sdk/test_async_devbox.py +++ b/tests/smoketests/sdk/test_async_devbox.py @@ -438,15 +438,20 @@ async def test_create_ssh_key(self, async_sdk_client: AsyncRunloopSDK) -> None: 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.""" + async def test_create_tunnel_deprecated(self, async_sdk_client: AsyncRunloopSDK) -> None: + """Test creating a tunnel (deprecated - now creates v2 tunnel). + + Note: The deprecated create_tunnel endpoint now creates v2 Portal tunnels + which cannot be removed. They remain active until the devbox is stopped. + Use enable_tunnel for creating v2 tunnels instead. + """ 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 + # Create tunnel (now creates v2 Portal tunnel) with pytest.warns(DeprecationWarning, match="create_tunnel is deprecated"): tunnel = await devbox.net.create_tunnel(port=8080) assert tunnel is not None @@ -454,9 +459,10 @@ async def test_create_and_remove_tunnel(self, async_sdk_client: AsyncRunloopSDK) assert tunnel.port == 8080 assert tunnel.devbox_id == devbox.id - # Remove tunnel - with pytest.warns(DeprecationWarning, match="remove_tunnel is deprecated"): - await devbox.net.remove_tunnel(port=8080) + # Verify tunnel persists in devbox info (v2 tunnels cannot be removed) + info = await devbox.get_info() + assert info.tunnel is not None + assert info.tunnel.tunnel_key is not None finally: await devbox.shutdown() diff --git a/tests/smoketests/sdk/test_devbox.py b/tests/smoketests/sdk/test_devbox.py index 196fb312e..38e885f71 100644 --- a/tests/smoketests/sdk/test_devbox.py +++ b/tests/smoketests/sdk/test_devbox.py @@ -435,15 +435,20 @@ def test_create_ssh_key(self, sdk_client: RunloopSDK) -> None: 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.""" + def test_create_tunnel_deprecated(self, sdk_client: RunloopSDK) -> None: + """Test creating a tunnel (deprecated - now creates v2 tunnel). + + Note: The deprecated create_tunnel endpoint now creates v2 Portal tunnels + which cannot be removed. They remain active until the devbox is stopped. + Use enable_tunnel for creating v2 tunnels instead. + """ 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 + # Create tunnel (now creates v2 Portal tunnel) with pytest.warns(DeprecationWarning, match="create_tunnel is deprecated"): tunnel = devbox.net.create_tunnel(port=8080) assert tunnel is not None @@ -451,9 +456,10 @@ def test_create_and_remove_tunnel(self, sdk_client: RunloopSDK) -> None: assert tunnel.port == 8080 assert tunnel.devbox_id == devbox.id - # Remove tunnel - with pytest.warns(DeprecationWarning, match="remove_tunnel is deprecated"): - devbox.net.remove_tunnel(port=8080) + # Verify tunnel persists in devbox info (v2 tunnels cannot be removed) + info = devbox.get_info() + assert info.tunnel is not None + assert info.tunnel.tunnel_key is not None finally: devbox.shutdown() From da5faa4e69348dd6f034a538046ab9ff2ff4831b Mon Sep 17 00:00:00 2001 From: james-rl Date: Wed, 4 Mar 2026 11:58:00 -0800 Subject: [PATCH 5/8] fix: add logs to devboxes, smoke tests & examples (#755) --- EXAMPLES.md | 8 ++-- README-SDK.md | 23 ++++++++- README.md | 11 +++++ examples/devbox_from_blueprint_lifecycle.py | 25 ++++++++-- llms.txt | 4 +- scripts/install-hooks | 27 +++++++++++ src/runloop_api_client/sdk/async_devbox.py | 34 ++++++++++++++ src/runloop_api_client/sdk/devbox.py | 34 ++++++++++++++ tests/smoketests/sdk/test_async_devbox.py | 52 +++++++++++++++++++++ tests/smoketests/sdk/test_devbox.py | 52 +++++++++++++++++++++ 10 files changed, 260 insertions(+), 10 deletions(-) create mode 100755 scripts/install-hooks diff --git a/EXAMPLES.md b/EXAMPLES.md index 0d3ff62cc..faa08edd4 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -44,15 +44,17 @@ uv run pytest -m smoketest tests/smoketests/examples/ ## Devbox From Blueprint (Run Command, Shutdown) -**Use case:** Create a devbox from a blueprint, run a command, validate output, and cleanly tear everything down. +**Use case:** Create a devbox from a blueprint, run a command, fetch logs, validate output, and cleanly tear everything down. -**Tags:** `devbox`, `blueprint`, `commands`, `cleanup` +**Tags:** `devbox`, `blueprint`, `commands`, `logs`, `cleanup` ### Workflow - Create a blueprint +- Fetch blueprint build logs - Create a devbox from the blueprint - Execute a command in the devbox -- Validate exit code and stdout +- Fetch devbox logs +- Validate exit code, stdout, and logs - Shutdown devbox and delete blueprint ### Prerequisites diff --git a/README-SDK.md b/README-SDK.md index e4885ff3b..115eacf22 100644 --- a/README-SDK.md +++ b/README-SDK.md @@ -160,13 +160,13 @@ print(f"Devbox {info.name} is {info.status}") Execute commands synchronously or asynchronously: ```python -# Synchronous command execution (waits for completion) +# exec blocks until completion - use for commands that return immediately result = devbox.cmd.exec("ls -la") print("Output:", result.stdout()) print("Exit code:", result.exit_code) print("Success:", result.success) -# Asynchronous command execution (returns immediately) +# exec_async returns immediately - use for long-running processes execution = devbox.cmd.exec_async("npm run dev") # Check execution status @@ -393,11 +393,30 @@ async with await runloop.devbox.create(name="temp-devbox") as devbox: # devbox is automatically shutdown when exiting the context ``` +#### Devbox Logs + +Retrieve logs from a devbox, optionally filtered by execution ID or shell name: + +```python +# Get all devbox logs +logs = devbox.logs() +for log in logs.logs: + print(f"[{log.level}] {log.message}") + +# Filter logs by execution ID +result = devbox.cmd.exec('echo "hello"') +exec_logs = devbox.logs(execution_id=result.execution_id) + +# Filter logs by shell name +shell_logs = devbox.logs(shell_name="my-shell") +``` + **Key methods:** - `devbox.get_info()` - Get devbox details and status - `devbox.cmd.exec()` - Execute commands synchronously - `devbox.cmd.exec_async()` - Execute commands asynchronously +- `devbox.logs()` - Retrieve devbox logs (optionally filter by execution_id or shell_name) - `devbox.file.read()` - Read file contents - `devbox.file.write()` - Write file contents - `devbox.file.upload()` - Upload files diff --git a/README.md b/README.md index 1907bf12b..09aeb9016 100644 --- a/README.md +++ b/README.md @@ -497,6 +497,17 @@ print(runloop_api_client.__version__) Python 3.9 or higher. +## Development + +After cloning the repository, run the bootstrap script and install git hooks: + +```sh +./scripts/bootstrap +./scripts/install-hooks +``` + +This installs pre-push hooks that run linting and verify generated files are up to date. + ## Contributing See [the contributing documentation](./CONTRIBUTING.md). diff --git a/examples/devbox_from_blueprint_lifecycle.py b/examples/devbox_from_blueprint_lifecycle.py index 9dc98c4ed..f916ee5f4 100644 --- a/examples/devbox_from_blueprint_lifecycle.py +++ b/examples/devbox_from_blueprint_lifecycle.py @@ -3,17 +3,20 @@ --- title: Devbox From Blueprint (Run Command, Shutdown) slug: devbox-from-blueprint-lifecycle -use_case: Create a devbox from a blueprint, run a command, validate output, and cleanly tear everything down. +use_case: Create a devbox from a blueprint, run a command, fetch logs, validate output, and cleanly tear everything down. workflow: - Create a blueprint + - Fetch blueprint build logs - Create a devbox from the blueprint - Execute a command in the devbox - - Validate exit code and stdout + - Fetch devbox logs + - Validate exit code, stdout, and logs - Shutdown devbox and delete blueprint tags: - devbox - blueprint - commands + - logs - cleanup prerequisites: - RUNLOOP_API_KEY @@ -34,7 +37,7 @@ def recipe(ctx: RecipeContext) -> RecipeOutput: - """Create a devbox from a blueprint, run a command, and clean up.""" + """Create a devbox from a blueprint, run a command, fetch logs, and clean up.""" cleanup = ctx.cleanup sdk = RunloopSDK() @@ -46,6 +49,9 @@ def recipe(ctx: RecipeContext) -> RecipeOutput: ) cleanup.add(f"blueprint:{blueprint.id}", blueprint.delete) + # Fetch blueprint build logs + blueprint_logs = blueprint.logs() + devbox = blueprint.create_devbox( name=unique_name("example-devbox"), launch_parameters={ @@ -58,6 +64,9 @@ def recipe(ctx: RecipeContext) -> RecipeOutput: result = devbox.cmd.exec('echo "Hello from your devbox"') stdout = result.stdout() + # Fetch devbox logs + devbox_logs = devbox.logs() + return RecipeOutput( resources_created=[f"blueprint:{blueprint.id}", f"devbox:{devbox.id}"], checks=[ @@ -71,6 +80,16 @@ def recipe(ctx: RecipeContext) -> RecipeOutput: passed="Hello from your devbox" in stdout, details=stdout.strip(), ), + ExampleCheck( + name="blueprint build logs are retrievable", + passed=hasattr(blueprint_logs, "logs"), + details=f"blueprint_log_count={len(blueprint_logs.logs)}", + ), + ExampleCheck( + name="devbox logs are retrievable", + passed=hasattr(devbox_logs, "logs"), + details=f"devbox_log_count={len(devbox_logs.logs)}", + ), ], ) diff --git a/llms.txt b/llms.txt index 07971384f..ec1e662bf 100644 --- a/llms.txt +++ b/llms.txt @@ -23,8 +23,8 @@ - **Prefer `AsyncRunloopSDK` over `RunloopSDK`** for better concurrency and performance; all SDK methods have async equivalents - Use `async with await runloop.devbox.create()` for automatic cleanup via context manager - For resources without SDK coverage (e.g., secrets, benchmarks), use `runloop.api.*` as a fallback -- Use `await devbox.cmd.exec('command')` for most commands—blocks until completion, returns `ExecutionResult` with stdout/stderr -- Use `await devbox.cmd.exec_async('command')` for long-running or background processes (servers, watchers)—returns immediately with `Execution` handle to check status, get result, or kill +- Use `await devbox.cmd.exec('command')` for commands expected to return immediately (e.g., `echo`, `pwd`, `cat`)—blocks until completion, returns `ExecutionResult` with stdout/stderr +- Use `await devbox.cmd.exec_async('command')` for long-running or background processes (servers, watchers, builds)—returns immediately with `Execution` handle to check status, get result, or kill - Both `exec` and `exec_async` support streaming callbacks (`stdout`, `stderr`, `output`) for real-time output - Call `await devbox.shutdown()` to clean up resources that are no longer in use. - Streaming callbacks (`stdout`, `stderr`, `output`) must be synchronous functions even with async SDK diff --git a/scripts/install-hooks b/scripts/install-hooks new file mode 100755 index 000000000..43261db61 --- /dev/null +++ b/scripts/install-hooks @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Installing git hooks..." + +mkdir -p .git/hooks + +cat > .git/hooks/pre-push << 'EOF' +#!/usr/bin/env bash +set -e +cd "$(git rev-parse --show-toplevel)" + +echo "==> Running lint checks..." +./scripts/lint + +echo "==> Checking EXAMPLES.md is up to date..." +uv run python scripts/generate_examples_md.py --check + +echo "==> All checks passed!" +EOF + +chmod +x .git/hooks/pre-push + +echo "==> Git hooks installed successfully!" diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index 81d602eb3..e6c0ac51e 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -33,6 +33,7 @@ SDKDevboxSnapshotDiskAsyncParams, SDKDevboxWriteFileContentsParams, ) +from .._types import omit from .._client import AsyncRunloop from ._helpers import filter_params from .._streaming import AsyncStream @@ -41,6 +42,7 @@ from .async_execution import AsyncExecution, _AsyncStreamingGroup from .async_execution_result import AsyncExecutionResult from ..types.devbox_execute_async_params import DevboxNiceExecuteAsyncParams +from ..types.devboxes.devbox_logs_list_view import DevboxLogsListView from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView StreamFactory = Callable[[], Awaitable[AsyncStream[ExecutionUpdateChunk]]] @@ -163,6 +165,38 @@ async def get_tunnel_url( return None return f"https://{port}-{tunnel_view.tunnel_key}.tunnel.runloop.ai" + async def logs( + self, + *, + execution_id: str | None = None, + shell_name: str | None = None, + **options: Unpack[BaseRequestOptions], + ) -> DevboxLogsListView: + """Retrieve logs for the devbox. + + Returns all logs from a running or completed devbox. Optionally filter + by execution ID or shell name. + + :param execution_id: Filter logs by execution ID, defaults to None + :type execution_id: str | None, optional + :param shell_name: Filter logs by shell name, defaults to None + :type shell_name: str | None, optional + :param options: Optional request configuration + :return: Log entries for the devbox + :rtype: :class:`~runloop_api_client.types.devboxes.devbox_logs_list_view.DevboxLogsListView` + + Example: + >>> logs = await devbox.logs() + >>> for log in logs.logs: + ... print(f"[{log.level}] {log.message}") + """ + return await self._client.devboxes.logs.list( + self._id, + execution_id=execution_id if execution_id is not None else omit, + shell_name=shell_name if shell_name is not None else omit, + **options, + ) + async def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView: """Wait for the devbox to reach running state. diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index ff541ce92..5718e7392 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -34,6 +34,7 @@ SDKDevboxSnapshotDiskAsyncParams, SDKDevboxWriteFileContentsParams, ) +from .._types import omit from .._client import Runloop from ._helpers import filter_params from .execution import Execution, _StreamingGroup @@ -42,6 +43,7 @@ from ..types.devboxes import ExecutionUpdateChunk from .execution_result import ExecutionResult from ..types.devbox_execute_async_params import DevboxNiceExecuteAsyncParams +from ..types.devboxes.devbox_logs_list_view import DevboxLogsListView from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView if TYPE_CHECKING: @@ -162,6 +164,38 @@ def get_tunnel_url( return None return f"https://{port}-{tunnel_view.tunnel_key}.tunnel.runloop.ai" + def logs( + self, + *, + execution_id: str | None = None, + shell_name: str | None = None, + **options: Unpack[BaseRequestOptions], + ) -> DevboxLogsListView: + """Retrieve logs for the devbox. + + Returns all logs from a running or completed devbox. Optionally filter + by execution ID or shell name. + + :param execution_id: Filter logs by execution ID, defaults to None + :type execution_id: str | None, optional + :param shell_name: Filter logs by shell name, defaults to None + :type shell_name: str | None, optional + :param options: Optional request configuration + :return: Log entries for the devbox + :rtype: :class:`~runloop_api_client.types.devboxes.devbox_logs_list_view.DevboxLogsListView` + + Example: + >>> logs = devbox.logs() + >>> for log in logs.logs: + ... print(f"[{log.level}] {log.message}") + """ + return self._client.devboxes.logs.list( + self._id, + execution_id=execution_id if execution_id is not None else omit, + shell_name=shell_name if shell_name is not None else omit, + **options, + ) + def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView: """Wait for the devbox to reach running state. diff --git a/tests/smoketests/sdk/test_async_devbox.py b/tests/smoketests/sdk/test_async_devbox.py index cbfc5c030..e21c18705 100644 --- a/tests/smoketests/sdk/test_async_devbox.py +++ b/tests/smoketests/sdk/test_async_devbox.py @@ -1064,3 +1064,55 @@ async def test_shell_exec_async_with_both_streams(self, devbox: AsyncDevbox) -> # Verify streaming captured same data as result assert stdout_combined == await result.stdout() assert stderr_combined == await result.stderr() + + +class TestAsyncDevboxLogs: + """Test async devbox logs retrieval functionality.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_logs_basic(self, shared_devbox: AsyncDevbox) -> None: + """Test retrieving devbox logs returns valid response structure.""" + test_message = "async basic log test message" + result = await shared_devbox.cmd.exec(f'echo "{test_message}"') + assert result.exit_code == 0 + + logs = await shared_devbox.logs() + + assert logs is not None + assert hasattr(logs, "logs") + assert isinstance(logs.logs, list) + log_content = " ".join(str(log) for log in logs.logs) + assert test_message in log_content + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_logs_with_execution_filter(self, shared_devbox: AsyncDevbox) -> None: + """Test retrieving devbox logs filtered by execution ID.""" + test_message = "async filtered log test" + result = await shared_devbox.cmd.exec(f'echo "{test_message}"') + assert result.exit_code == 0 + + logs = await shared_devbox.logs(execution_id=result.execution_id) + + assert logs is not None + assert hasattr(logs, "logs") + assert isinstance(logs.logs, list) + log_content = " ".join(str(log) for log in logs.logs) + assert test_message in log_content + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + async def test_logs_with_shell_name_filter(self, shared_devbox: AsyncDevbox) -> None: + """Test retrieving devbox logs filtered by shell name.""" + shell_name = "async-test-logs-shell" + shell = shared_devbox.shell(shell_name) + + test_message = "async shell log test" + result = await shell.exec(f'echo "{test_message}"') + assert result.exit_code == 0 + + logs = await shared_devbox.logs(shell_name=shell_name) + + assert logs is not None + assert hasattr(logs, "logs") + assert isinstance(logs.logs, list) + log_content = " ".join(str(log) for log in logs.logs) + assert test_message in log_content diff --git a/tests/smoketests/sdk/test_devbox.py b/tests/smoketests/sdk/test_devbox.py index 38e885f71..e145b9b91 100644 --- a/tests/smoketests/sdk/test_devbox.py +++ b/tests/smoketests/sdk/test_devbox.py @@ -1050,3 +1050,55 @@ def test_shell_exec_async_with_both_streams(self, devbox: Devbox) -> None: # Verify streaming captured same data as result assert stdout_combined == result.stdout() assert stderr_combined == result.stderr() + + +class TestDevboxLogs: + """Test devbox logs retrieval functionality.""" + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_logs_basic(self, shared_devbox: Devbox) -> None: + """Test retrieving devbox logs returns valid response structure.""" + test_message = "basic log test message" + result = shared_devbox.cmd.exec(f'echo "{test_message}"') + assert result.exit_code == 0 + + logs = shared_devbox.logs() + + assert logs is not None + assert hasattr(logs, "logs") + assert isinstance(logs.logs, list) + log_content = " ".join(str(log) for log in logs.logs) + assert test_message in log_content + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_logs_with_execution_filter(self, shared_devbox: Devbox) -> None: + """Test retrieving devbox logs filtered by execution ID.""" + test_message = "filtered log test" + result = shared_devbox.cmd.exec(f'echo "{test_message}"') + assert result.exit_code == 0 + + logs = shared_devbox.logs(execution_id=result.execution_id) + + assert logs is not None + assert hasattr(logs, "logs") + assert isinstance(logs.logs, list) + log_content = " ".join(str(log) for log in logs.logs) + assert test_message in log_content + + @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT) + def test_logs_with_shell_name_filter(self, shared_devbox: Devbox) -> None: + """Test retrieving devbox logs filtered by shell name.""" + shell_name = "test-logs-shell" + shell = shared_devbox.shell(shell_name) + + test_message = "shell log test" + result = shell.exec(f'echo "{test_message}"') + assert result.exit_code == 0 + + logs = shared_devbox.logs(shell_name=shell_name) + + assert logs is not None + assert hasattr(logs, "logs") + assert isinstance(logs.logs, list) + log_content = " ".join(str(log) for log in logs.logs) + assert test_message in log_content From f13916a570fe708aef56c6fbfd5c06b9e0dc158e Mon Sep 17 00:00:00 2001 From: james-rl Date: Thu, 5 Mar 2026 16:10:04 -0800 Subject: [PATCH 6/8] feat: add snapshots example (#753) --- EXAMPLES.md | 32 +++++++ examples/devbox_snapshot_resume.py | 134 +++++++++++++++++++++++++++++ examples/registry.py | 8 ++ llms.txt | 1 + 4 files changed, 175 insertions(+) create mode 100644 examples/devbox_snapshot_resume.py diff --git a/EXAMPLES.md b/EXAMPLES.md index faa08edd4..eb1222c9c 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -9,6 +9,7 @@ Runnable examples live in [`examples/`](./examples). - [Blueprint with Build Context](#blueprint-with-build-context) - [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle) +- [Devbox Snapshot and Resume](#devbox-snapshot-resume) - [MCP Hub + Claude Code + GitHub](#mcp-github-tools) @@ -72,6 +73,37 @@ uv run pytest -m smoketest tests/smoketests/examples/ **Source:** [`examples/devbox_from_blueprint_lifecycle.py`](./examples/devbox_from_blueprint_lifecycle.py) + +## Devbox Snapshot and Resume + +**Use case:** Create a devbox, snapshot its disk, resume from the snapshot, and demonstrate that changes in the original devbox do not affect the clone. Uses the async SDK. + +**Tags:** `devbox`, `snapshot`, `resume`, `cleanup`, `async` + +### Workflow +- Create a devbox +- Write a file to the devbox +- Create a disk snapshot +- Create a new devbox from the snapshot +- Modify the file on the original devbox +- Verify the clone has the original content +- Shutdown both devboxes and delete the snapshot + +### Prerequisites +- `RUNLOOP_API_KEY` + +### Run +```sh +uv run python -m examples.devbox_snapshot_resume +``` + +### Test +```sh +uv run pytest -m smoketest tests/smoketests/examples/ +``` + +**Source:** [`examples/devbox_snapshot_resume.py`](./examples/devbox_snapshot_resume.py) + ## MCP Hub + Claude Code + GitHub diff --git a/examples/devbox_snapshot_resume.py b/examples/devbox_snapshot_resume.py new file mode 100644 index 000000000..6913731b2 --- /dev/null +++ b/examples/devbox_snapshot_resume.py @@ -0,0 +1,134 @@ +#!/usr/bin/env -S uv run python +""" +--- +title: Devbox Snapshot and Resume +slug: devbox-snapshot-resume +use_case: Create a devbox, snapshot its disk, resume from the snapshot, and demonstrate that changes in the original devbox do not affect the clone. Uses the async SDK. +workflow: + - Create a devbox + - Write a file to the devbox + - Create a disk snapshot + - Create a new devbox from the snapshot + - Modify the file on the original devbox + - Verify the clone has the original content + - Shutdown both devboxes and delete the snapshot +tags: + - devbox + - snapshot + - resume + - cleanup + - async +prerequisites: + - RUNLOOP_API_KEY +run: uv run python -m examples.devbox_snapshot_resume +test: uv run pytest -m smoketest tests/smoketests/examples/ +--- +""" + +from __future__ import annotations + +from runloop_api_client import AsyncRunloopSDK + +from ._harness import run_as_cli, wrap_recipe +from .example_types import ExampleCheck, RecipeOutput, RecipeContext + +FILE_PATH = "/home/user/welcome.txt" +ORIGINAL_CONTENT = "hello world!" +MODIFIED_CONTENT = "original devbox has changed the welcome message" + + +async def recipe(ctx: RecipeContext) -> RecipeOutput: + """Create a devbox, snapshot it, resume from snapshot, and verify state isolation.""" + cleanup = ctx.cleanup + + sdk = AsyncRunloopSDK() + + # Create a devbox + dbx_original = await sdk.devbox.create( + name="dbx_original", + launch_parameters={ + "resource_size_request": "X_SMALL", + "keep_alive_time_seconds": 60 * 5, + }, + ) + cleanup.add(f"devbox:{dbx_original.id}", dbx_original.shutdown) + + # Write a file to the original devbox + await dbx_original.file.write(file_path=FILE_PATH, contents=ORIGINAL_CONTENT) + + # Read and display the file contents + cat_original_before = await dbx_original.cmd.exec(f"cat {FILE_PATH}") + original_content_before = await cat_original_before.stdout() + + # Create a disk snapshot of the original devbox + snapshot = await dbx_original.snapshot_disk(name="my-snapshot") + cleanup.add(f"snapshot:{snapshot.id}", snapshot.delete) + + # Create a new devbox from the snapshot + dbx_clone = await sdk.devbox.create_from_snapshot( + snapshot.id, + name="dbx_clone", + launch_parameters={ + "resource_size_request": "X_SMALL", + "keep_alive_time_seconds": 60 * 5, + }, + ) + cleanup.add(f"devbox:{dbx_clone.id}", dbx_clone.shutdown) + + # Modify the file on the original devbox + await dbx_original.file.write(file_path=FILE_PATH, contents=MODIFIED_CONTENT) + + # Read the file contents from both devboxes + cat_clone = await dbx_clone.cmd.exec(f"cat {FILE_PATH}") + clone_content = await cat_clone.stdout() + + # now the original devbox has been modified but the clone has the original message + cat_original_after = await dbx_original.cmd.exec(f"cat {FILE_PATH}") + original_content_after = await cat_original_after.stdout() + + return RecipeOutput( + resources_created=[ + f"devbox:{dbx_original.id}", + f"snapshot:{snapshot.id}", + f"devbox:{dbx_clone.id}", + ], + checks=[ + ExampleCheck( + name="original devbox file created successfully", + passed=cat_original_before.exit_code == 0 and original_content_before.strip() == ORIGINAL_CONTENT, + details=f'content="{original_content_before.strip()}"', + ), + ExampleCheck( + name="snapshot created successfully", + passed=bool(snapshot.id), + details=f"snapshotId={snapshot.id}", + ), + ExampleCheck( + name="clone devbox created from snapshot", + passed=bool(dbx_clone.id), + details=f"cloneId={dbx_clone.id}", + ), + ExampleCheck( + name="clone has original file content (before modification)", + passed=cat_clone.exit_code == 0 and clone_content.strip() == ORIGINAL_CONTENT, + details=f'cloneContent="{clone_content.strip()}"', + ), + ExampleCheck( + name="original devbox has modified content", + passed=cat_original_after.exit_code == 0 and original_content_after.strip() == MODIFIED_CONTENT, + details=f'originalContent="{original_content_after.strip()}"', + ), + ExampleCheck( + name="clone and original have divergent state", + passed=clone_content.strip() != original_content_after.strip(), + details=f'clone="{clone_content.strip()}" vs original="{original_content_after.strip()}"', + ), + ], + ) + + +run_devbox_snapshot_resume_example = wrap_recipe(recipe) + + +if __name__ == "__main__": + run_as_cli(run_devbox_snapshot_resume_example) diff --git a/examples/registry.py b/examples/registry.py index cb6b780a9..cde21979d 100644 --- a/examples/registry.py +++ b/examples/registry.py @@ -9,6 +9,7 @@ from .example_types import ExampleResult from .mcp_github_tools import run_mcp_github_tools_example +from .devbox_snapshot_resume import run_devbox_snapshot_resume_example from .blueprint_with_build_context import run_blueprint_with_build_context_example from .devbox_from_blueprint_lifecycle import run_devbox_from_blueprint_lifecycle_example @@ -29,6 +30,13 @@ "required_env": ["RUNLOOP_API_KEY"], "run": run_devbox_from_blueprint_lifecycle_example, }, + { + "slug": "devbox-snapshot-resume", + "title": "Devbox Snapshot and Resume", + "file_name": "devbox_snapshot_resume.py", + "required_env": ["RUNLOOP_API_KEY"], + "run": run_devbox_snapshot_resume_example, + }, { "slug": "mcp-github-tools", "title": "MCP Hub + Claude Code + GitHub", diff --git a/llms.txt b/llms.txt index ec1e662bf..ed9c684a8 100644 --- a/llms.txt +++ b/llms.txt @@ -11,6 +11,7 @@ ## Core Patterns - [Devbox lifecycle example](examples/devbox_from_blueprint_lifecycle.py): Create blueprint, launch devbox, run commands, cleanup +- [Devbox snapshot and resume example](examples/devbox_snapshot_resume.py): Snapshot disk, resume from snapshot, verify state isolation - [MCP GitHub example](examples/mcp_github_tools.py): MCP Hub integration with Claude Code ## API Reference From c2958c51f05e55abb9edf225546cd4548c91e4db Mon Sep 17 00:00:00 2001 From: Alexander Dines <160077924+dines-rl@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:56:04 -0700 Subject: [PATCH 7/8] Fix frozen command_id default causing concurrent execution stdout corruption (#757) --- .../resources/devboxes/devboxes.py | 24 +++-- tests/test_command_id.py | 94 +++++++++++++++++++ 2 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 tests/test_command_id.py diff --git a/src/runloop_api_client/resources/devboxes/devboxes.py b/src/runloop_api_client/resources/devboxes/devboxes.py index de809b855..dbd02be20 100644 --- a/src/runloop_api_client/resources/devboxes/devboxes.py +++ b/src/runloop_api_client/resources/devboxes/devboxes.py @@ -867,7 +867,7 @@ def execute( id: str, *, command: str, - command_id: str = str(uuid7()), + command_id: str | None = None, last_n: str | Omit = omit, optimistic_timeout: Optional[int] | Omit = omit, shell_name: Optional[str] | Omit = omit, @@ -892,7 +892,8 @@ def execute( specified the command is run from the directory based on the recent state of the persistent shell. - command_id: The command ID in UUIDv7 string format for idempotency and tracking + command_id: The command ID in UUIDv7 string format for idempotency and tracking. + A fresh UUID is generated per call if not provided. last_n: Last n lines of standard error / standard out to return (default: 100) @@ -915,6 +916,8 @@ def execute( """ if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + if command_id is None: + command_id = str(uuid7()) if not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT: timeout = 600 return self._post( @@ -944,7 +947,7 @@ def execute_and_await_completion( devbox_id: str, *, command: str, - command_id: str = str(uuid7()), + command_id: str | None = None, last_n: str | Omit = omit, optimistic_timeout: Optional[int] | Omit = omit, shell_name: Optional[str] | Omit = omit, @@ -963,9 +966,11 @@ def execute_and_await_completion( return the result within the initial request's timeout. If the execution is not yet complete, it switches to using wait_for_command to minimize latency while waiting. - A command_id (UUIDv7) is automatically generated for idempotency and tracking. + A command_id (UUIDv7) is automatically generated per call for idempotency and tracking. You can provide your own command_id to enable custom retry logic or external tracking. """ + if command_id is None: + command_id = str(uuid7()) execution = self.execute( devbox_id, command=command, @@ -2543,7 +2548,7 @@ async def execute( id: str, *, command: str, - command_id: str = str(uuid7()), + command_id: str | None = None, last_n: str | Omit = omit, optimistic_timeout: Optional[int] | Omit = omit, shell_name: Optional[str] | Omit = omit, @@ -2568,7 +2573,8 @@ async def execute( specified the command is run from the directory based on the recent state of the persistent shell. - command_id: The command ID in UUIDv7 string format for idempotency and tracking + command_id: The command ID in UUIDv7 string format for idempotency and tracking. + A fresh UUID is generated per call if not provided. last_n: Last n lines of standard error / standard out to return (default: 100) @@ -2591,6 +2597,8 @@ async def execute( """ if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + if command_id is None: + command_id = str(uuid7()) if not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT: timeout = 600 return await self._post( @@ -2620,7 +2628,7 @@ async def execute_and_await_completion( devbox_id: str, *, command: str, - command_id: str = str(uuid7()), + command_id: str | None = None, last_n: str | Omit = omit, optimistic_timeout: Optional[int] | Omit = omit, shell_name: Optional[str] | Omit = omit, @@ -2639,7 +2647,7 @@ async def execute_and_await_completion( return the result within the initial request's timeout. If the execution is not yet complete, it switches to using wait_for_command to minimize latency while waiting. - A command_id (UUIDv7) is automatically generated for idempotency and tracking. + A command_id (UUIDv7) is automatically generated per call for idempotency and tracking. You can provide your own command_id to enable custom retry logic or external tracking. """ diff --git a/tests/test_command_id.py b/tests/test_command_id.py new file mode 100644 index 000000000..92089d28a --- /dev/null +++ b/tests/test_command_id.py @@ -0,0 +1,94 @@ +"""Tests for command_id default generation in execute / execute_and_await_completion. + +Verifies that each call generates a fresh UUIDv7 rather than reusing a frozen +default (the bug fixed in this change). +""" + +from __future__ import annotations + +import json +from typing import cast + +import httpx +import pytest +from respx import Route, MockRouter + +from runloop_api_client import Runloop, AsyncRunloop + +base_url = "http://127.0.0.1:4010" +EXECUTE_PATH = "/v1/devboxes/dbx_test/execute" + +STUB_RESPONSE = { + "execution_id": "exec_1", + "command_id": "ignored", + "devbox_id": "dbx_test", + "status": "completed", + "exit_status": 0, + "stdout": "", + "stderr": "", +} + + +def _get_command_ids(route: Route) -> list[str]: + return [ + json.loads(cast(bytes, call.request.content))["command_id"] # type: ignore[union-attr] + for call in route.calls # type: ignore[union-attr] + ] + + +def _get_request_body(route: Route, index: int = 0) -> dict[str, object]: + return json.loads(cast(bytes, route.calls[index].request.content)) # type: ignore[union-attr] + + +class TestCommandIdGeneration: + """command_id must be a fresh UUIDv7 per call when not explicitly provided.""" + + @pytest.mark.respx(base_url=base_url) + def test_execute_generates_unique_command_ids(self, respx_mock: MockRouter) -> None: + route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE)) + client = Runloop(base_url=base_url, bearer_token="test") + + for _ in range(5): + client.devboxes.execute(id="dbx_test", command="echo hi") + + assert route.call_count == 5 + ids = _get_command_ids(route) + assert len(set(ids)) == 5, f"command_ids should all be unique, got: {ids}" + + @pytest.mark.respx(base_url=base_url) + def test_execute_preserves_explicit_command_id(self, respx_mock: MockRouter) -> None: + route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE)) + client = Runloop(base_url=base_url, bearer_token="test") + + client.devboxes.execute(id="dbx_test", command="echo hi", command_id="my-custom-id") + + body = _get_request_body(route) + assert body["command_id"] == "my-custom-id" + + +class TestAsyncCommandIdGeneration: + """Async variant: command_id must be a fresh UUIDv7 per call when not explicitly provided.""" + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_execute_generates_unique_command_ids(self, respx_mock: MockRouter) -> None: + route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE)) + client = AsyncRunloop(base_url=base_url, bearer_token="test") + + for _ in range(5): + await client.devboxes.execute(id="dbx_test", command="echo hi") + + assert route.call_count == 5 + ids = _get_command_ids(route) + assert len(set(ids)) == 5, f"command_ids should all be unique, got: {ids}" + + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_execute_preserves_explicit_command_id(self, respx_mock: MockRouter) -> None: + route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE)) + client = AsyncRunloop(base_url=base_url, bearer_token="test") + + await client.devboxes.execute(id="dbx_test", command="echo hi", command_id="my-custom-id") + + body = _get_request_body(route) + assert body["command_id"] == "my-custom-id" From 84286820c765e7a94ac37fb1186f26e3411c48c5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:05:56 +0000 Subject: [PATCH 8/8] release: 1.11.0 (#749) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 8 ++++++-- .release-please-manifest.json | 2 +- CHANGELOG.md | 22 ++++++++++++++++++++++ pyproject.toml | 2 +- scripts/mock | 13 ++++++++++++- src/runloop_api_client/_version.py | 2 +- tests/api_resources/test_devboxes.py | 4 ++-- 7 files changed, 45 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e5b32a4c..8c3962610 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,14 +58,18 @@ jobs: run: uv build - name: Get GitHub OIDC Token - if: github.repository == 'stainless-sdks/runloop-python' + if: |- + github.repository == 'stainless-sdks/runloop-python' && + !startsWith(github.ref, 'refs/heads/stl/') id: github-oidc uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); - name: Upload tarball - if: github.repository == 'stainless-sdks/runloop-python' + if: |- + github.repository == 'stainless-sdks/runloop-python' && + !startsWith(github.ref, 'refs/heads/stl/') env: URL: https://pkg.stainless.com/s AUTH: ${{ steps.github-oidc.outputs.github_token }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 083df00ef..caf148712 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.10.3" + ".": "1.11.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bb2c9f22c..1a11183b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 1.11.0 (2026-03-10) + +Full Changelog: [v1.10.3...v1.11.0](https://github.com/runloopai/api-client-python/compare/v1.10.3...v1.11.0) + +### Features + +* add snapshots example ([#753](https://github.com/runloopai/api-client-python/issues/753)) ([f13916a](https://github.com/runloopai/api-client-python/commit/f13916a570fe708aef56c6fbfd5c06b9e0dc158e)) +* **documentation:** added llms.txt, examples and referenced these artifacts via README ([#748](https://github.com/runloopai/api-client-python/issues/748)) ([bceb953](https://github.com/runloopai/api-client-python/commit/bceb953c8486bec6bbe5bca1276ed54d625eda5d)) + + +### Bug Fixes + +* add logs to devboxes, smoke tests & examples ([#755](https://github.com/runloopai/api-client-python/issues/755)) ([da5faa4](https://github.com/runloopai/api-client-python/commit/da5faa4e69348dd6f034a538046ab9ff2ff4831b)) + + +### Chores + +* **ci:** skip uploading artifacts on stainless-internal branches ([6874252](https://github.com/runloopai/api-client-python/commit/6874252fe355ac8a44be6bc57cd6bb15a3f035be)) +* **documentation:** correct exec advice ([#752](https://github.com/runloopai/api-client-python/issues/752)) ([fa16c1d](https://github.com/runloopai/api-client-python/commit/fa16c1d602ec301200e82a48fe23c2218ae82a08)) +* **test:** do not count install time for mock server timeout ([f6fde05](https://github.com/runloopai/api-client-python/commit/f6fde050a5f4b985c01003cd7593c1619ede028f)) +* update placeholder string ([5dfcd93](https://github.com/runloopai/api-client-python/commit/5dfcd93bb05cda783901377ee7fc03a6acffe0ae)) + ## 1.10.3 (2026-02-27) Full Changelog: [v1.10.2...v1.10.3](https://github.com/runloopai/api-client-python/compare/v1.10.2...v1.10.3) diff --git a/pyproject.toml b/pyproject.toml index e80f1b5ea..6b7225899 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "runloop_api_client" -version = "1.10.3" +version = "1.11.0" description = "The official Python library for the runloop API" dynamic = ["readme"] license = "MIT" diff --git a/scripts/mock b/scripts/mock index 0b28f6ea2..bcf3b392b 100755 --- a/scripts/mock +++ b/scripts/mock @@ -21,11 +21,22 @@ echo "==> Starting mock server with URL ${URL}" # Run prism mock on the given spec if [ "$1" == "--daemon" ]; then + # Pre-install the package so the download doesn't eat into the startup timeout + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & - # Wait for server to come online + # Wait for server to come online (max 30s) echo -n "Waiting for server" + attempts=0 while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 300 ]; then + echo + echo "Timed out waiting for Prism server to start" + cat .prism.log + exit 1 + fi echo -n "." sleep 0.1 done diff --git a/src/runloop_api_client/_version.py b/src/runloop_api_client/_version.py index 8592b7e83..93214b609 100644 --- a/src/runloop_api_client/_version.py +++ b/src/runloop_api_client/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "runloop_api_client" -__version__ = "1.10.3" # x-release-please-version +__version__ = "1.11.0" # x-release-please-version diff --git a/tests/api_resources/test_devboxes.py b/tests/api_resources/test_devboxes.py index 7f83732a0..1e25ced15 100644 --- a/tests/api_resources/test_devboxes.py +++ b/tests/api_resources/test_devboxes.py @@ -1087,7 +1087,7 @@ def test_method_upload_file_with_all_params(self, client: Runloop) -> None: devbox = client.devboxes.upload_file( id="id", path="path", - file=b"raw file contents", + file=b"Example data", ) assert_matches_type(object, devbox, path=["response"]) @@ -2755,7 +2755,7 @@ async def test_method_upload_file_with_all_params(self, async_client: AsyncRunlo devbox = await async_client.devboxes.upload_file( id="id", path="path", - file=b"raw file contents", + file=b"Example data", ) assert_matches_type(object, devbox, path=["response"])