Skip to content

Commit da5faa4

Browse files
authored
fix: add logs to devboxes, smoke tests & examples (#755)
1 parent 92004cc commit da5faa4

File tree

10 files changed

+260
-10
lines changed

10 files changed

+260
-10
lines changed

EXAMPLES.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,17 @@ uv run pytest -m smoketest tests/smoketests/examples/
4444
<a id="devbox-from-blueprint-lifecycle"></a>
4545
## Devbox From Blueprint (Run Command, Shutdown)
4646

47-
**Use case:** Create a devbox from a blueprint, run a command, validate output, and cleanly tear everything down.
47+
**Use case:** Create a devbox from a blueprint, run a command, fetch logs, validate output, and cleanly tear everything down.
4848

49-
**Tags:** `devbox`, `blueprint`, `commands`, `cleanup`
49+
**Tags:** `devbox`, `blueprint`, `commands`, `logs`, `cleanup`
5050

5151
### Workflow
5252
- Create a blueprint
53+
- Fetch blueprint build logs
5354
- Create a devbox from the blueprint
5455
- Execute a command in the devbox
55-
- Validate exit code and stdout
56+
- Fetch devbox logs
57+
- Validate exit code, stdout, and logs
5658
- Shutdown devbox and delete blueprint
5759

5860
### Prerequisites

README-SDK.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,13 +160,13 @@ print(f"Devbox {info.name} is {info.status}")
160160
Execute commands synchronously or asynchronously:
161161

162162
```python
163-
# Synchronous command execution (waits for completion)
163+
# exec blocks until completion - use for commands that return immediately
164164
result = devbox.cmd.exec("ls -la")
165165
print("Output:", result.stdout())
166166
print("Exit code:", result.exit_code)
167167
print("Success:", result.success)
168168

169-
# Asynchronous command execution (returns immediately)
169+
# exec_async returns immediately - use for long-running processes
170170
execution = devbox.cmd.exec_async("npm run dev")
171171

172172
# Check execution status
@@ -393,11 +393,30 @@ async with await runloop.devbox.create(name="temp-devbox") as devbox:
393393
# devbox is automatically shutdown when exiting the context
394394
```
395395

396+
#### Devbox Logs
397+
398+
Retrieve logs from a devbox, optionally filtered by execution ID or shell name:
399+
400+
```python
401+
# Get all devbox logs
402+
logs = devbox.logs()
403+
for log in logs.logs:
404+
print(f"[{log.level}] {log.message}")
405+
406+
# Filter logs by execution ID
407+
result = devbox.cmd.exec('echo "hello"')
408+
exec_logs = devbox.logs(execution_id=result.execution_id)
409+
410+
# Filter logs by shell name
411+
shell_logs = devbox.logs(shell_name="my-shell")
412+
```
413+
396414
**Key methods:**
397415

398416
- `devbox.get_info()` - Get devbox details and status
399417
- `devbox.cmd.exec()` - Execute commands synchronously
400418
- `devbox.cmd.exec_async()` - Execute commands asynchronously
419+
- `devbox.logs()` - Retrieve devbox logs (optionally filter by execution_id or shell_name)
401420
- `devbox.file.read()` - Read file contents
402421
- `devbox.file.write()` - Write file contents
403422
- `devbox.file.upload()` - Upload files

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,17 @@ print(runloop_api_client.__version__)
497497

498498
Python 3.9 or higher.
499499

500+
## Development
501+
502+
After cloning the repository, run the bootstrap script and install git hooks:
503+
504+
```sh
505+
./scripts/bootstrap
506+
./scripts/install-hooks
507+
```
508+
509+
This installs pre-push hooks that run linting and verify generated files are up to date.
510+
500511
## Contributing
501512

502513
See [the contributing documentation](./CONTRIBUTING.md).

examples/devbox_from_blueprint_lifecycle.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@
33
---
44
title: Devbox From Blueprint (Run Command, Shutdown)
55
slug: devbox-from-blueprint-lifecycle
6-
use_case: Create a devbox from a blueprint, run a command, validate output, and cleanly tear everything down.
6+
use_case: Create a devbox from a blueprint, run a command, fetch logs, validate output, and cleanly tear everything down.
77
workflow:
88
- Create a blueprint
9+
- Fetch blueprint build logs
910
- Create a devbox from the blueprint
1011
- Execute a command in the devbox
11-
- Validate exit code and stdout
12+
- Fetch devbox logs
13+
- Validate exit code, stdout, and logs
1214
- Shutdown devbox and delete blueprint
1315
tags:
1416
- devbox
1517
- blueprint
1618
- commands
19+
- logs
1720
- cleanup
1821
prerequisites:
1922
- RUNLOOP_API_KEY
@@ -34,7 +37,7 @@
3437

3538

3639
def recipe(ctx: RecipeContext) -> RecipeOutput:
37-
"""Create a devbox from a blueprint, run a command, and clean up."""
40+
"""Create a devbox from a blueprint, run a command, fetch logs, and clean up."""
3841
cleanup = ctx.cleanup
3942

4043
sdk = RunloopSDK()
@@ -46,6 +49,9 @@ def recipe(ctx: RecipeContext) -> RecipeOutput:
4649
)
4750
cleanup.add(f"blueprint:{blueprint.id}", blueprint.delete)
4851

52+
# Fetch blueprint build logs
53+
blueprint_logs = blueprint.logs()
54+
4955
devbox = blueprint.create_devbox(
5056
name=unique_name("example-devbox"),
5157
launch_parameters={
@@ -58,6 +64,9 @@ def recipe(ctx: RecipeContext) -> RecipeOutput:
5864
result = devbox.cmd.exec('echo "Hello from your devbox"')
5965
stdout = result.stdout()
6066

67+
# Fetch devbox logs
68+
devbox_logs = devbox.logs()
69+
6170
return RecipeOutput(
6271
resources_created=[f"blueprint:{blueprint.id}", f"devbox:{devbox.id}"],
6372
checks=[
@@ -71,6 +80,16 @@ def recipe(ctx: RecipeContext) -> RecipeOutput:
7180
passed="Hello from your devbox" in stdout,
7281
details=stdout.strip(),
7382
),
83+
ExampleCheck(
84+
name="blueprint build logs are retrievable",
85+
passed=hasattr(blueprint_logs, "logs"),
86+
details=f"blueprint_log_count={len(blueprint_logs.logs)}",
87+
),
88+
ExampleCheck(
89+
name="devbox logs are retrievable",
90+
passed=hasattr(devbox_logs, "logs"),
91+
details=f"devbox_log_count={len(devbox_logs.logs)}",
92+
),
7493
],
7594
)
7695

llms.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
- **Prefer `AsyncRunloopSDK` over `RunloopSDK`** for better concurrency and performance; all SDK methods have async equivalents
2424
- Use `async with await runloop.devbox.create()` for automatic cleanup via context manager
2525
- For resources without SDK coverage (e.g., secrets, benchmarks), use `runloop.api.*` as a fallback
26-
- Use `await devbox.cmd.exec('command')` for most commands—blocks until completion, returns `ExecutionResult` with stdout/stderr
27-
- 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
26+
- 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
27+
- 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
2828
- Both `exec` and `exec_async` support streaming callbacks (`stdout`, `stderr`, `output`) for real-time output
2929
- Call `await devbox.shutdown()` to clean up resources that are no longer in use.
3030
- Streaming callbacks (`stdout`, `stderr`, `output`) must be synchronous functions even with async SDK

scripts/install-hooks

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env bash
2+
3+
set -e
4+
5+
cd "$(dirname "$0")/.."
6+
7+
echo "==> Installing git hooks..."
8+
9+
mkdir -p .git/hooks
10+
11+
cat > .git/hooks/pre-push << 'EOF'
12+
#!/usr/bin/env bash
13+
set -e
14+
cd "$(git rev-parse --show-toplevel)"
15+
16+
echo "==> Running lint checks..."
17+
./scripts/lint
18+
19+
echo "==> Checking EXAMPLES.md is up to date..."
20+
uv run python scripts/generate_examples_md.py --check
21+
22+
echo "==> All checks passed!"
23+
EOF
24+
25+
chmod +x .git/hooks/pre-push
26+
27+
echo "==> Git hooks installed successfully!"

src/runloop_api_client/sdk/async_devbox.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
SDKDevboxSnapshotDiskAsyncParams,
3434
SDKDevboxWriteFileContentsParams,
3535
)
36+
from .._types import omit
3637
from .._client import AsyncRunloop
3738
from ._helpers import filter_params
3839
from .._streaming import AsyncStream
@@ -41,6 +42,7 @@
4142
from .async_execution import AsyncExecution, _AsyncStreamingGroup
4243
from .async_execution_result import AsyncExecutionResult
4344
from ..types.devbox_execute_async_params import DevboxNiceExecuteAsyncParams
45+
from ..types.devboxes.devbox_logs_list_view import DevboxLogsListView
4446
from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView
4547

