Skip to content

Commit 12f2646

Browse files
committed
added llms.txt, examples and referenced these artifacts via README
1 parent 2489242 commit 12f2646

17 files changed

Lines changed: 1179 additions & 5 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ jobs:
3232
- name: Run lints
3333
run: ./scripts/lint
3434

35+
- name: Verify generated examples docs
36+
run: uv run python scripts/generate_examples_md.py --check
37+
3538
build:
3639
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
3740
timeout-minutes: 10

.github/workflows/smoketests.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ jobs:
4545
fi
4646
echo "DEBUG=false" >> $GITHUB_ENV
4747
echo "PYTHONPATH=${{ github.workspace }}/src" >> $GITHUB_ENV
48+
echo "RUN_EXAMPLE_LIVE_TESTS=1" >> $GITHUB_ENV
49+
50+
- name: Verify generated example artifacts
51+
run: uv run python scripts/generate_examples_md.py --check
4852

4953
- name: Run smoke tests (pytest via uv)
5054
env:

EXAMPLES.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Examples
2+
3+
> This file is auto-generated from metadata in `examples/*.py`.
4+
> Do not edit this file manually. Run `uv run python scripts/generate_examples_md.py` instead.
5+
6+
Runnable examples live in [`examples/`](./examples).
7+
8+
## Table of Contents
9+
10+
- [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle)
11+
- [MCP Hub + Claude Code + GitHub](#mcp-github-tools)
12+
13+
<a id="devbox-from-blueprint-lifecycle"></a>
14+
## Devbox From Blueprint (Run Command, Shutdown)
15+
16+
**Use case:** Create a devbox from a blueprint, run a command, validate output, and cleanly tear everything down.
17+
18+
**Tags:** `devbox`, `blueprint`, `commands`, `cleanup`
19+
20+
### Workflow
21+
- Create a blueprint
22+
- Create a devbox from the blueprint
23+
- Execute a command in the devbox
24+
- Validate exit code and stdout
25+
- Shutdown devbox and delete blueprint
26+
27+
### Prerequisites
28+
- `RUNLOOP_API_KEY`
29+
30+
### Run
31+
```sh
32+
uv run examples/devbox_from_blueprint_lifecycle.py
33+
```
34+
35+
### Test
36+
```sh
37+
uv run pytest -m smoketest tests/smoketests/examples/
38+
```
39+
40+
**Source:** [`examples/devbox_from_blueprint_lifecycle.py`](./examples/devbox_from_blueprint_lifecycle.py)
41+
42+
<a id="mcp-github-tools"></a>
43+
## MCP Hub + Claude Code + GitHub
44+
45+
**Use case:** Connect Claude Code running in a devbox to GitHub tools through MCP Hub without exposing raw GitHub credentials to the devbox.
46+
47+
**Tags:** `mcp`, `devbox`, `github`, `commands`, `cleanup`
48+
49+
### Workflow
50+
- Create an MCP config for GitHub
51+
- Store GitHub token as a Runloop secret
52+
- Launch a devbox with MCP Hub wiring
53+
- Install Claude Code and register MCP endpoint
54+
- Run a Claude prompt through MCP tools
55+
- Shutdown devbox and clean up cloud resources
56+
57+
### Prerequisites
58+
- `RUNLOOP_API_KEY`
59+
- `GITHUB_TOKEN (GitHub PAT with repo scope)`
60+
- `ANTHROPIC_API_KEY`
61+
62+
### Run
63+
```sh
64+
GITHUB_TOKEN=ghp_xxx ANTHROPIC_API_KEY=sk-ant-xxx uv run examples/mcp_github_tools.py
65+
```
66+
67+
### Test
68+
```sh
69+
uv run pytest -m smoketest tests/smoketests/examples/
70+
```
71+
72+
**Source:** [`examples/mcp_github_tools.py`](./examples/mcp_github_tools.py)

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,23 @@ asyncio.run(main())
8888

8989
Functionality between the synchronous and asynchronous clients is otherwise identical.
9090

91+
## Examples
92+
93+
Workflow-oriented runnable examples are documented in [`EXAMPLES.md`](./EXAMPLES.md).
94+
95+
`EXAMPLES.md` is generated from metadata in `examples/*.py` and should not be edited manually.
96+
Regenerate it with:
97+
98+
```sh
99+
uv run python scripts/generate_examples_md.py
100+
```
101+
102+
## Agent Guidance
103+
104+
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).
105+
106+
After completing any modifications to this project, ensure `llms.txt` and `README.md` are kept in sync.
107+
91108
### With aiohttp
92109

93110
By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend.

examples/.keep

Lines changed: 0 additions & 4 deletions
This file was deleted.

examples/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Examples package for Runloop Python SDK

