From 12f26461053f97e4bcec4a2265f249b28f2a1ff4 Mon Sep 17 00:00:00 2001 From: James Chainey Date: Mon, 2 Mar 2026 13:12:14 -0800 Subject: [PATCH 1/6] added llms.txt, examples and referenced these artifacts via README --- .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 | 178 +++++++++++++ examples/devbox_from_blueprint_lifecycle.py | 90 +++++++ examples/mcp_github_tools.py | 191 ++++++++++++++ examples/registry.py | 39 +++ examples/types.py | 69 ++++++ llms.txt | 57 +++++ pyproject.toml | 1 + scripts/generate_examples_md.py | 262 ++++++++++++++++++++ tests/smoketests/examples/__init__.py | 1 + tests/smoketests/examples/test_examples.py | 106 ++++++++ uv.lock | 89 ++++++- 17 files changed, 1179 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/mcp_github_tools.py create mode 100644 examples/registry.py create mode 100644 examples/types.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..fd3a06a26 --- /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 examples/devbox_from_blueprint_lifecycle.py +``` + +### 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 examples/mcp_github_tools.py +``` + +### 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..147d49859 --- /dev/null +++ b/examples/_harness.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import sys +import json +import asyncio +from typing import Any, TypeVar, Callable, Awaitable +from dataclasses import asdict + +from .types import ( + ExampleCheck, + RecipeOutput, + ExampleResult, + RecipeContext, + ExampleCleanupStatus, + ExampleCleanupFailure, + empty_cleanup_status, +) + +T = TypeVar("T") + + +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 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 = empty_cleanup_status() + 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) + + async def _run_async() -> RecipeOutput: + try: + result = recipe(ctx) + 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() + + 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 = empty_cleanup_status() + 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) + + async def _run_async() -> RecipeOutput: + try: + result = recipe(ctx, options) + 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() + + 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..21354b53f --- /dev/null +++ b/examples/devbox_from_blueprint_lifecycle.py @@ -0,0 +1,90 @@ +#!/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 examples/devbox_from_blueprint_lifecycle.py +test: uv run pytest -m smoketest tests/smoketests/examples/ +--- +""" + +from __future__ import annotations + +import time + +from runloop_api_client import RunloopSDK +from runloop_api_client.lib.polling import PollingConfig + +from .types import ExampleCheck, RecipeOutput, RecipeContext +from ._harness import run_as_cli, wrap_recipe + + +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:]}" + + +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/mcp_github_tools.py b/examples/mcp_github_tools.py new file mode 100644 index 000000000..7e49b0fea --- /dev/null +++ b/examples/mcp_github_tools.py @@ -0,0 +1,191 @@ +#!/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 examples/mcp_github_tools.py +test: uv run pytest -m smoketest tests/smoketests/examples/ +--- +""" + +from __future__ import annotations + +import os +import time +from dataclasses import dataclass + +from runloop_api_client import RunloopSDK + +from .types import ExampleCheck, RecipeOutput, RecipeContext +from ._harness import run_as_cli, wrap_recipe_with_options + + +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:]}" + + +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..ca0e9de1d --- /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 .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/examples/types.py b/examples/types.py new file mode 100644 index 000000000..514d1689e --- /dev/null +++ b/examples/types.py @@ -0,0 +1,69 @@ +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 + + +def empty_cleanup_status() -> ExampleCleanupStatus: + """Create an empty cleanup status.""" + return ExampleCleanupStatus(attempted=[], succeeded=[], failed=[]) diff --git a/llms.txt b/llms.txt new file mode 100644 index 000000000..4890e555f --- /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`, `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..d7857e2af --- /dev/null +++ b/scripts/generate_examples_md.py @@ -0,0 +1,262 @@ +#!/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", "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) + + if f"examples/{file_name}" not in metadata["run"]: + raise ValueError(f"{path}: run command must reference the file") + + +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 .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..00974932f --- /dev/null +++ b/tests/smoketests/examples/test_examples.py @@ -0,0 +1,106 @@ +"""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 + +import pytest + +# Add the root directory to the path so we can import examples +sys.path.insert(0, str(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))))) + +from examples.types import ExampleResult # noqa: E402 +from examples.registry import example_registry # 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 c47383d638f861779dd92b309e69c0414c15c5dc Mon Sep 17 00:00:00 2001 From: James Chainey Date: Mon, 2 Mar 2026 13:20:41 -0800 Subject: [PATCH 2/6] made path injection less ugly --- tests/smoketests/examples/test_examples.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/smoketests/examples/test_examples.py b/tests/smoketests/examples/test_examples.py index 00974932f..0094c4496 100644 --- a/tests/smoketests/examples/test_examples.py +++ b/tests/smoketests/examples/test_examples.py @@ -8,12 +8,13 @@ import os import sys +from pathlib import Path from typing import Any import pytest # Add the root directory to the path so we can import examples -sys.path.insert(0, str(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))))) +sys.path.insert(0, str(Path(__file__).parents[3])) from examples.types import ExampleResult # noqa: E402 from examples.registry import example_registry # noqa: E402 From 809ebf6c1f929c6898b95d5a5cb29a54405b4271 Mon Sep 17 00:00:00 2001 From: James Chainey Date: Mon, 2 Mar 2026 13:23:15 -0800 Subject: [PATCH 3/6] lint --- tests/smoketests/examples/test_examples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/smoketests/examples/test_examples.py b/tests/smoketests/examples/test_examples.py index 0094c4496..b47bcfc3c 100644 --- a/tests/smoketests/examples/test_examples.py +++ b/tests/smoketests/examples/test_examples.py @@ -8,8 +8,8 @@ import os import sys -from pathlib import Path from typing import Any +from pathlib import Path import pytest From 00b7da4124be4b67f0d0ab0b150fc72c6aaa4381 Mon Sep 17 00:00:00 2001 From: James Chainey Date: Mon, 2 Mar 2026 14:22:19 -0800 Subject: [PATCH 4/6] fixed name issue when running examples (thanks sid!) --- EXAMPLES.md | 4 ++-- examples/_harness.py | 2 +- examples/devbox_from_blueprint_lifecycle.py | 4 ++-- examples/{types.py => example_types.py} | 0 examples/mcp_github_tools.py | 4 ++-- examples/registry.py | 2 +- llms.txt | 2 +- scripts/generate_examples_md.py | 9 +++++---- tests/smoketests/examples/test_examples.py | 2 +- 9 files changed, 15 insertions(+), 14 deletions(-) rename examples/{types.py => example_types.py} (100%) diff --git a/EXAMPLES.md b/EXAMPLES.md index fd3a06a26..9377b08bb 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -29,7 +29,7 @@ Runnable examples live in [`examples/`](./examples). ### Run ```sh -uv run examples/devbox_from_blueprint_lifecycle.py +uv run python -m examples.devbox_from_blueprint_lifecycle ``` ### Test @@ -61,7 +61,7 @@ uv run pytest -m smoketest tests/smoketests/examples/ ### Run ```sh -GITHUB_TOKEN=ghp_xxx ANTHROPIC_API_KEY=sk-ant-xxx uv run examples/mcp_github_tools.py +GITHUB_TOKEN=ghp_xxx ANTHROPIC_API_KEY=sk-ant-xxx uv run python -m examples.mcp_github_tools ``` ### Test diff --git a/examples/_harness.py b/examples/_harness.py index 147d49859..5d2c2c93b 100644 --- a/examples/_harness.py +++ b/examples/_harness.py @@ -6,7 +6,7 @@ from typing import Any, TypeVar, Callable, Awaitable from dataclasses import asdict -from .types import ( +from .example_types import ( ExampleCheck, RecipeOutput, ExampleResult, diff --git a/examples/devbox_from_blueprint_lifecycle.py b/examples/devbox_from_blueprint_lifecycle.py index 21354b53f..dfe2be8f1 100644 --- a/examples/devbox_from_blueprint_lifecycle.py +++ b/examples/devbox_from_blueprint_lifecycle.py @@ -17,7 +17,7 @@ - cleanup prerequisites: - RUNLOOP_API_KEY -run: uv run examples/devbox_from_blueprint_lifecycle.py +run: uv run python -m examples.devbox_from_blueprint_lifecycle test: uv run pytest -m smoketest tests/smoketests/examples/ --- """ @@ -29,7 +29,7 @@ from runloop_api_client import RunloopSDK from runloop_api_client.lib.polling import PollingConfig -from .types import ExampleCheck, RecipeOutput, RecipeContext +from .example_types import ExampleCheck, RecipeOutput, RecipeContext from ._harness import run_as_cli, wrap_recipe diff --git a/examples/types.py b/examples/example_types.py similarity index 100% rename from examples/types.py rename to examples/example_types.py diff --git a/examples/mcp_github_tools.py b/examples/mcp_github_tools.py index 7e49b0fea..49d07df37 100644 --- a/examples/mcp_github_tools.py +++ b/examples/mcp_github_tools.py @@ -21,7 +21,7 @@ - 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 examples/mcp_github_tools.py +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/ --- """ @@ -34,7 +34,7 @@ from runloop_api_client import RunloopSDK -from .types import ExampleCheck, RecipeOutput, RecipeContext +from .example_types import ExampleCheck, RecipeOutput, RecipeContext from ._harness import run_as_cli, wrap_recipe_with_options diff --git a/examples/registry.py b/examples/registry.py index ca0e9de1d..41a4b4b51 100644 --- a/examples/registry.py +++ b/examples/registry.py @@ -7,7 +7,7 @@ from typing import Any, Callable, cast -from .types import ExampleResult +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 diff --git a/llms.txt b/llms.txt index 4890e555f..6683b6554 100644 --- a/llms.txt +++ b/llms.txt @@ -26,7 +26,7 @@ - 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`, `types.py`) +- 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 diff --git a/scripts/generate_examples_md.py b/scripts/generate_examples_md.py index d7857e2af..bf4dc587a 100644 --- a/scripts/generate_examples_md.py +++ b/scripts/generate_examples_md.py @@ -23,7 +23,7 @@ LLMS_FILE = ROOT / "llms.txt" REQUIRED_FIELDS = ["title", "slug", "use_case", "workflow", "tags", "prerequisites", "run", "test"] -EXCLUDED_FILES = {"_harness.py", "types.py", "registry.py", "__init__.py"} +EXCLUDED_FILES = {"_harness.py", "example_types.py", "registry.py", "__init__.py"} def parse_example(path: Path) -> dict[str, Any]: @@ -64,8 +64,9 @@ def validate_example(metadata: dict[str, Any], file_name: str, seen_slugs: set[s raise ValueError(f"{path}: duplicate slug") seen_slugs.add(slug) - if f"examples/{file_name}" not in metadata["run"]: - raise ValueError(f"{path}: run command must reference the file") + 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: @@ -180,7 +181,7 @@ def generate_registry(examples: list[dict[str, Any]]) -> str: from typing import Any, Callable, cast -from .types import ExampleResult +from .example_types import ExampleResult {chr(10).join(imports)} ExampleRegistryEntry = dict[str, Any] diff --git a/tests/smoketests/examples/test_examples.py b/tests/smoketests/examples/test_examples.py index b47bcfc3c..7dbe82581 100644 --- a/tests/smoketests/examples/test_examples.py +++ b/tests/smoketests/examples/test_examples.py @@ -16,8 +16,8 @@ # Add the root directory to the path so we can import examples sys.path.insert(0, str(Path(__file__).parents[3])) -from examples.types import ExampleResult # noqa: E402 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 From c917d0b09030314640dbbc860fdde028fccc084d Mon Sep 17 00:00:00 2001 From: James Chainey Date: Mon, 2 Mar 2026 14:25:42 -0800 Subject: [PATCH 5/6] lint --- examples/devbox_from_blueprint_lifecycle.py | 2 +- examples/mcp_github_tools.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/devbox_from_blueprint_lifecycle.py b/examples/devbox_from_blueprint_lifecycle.py index dfe2be8f1..56875076c 100644 --- a/examples/devbox_from_blueprint_lifecycle.py +++ b/examples/devbox_from_blueprint_lifecycle.py @@ -29,8 +29,8 @@ from runloop_api_client import RunloopSDK from runloop_api_client.lib.polling import PollingConfig -from .example_types import ExampleCheck, RecipeOutput, RecipeContext from ._harness import run_as_cli, wrap_recipe +from .example_types import ExampleCheck, RecipeOutput, RecipeContext def unique_name(prefix: str) -> str: diff --git a/examples/mcp_github_tools.py b/examples/mcp_github_tools.py index 49d07df37..bd89c67b3 100644 --- a/examples/mcp_github_tools.py +++ b/examples/mcp_github_tools.py @@ -34,8 +34,8 @@ from runloop_api_client import RunloopSDK -from .example_types import ExampleCheck, RecipeOutput, RecipeContext from ._harness import run_as_cli, wrap_recipe_with_options +from .example_types import ExampleCheck, RecipeOutput, RecipeContext def unique_name(prefix: str) -> str: From ca5c0b8fbd88b0e4e6d3417e94690c8daf6c2875 Mon Sep 17 00:00:00 2001 From: James Chainey Date: Mon, 2 Mar 2026 15:29:00 -0800 Subject: [PATCH 6/6] some deduping --- examples/_harness.py | 84 ++++++++++----------- examples/devbox_from_blueprint_lifecycle.py | 10 +-- examples/example_types.py | 5 -- examples/mcp_github_tools.py | 9 +-- 4 files changed, 41 insertions(+), 67 deletions(-) diff --git a/examples/_harness.py b/examples/_harness.py index 5d2c2c93b..dec6dfdf4 100644 --- a/examples/_harness.py +++ b/examples/_harness.py @@ -2,6 +2,7 @@ import sys import json +import time import asyncio from typing import Any, TypeVar, Callable, Awaitable from dataclasses import asdict @@ -13,12 +14,16 @@ RecipeContext, ExampleCleanupStatus, ExampleCleanupFailure, - empty_cleanup_status, ) 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.""" @@ -56,6 +61,35 @@ def _should_fail_process(result: ExampleResult) -> bool: 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, @@ -72,7 +106,7 @@ def wrap_recipe( """ def run() -> ExampleResult: - cleanup_status = empty_cleanup_status() + cleanup_status = ExampleCleanupStatus() cleanup = _CleanupTracker(cleanup_status) if validate_env is not None: @@ -86,27 +120,7 @@ def run() -> ExampleResult: ) ctx = RecipeContext(cleanup=cleanup) - - async def _run_async() -> RecipeOutput: - try: - result = recipe(ctx) - 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() + return _run_recipe_impl(lambda: recipe(ctx), cleanup, cleanup_status) return run @@ -127,7 +141,7 @@ def wrap_recipe_with_options( """ def run(options: T) -> ExampleResult: - cleanup_status = empty_cleanup_status() + cleanup_status = ExampleCleanupStatus() cleanup = _CleanupTracker(cleanup_status) if validate_env is not None: @@ -141,27 +155,7 @@ def run(options: T) -> ExampleResult: ) ctx = RecipeContext(cleanup=cleanup) - - async def _run_async() -> RecipeOutput: - try: - result = recipe(ctx, options) - 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() + return _run_recipe_impl(lambda: recipe(ctx, options), cleanup, cleanup_status) return run diff --git a/examples/devbox_from_blueprint_lifecycle.py b/examples/devbox_from_blueprint_lifecycle.py index 56875076c..9dc98c4ed 100644 --- a/examples/devbox_from_blueprint_lifecycle.py +++ b/examples/devbox_from_blueprint_lifecycle.py @@ -24,20 +24,12 @@ from __future__ import annotations -import time - from runloop_api_client import RunloopSDK from runloop_api_client.lib.polling import PollingConfig -from ._harness import run_as_cli, wrap_recipe +from ._harness import run_as_cli, unique_name, wrap_recipe from .example_types import ExampleCheck, RecipeOutput, RecipeContext - -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:]}" - - BLUEPRINT_POLL_TIMEOUT_S = 10 * 60 diff --git a/examples/example_types.py b/examples/example_types.py index 514d1689e..46e8931c8 100644 --- a/examples/example_types.py +++ b/examples/example_types.py @@ -62,8 +62,3 @@ class RecipeContext: """Context passed to recipe functions.""" cleanup: Any # CleanupTracker, but using Any to avoid circular typing issues - - -def empty_cleanup_status() -> ExampleCleanupStatus: - """Create an empty cleanup status.""" - return ExampleCleanupStatus(attempted=[], succeeded=[], failed=[]) diff --git a/examples/mcp_github_tools.py b/examples/mcp_github_tools.py index bd89c67b3..b2850c7af 100644 --- a/examples/mcp_github_tools.py +++ b/examples/mcp_github_tools.py @@ -29,20 +29,13 @@ from __future__ import annotations import os -import time from dataclasses import dataclass from runloop_api_client import RunloopSDK -from ._harness import run_as_cli, wrap_recipe_with_options +from ._harness import run_as_cli, unique_name, wrap_recipe_with_options from .example_types import ExampleCheck, RecipeOutput, RecipeContext - -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:]}" - - GITHUB_MCP_ENDPOINT = "https://api.githubcopilot.com/mcp/"