4648
StreamFactory = Callable[[], Awaitable[AsyncStream[ExecutionUpdateChunk]]]
@@ -163,6 +165,38 @@ async def get_tunnel_url(
163165
return None
164166
return f"https://{port}-{tunnel_view.tunnel_key}.tunnel.runloop.ai"
165167

168+
async def logs(
169+
self,
170+
*,
171+
execution_id: str | None = None,
172+
shell_name: str | None = None,
173+
**options: Unpack[BaseRequestOptions],
174+
) -> DevboxLogsListView:
175+
"""Retrieve logs for the devbox.
176+
177+
Returns all logs from a running or completed devbox. Optionally filter
178+
by execution ID or shell name.
179+
180+
:param execution_id: Filter logs by execution ID, defaults to None
181+
:type execution_id: str | None, optional
182+
:param shell_name: Filter logs by shell name, defaults to None
183+
:type shell_name: str | None, optional
184+
:param options: Optional request configuration
185+
:return: Log entries for the devbox
186+
:rtype: :class:`~runloop_api_client.types.devboxes.devbox_logs_list_view.DevboxLogsListView`
187+
188+
Example:
189+
>>> logs = await devbox.logs()
190+
>>> for log in logs.logs:
191+
... print(f"[{log.level}] {log.message}")
192+
"""
193+
return await self._client.devboxes.logs.list(
194+
self._id,
195+
execution_id=execution_id if execution_id is not None else omit,
196+
shell_name=shell_name if shell_name is not None else omit,
197+
**options,
198+
)
199+
166200
async def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView:
167201
"""Wait for the devbox to reach running state.
168202

src/runloop_api_client/sdk/devbox.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
SDKDevboxSnapshotDiskAsyncParams,
3535
SDKDevboxWriteFileContentsParams,
3636
)
37+
from .._types import omit
3738
from .._client import Runloop
3839
from ._helpers import filter_params
3940
from .execution import Execution, _StreamingGroup
@@ -42,6 +43,7 @@
4243
from ..types.devboxes import ExecutionUpdateChunk
4344
from .execution_result import ExecutionResult
4445
from ..types.devbox_execute_async_params import DevboxNiceExecuteAsyncParams
46+
from ..types.devboxes.devbox_logs_list_view import DevboxLogsListView
4547
from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView
4648

4749
if TYPE_CHECKING:
@@ -162,6 +164,38 @@ def get_tunnel_url(
162164
return None
163165
return f"https://{port}-{tunnel_view.tunnel_key}.tunnel.runloop.ai"
164166

167+
def logs(
168+
self,
169+
*,
170+
execution_id: str | None = None,
171+
shell_name: str | None = None,
172+
**options: Unpack[BaseRequestOptions],
173+
) -> DevboxLogsListView:
174+
"""Retrieve logs for the devbox.
175+
176+
Returns all logs from a running or completed devbox. Optionally filter
177+
by execution ID or shell name.
178+
179+
:param execution_id: Filter logs by execution ID, defaults to None
180+
:type execution_id: str | None, optional
181+
:param shell_name: Filter logs by shell name, defaults to None
182+
:type shell_name: str | None, optional
183+
:param options: Optional request configuration
184+
:return: Log entries for the devbox
185+
:rtype: :class:`~runloop_api_client.types.devboxes.devbox_logs_list_view.DevboxLogsListView`
186+
187+
Example:
188+
>>> logs = devbox.logs()
189+
>>> for log in logs.logs:
190+
... print(f"[{log.level}] {log.message}")
191+
"""
192+
return self._client.devboxes.logs.list(
193+
self._id,
194+
execution_id=execution_id if execution_id is not None else omit,
195+
shell_name=shell_name if shell_name is not None else omit,
196+
**options,
197+
)
198+
165199
def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView:
166200
"""Wait for the devbox to reach running state.
167201

tests/smoketests/sdk/test_async_devbox.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,3 +1064,55 @@ async def test_shell_exec_async_with_both_streams(self, devbox: AsyncDevbox) ->
10641064
# Verify streaming captured same data as result
10651065
assert stdout_combined == await result.stdout()
10661066
assert stderr_combined == await result.stderr()
1067+
1068+
1069+
class TestAsyncDevboxLogs:
1070+
"""Test async devbox logs retrieval functionality."""
1071+
1072+
@pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
1073+
async def test_logs_basic(self, shared_devbox: AsyncDevbox) -> None:
1074+
"""Test retrieving devbox logs returns valid response structure."""
1075+
test_message = "async basic log test message"
1076+
result = await shared_devbox.cmd.exec(f'echo "{test_message}"')
1077+
assert result.exit_code == 0
1078+
1079+
logs = await shared_devbox.logs()
1080+
1081+
assert logs is not None
1082+
assert hasattr(logs, "logs")
1083+
assert isinstance(logs.logs, list)
1084+
log_content = " ".join(str(log) for log in logs.logs)
1085+
assert test_message in log_content
1086+
1087+
@pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
1088+
async def test_logs_with_execution_filter(self, shared_devbox: AsyncDevbox) -> None:
1089+
"""Test retrieving devbox logs filtered by execution ID."""
1090+
test_message = "async filtered log test"
1091+
result = await shared_devbox.cmd.exec(f'echo "{test_message}"')
1092+
assert result.exit_code == 0
1093+
1094+
logs = await shared_devbox.logs(execution_id=result.execution_id)
1095+
1096+
assert logs is not None
1097+
assert hasattr(logs, "logs")
1098+
assert isinstance(logs.logs, list)
1099+
log_content = " ".join(str(log) for log in logs.logs)
1100+
assert test_message in log_content
1101+
1102+
@pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
1103+
async def test_logs_with_shell_name_filter(self, shared_devbox: AsyncDevbox) -> None:
1104+
"""Test retrieving devbox logs filtered by shell name."""
1105+
shell_name = "async-test-logs-shell"
1106+
shell = shared_devbox.shell(shell_name)
1107+
1108+
test_message = "async shell log test"
1109+
result = await shell.exec(f'echo "{test_message}"')
1110+
assert result.exit_code == 0
1111+
1112+
logs = await shared_devbox.logs(shell_name=shell_name)
1113+
1114+
assert logs is not None
1115+
assert hasattr(logs, "logs")
1116+
assert isinstance(logs.logs, list)
1117+
log_content = " ".join(str(log) for log in logs.logs)
1118+
assert test_message in log_content

0 commit comments

Comments
 (0)