examples/_harness.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
import json
5+
import asyncio
6+
from typing import Any, TypeVar, Callable, Awaitable
7+
from dataclasses import asdict
8+
9+
from .types import (
10+
ExampleCheck,
11+
RecipeOutput,
12+
ExampleResult,
13+
RecipeContext,
14+
ExampleCleanupStatus,
15+
ExampleCleanupFailure,
16+
empty_cleanup_status,
17+
)
18+
19+
T = TypeVar("T")
20+
21+
22+
class _CleanupTracker:
23+
"""Tracks cleanup actions and executes them in LIFO order."""
24+
25+
def __init__(self, status: ExampleCleanupStatus) -> None:
26+
self._status = status
27+
self._actions: list[tuple[str, Callable[[], Any]]] = []
28+
29+
def add(self, resource: str, action: Callable[[], Any]) -> None:
30+
"""Register a cleanup action for a resource."""
31+
self._actions.append((resource, action))
32+
33+
async def run(self) -> None:
34+
"""Execute all cleanup actions in reverse order."""
35+
while self._actions:
36+
resource, action = self._actions.pop()
37+
self._status.attempted.append(resource)
38+
try:
39+
result = action()
40+
if asyncio.iscoroutine(result):
41+
await result
42+
self._status.succeeded.append(resource)
43+
except Exception as e:
44+
self._status.failed.append(ExampleCleanupFailure(resource, str(e)))
45+
46+
if self._status.attempted:
47+
if not self._status.failed:
48+
print("Cleanup completed.") # noqa: T201
49+
else:
50+
print("Cleanup finished with errors.") # noqa: T201
51+
52+
53+
def _should_fail_process(result: ExampleResult) -> bool:
54+
"""Determine if the process should exit with failure."""
55+
has_failed_checks = any(not check.passed for check in result.checks)
56+
return result.skipped or has_failed_checks or len(result.cleanup_status.failed) > 0
57+
58+
59+
def wrap_recipe(
60+
recipe: Callable[[RecipeContext], RecipeOutput] | Callable[[RecipeContext], Awaitable[RecipeOutput]],
61+
validate_env: Callable[[], tuple[bool, list[ExampleCheck]]] | None = None,
62+
) -> Callable[[], ExampleResult]:
63+
"""Wrap a recipe function with cleanup tracking and result handling.
64+
65+
Args:
66+
recipe: The recipe function to wrap. Can be sync or async.
67+
validate_env: Optional function to validate environment before running.
68+
Returns (skip, checks) tuple.
69+
70+
Returns:
71+
A callable that runs the recipe and returns ExampleResult.
72+
"""
73+
74+
def run() -> ExampleResult:
75+
cleanup_status = empty_cleanup_status()
76+
cleanup = _CleanupTracker(cleanup_status)
77+
78+
if validate_env is not None:
79+
skip, checks = validate_env()
80+
if skip:
81+
return ExampleResult(
82+
resources_created=[],
83+
checks=checks,
84+
cleanup_status=cleanup_status,
85+
skipped=True,
86+
)
87+
88+
ctx = RecipeContext(cleanup=cleanup)
89+
90+
async def _run_async() -> RecipeOutput:
91+
try:
92+
result = recipe(ctx)
93+
if asyncio.iscoroutine(result):
94+
output: RecipeOutput = await result
95+
return output
96+
return result # type: ignore[return-value]
97+
finally:
98+
await cleanup.run()
99+
100+
loop = asyncio.new_event_loop()
101+
try:
102+
output = loop.run_until_complete(_run_async())
103+
return ExampleResult(
104+
resources_created=output.resources_created,
105+
checks=output.checks,
106+
cleanup_status=cleanup_status,
107+
)
108+
finally:
109+
loop.close()
110+
111+
return run
112+
113+
114+
def wrap_recipe_with_options(
115+
recipe: Callable[[RecipeContext, T], RecipeOutput] | Callable[[RecipeContext, T], Awaitable[RecipeOutput]],
116+
validate_env: Callable[[T], tuple[bool, list[ExampleCheck]]] | None = None,
117+
) -> Callable[[T], ExampleResult]:
118+
"""Wrap a recipe function that takes options with cleanup tracking.
119+
120+
Args:
121+
recipe: The recipe function to wrap. Can be sync or async. Takes options parameter.
122+
validate_env: Optional function to validate environment before running.
123+
Takes options and returns (skip, checks) tuple.
124+
125+
Returns:
126+
A callable that runs the recipe with options and returns ExampleResult.
127+
"""
128+
129+
def run(options: T) -> ExampleResult:
130+
cleanup_status = empty_cleanup_status()
131+
cleanup = _CleanupTracker(cleanup_status)
132+
133+
if validate_env is not None:
134+
skip, checks = validate_env(options)
135+
if skip:
136+
return ExampleResult(
137+
resources_created=[],
138+
checks=checks,
139+
cleanup_status=cleanup_status,
140+
skipped=True,
141+
)
142+
143+
ctx = RecipeContext(cleanup=cleanup)
144+
145+
async def _run_async() -> RecipeOutput:
146+
try:
147+
result = recipe(ctx, options)
148+
if asyncio.iscoroutine(result):
149+
output: RecipeOutput = await result
150+
return output
151+
return result # type: ignore[return-value]
152+
finally:
153+
await cleanup.run()
154+
155+
loop = asyncio.new_event_loop()
156+
try:
157+
output = loop.run_until_complete(_run_async())
158+
return ExampleResult(
159+
resources_created=output.resources_created,
160+
checks=output.checks,
161+
cleanup_status=cleanup_status,
162+
)
163+
finally:
164+
loop.close()
165+
166+
return run
167+
168+
169+
def run_as_cli(run: Callable[[], ExampleResult]) -> None:
170+
"""Run an example and exit with appropriate status code."""
171+
try:
172+
result = run()
173+
print(json.dumps(asdict(result), indent=2)) # noqa: T201
174+
if _should_fail_process(result):
175+
sys.exit(1)
176+
except Exception as e:
177+
print(f"Error: {e}") # noqa: T201
178+
sys.exit(1)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#!/usr/bin/env -S uv run python
2+
"""
3+
---
4+
title: Devbox From Blueprint (Run Command, Shutdown)
5+
slug: devbox-from-blueprint-lifecycle
6+
use_case: Create a devbox from a blueprint, run a command, validate output, and cleanly tear everything down.
7+
workflow:
8+
- Create a blueprint
9+
- Create a devbox from the blueprint
10+
- Execute a command in the devbox
11+
- Validate exit code and stdout
12+
- Shutdown devbox and delete blueprint
13+
tags:
14+
- devbox
15+
- blueprint
16+
- commands
17+
- cleanup
18+
prerequisites:
19+
- RUNLOOP_API_KEY
20+
run: uv run examples/devbox_from_blueprint_lifecycle.py
21+
test: uv run pytest -m smoketest tests/smoketests/examples/
22+
---
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import time
28+
29+
from runloop_api_client import RunloopSDK
30+
from runloop_api_client.lib.polling import PollingConfig
31+
32+
from .types import ExampleCheck, RecipeOutput, RecipeContext
33+
from ._harness import run_as_cli, wrap_recipe
34+
35+
36+
def unique_name(prefix: str) -> str:
37+
"""Generate a unique name with timestamp and random suffix."""
38+
return f"{prefix}-{int(time.time())}-{hex(int(time.time() * 1000) % 0xFFFFFF)[2:]}"
39+
40+
41+
BLUEPRINT_POLL_TIMEOUT_S = 10 * 60
42+
43+
44+
def recipe(ctx: RecipeContext) -> RecipeOutput:
45+
"""Create a devbox from a blueprint, run a command, and clean up."""
46+
cleanup = ctx.cleanup
47+
48+
sdk = RunloopSDK()
49+
50+
blueprint = sdk.blueprint.create(
51+
name=unique_name("example-blueprint"),
52+
dockerfile='FROM ubuntu:22.04\nRUN echo "Hello from your blueprint"',
53+
polling_config=PollingConfig(timeout_seconds=BLUEPRINT_POLL_TIMEOUT_S),
54+
)
55+
cleanup.add(f"blueprint:{blueprint.id}", blueprint.delete)
56+
57+
devbox = blueprint.create_devbox(
58+
name=unique_name("example-devbox"),
59+
launch_parameters={
60+
"resource_size_request": "X_SMALL",
61+
"keep_alive_time_seconds": 60 * 5,
62+
},
63+
)
64+
cleanup.add(f"devbox:{devbox.id}", devbox.shutdown)
65+
66+
result = devbox.cmd.exec('echo "Hello from your devbox"')
67+
stdout = result.stdout()
68+
69+
return RecipeOutput(
70+
resources_created=[f"blueprint:{blueprint.id}", f"devbox:{devbox.id}"],
71+
checks=[
72+
ExampleCheck(
73+
name="command exits successfully",
74+
passed=result.exit_code == 0,
75+
details=f"exitCode={result.exit_code}",
76+
),
77+
ExampleCheck(
78+
name="command output contains expected text",
79+
passed="Hello from your devbox" in stdout,
80+
details=stdout.strip(),
81+
),
82+
],
83+
)
84+
85+
86+
run_devbox_from_blueprint_lifecycle_example = wrap_recipe(recipe)
87+
88+
89+
if __name__ == "__main__":
90+
run_as_cli(run_devbox_from_blueprint_lifecycle_example)

0 commit comments

Comments
 (0)