Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
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
44 changes: 44 additions & 0 deletions tests/smoketests/sdk/test_devbox.py
Comment thread
james-rl marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -1050,3 +1050,47 @@ def test_shell_exec_async_with_both_streams(self, devbox: Devbox) -> None:
# Verify streaming captured same data as result
assert stdout_combined == result.stdout()
assert stderr_combined == result.stderr()


class TestDevboxLogs:
"""Test devbox logs retrieval functionality."""

@pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
def test_logs_basic(self, shared_devbox: Devbox) -> None:
"""Test retrieving devbox logs returns valid response structure."""
# Fetch logs - the API may return empty logs depending on timing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we do a basic echo command to ensure that the logs aren't empty?

logs = shared_devbox.logs()

assert logs is not None
assert hasattr(logs, "logs")
assert isinstance(logs.logs, list)

@pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
def test_logs_with_execution_filter(self, shared_devbox: Devbox) -> None:
"""Test retrieving devbox logs filtered by execution ID."""
# Run a command and get its execution ID
execution = shared_devbox.cmd.exec_async('echo "filtered log test"')
result = execution.result()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we just exec()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great catch -- this is almost certainly caused by an issue with our agent guidance. I'm going to update this in a few places

assert result.exit_code == 0

# Fetch logs filtered by execution ID - verifies API accepts the filter
logs = shared_devbox.logs(execution_id=execution.execution_id)

assert logs is not None
assert isinstance(logs.logs, list)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to add hasattr(logs, "logs") protection here as well? also can we check that the actual string we echo'd is in the logs?


@pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
def test_logs_with_shell_name_filter(self, shared_devbox: Devbox) -> None:
"""Test retrieving devbox logs filtered by shell name."""
shell_name = "test-logs-shell"
shell = shared_devbox.shell(shell_name)

# Run a command in the named shell
result = shell.exec('echo "shell log test"')
assert result.exit_code == 0

# Fetch logs filtered by shell name - verifies API accepts the filter
logs = shared_devbox.logs(shell_name=shell_name)

assert logs is not None
assert isinstance(logs.logs, list)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above comment