Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,17 @@ uv run pytest -m smoketest tests/smoketests/examples/
<a id="devbox-from-blueprint-lifecycle"></a>
## 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
Expand Down
23 changes: 21 additions & 2 deletions README-SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
25 changes: 22 additions & 3 deletions examples/devbox_from_blueprint_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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={
Expand All @@ -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=[
Expand All @@ -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)}",
),
],
)

Expand Down
4 changes: 2 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions scripts/install-hooks
Original file line number Diff line number Diff line change
@@ -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!"
34 changes: 34 additions & 0 deletions src/runloop_api_client/sdk/async_devbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
SDKDevboxSnapshotDiskAsyncParams,
SDKDevboxWriteFileContentsParams,
)
from .._types import omit
from .._client import AsyncRunloop
from ._helpers import filter_params
from .._streaming import AsyncStream
Expand All @@ -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]]]
Expand Down Expand Up @@ -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.

Expand Down
34 changes: 34 additions & 0 deletions src/runloop_api_client/sdk/devbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
SDKDevboxSnapshotDiskAsyncParams,
SDKDevboxWriteFileContentsParams,
)
from .._types import omit
from .._client import Runloop
from ._helpers import filter_params
from .execution import Execution, _StreamingGroup
Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
52 changes: 52 additions & 0 deletions tests/smoketests/sdk/test_async_devbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading