diff --git a/EXAMPLES.md b/EXAMPLES.md index 0d3ff62cc..faa08edd4 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -44,15 +44,17 @@ uv run pytest -m smoketest tests/smoketests/examples/ ## 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 diff --git a/README-SDK.md b/README-SDK.md index e4885ff3b..115eacf22 100644 --- a/README-SDK.md +++ b/README-SDK.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 1907bf12b..09aeb9016 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/examples/devbox_from_blueprint_lifecycle.py b/examples/devbox_from_blueprint_lifecycle.py index 9dc98c4ed..f916ee5f4 100644 --- a/examples/devbox_from_blueprint_lifecycle.py +++ b/examples/devbox_from_blueprint_lifecycle.py @@ -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 @@ -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() @@ -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={ @@ -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=[ @@ -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)}", + ), ], ) diff --git a/llms.txt b/llms.txt index 07971384f..ec1e662bf 100644 --- a/llms.txt +++ b/llms.txt @@ -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 diff --git a/scripts/install-hooks b/scripts/install-hooks new file mode 100755 index 000000000..43261db61 --- /dev/null +++ b/scripts/install-hooks @@ -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!" diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index 81d602eb3..e6c0ac51e 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -33,6 +33,7 @@ SDKDevboxSnapshotDiskAsyncParams, SDKDevboxWriteFileContentsParams, ) +from .._types import omit from .._client import AsyncRunloop from ._helpers import filter_params from .._streaming import AsyncStream @@ -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]]] @@ -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. diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index ff541ce92..5718e7392 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -34,6 +34,7 @@ SDKDevboxSnapshotDiskAsyncParams, SDKDevboxWriteFileContentsParams, ) +from .._types import omit from .._client import Runloop from ._helpers import filter_params from .execution import Execution, _StreamingGroup @@ -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: @@ -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. diff --git a/tests/smoketests/sdk/test_async_devbox.py b/tests/smoketests/sdk/test_async_devbox.py index cbfc5c030..e21c18705 100644 --- a/tests/smoketests/sdk/test_async_devbox.py +++ b/tests/smoketests/sdk/test_async_devbox.py @@ -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 diff --git a/tests/smoketests/sdk/test_devbox.py b/tests/smoketests/sdk/test_devbox.py index 38e885f71..e145b9b91 100644 --- a/tests/smoketests/sdk/test_devbox.py +++ b/tests/smoketests/sdk/test_devbox.py @@ -1050,3 +1050,55 @@ 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.""" + test_message = "basic log test message" + result = shared_devbox.cmd.exec(f'echo "{test_message}"') + assert result.exit_code == 0 + + logs = 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) + def test_logs_with_execution_filter(self, shared_devbox: Devbox) -> None: + """Test retrieving devbox logs filtered by execution ID.""" + test_message = "filtered log test" + result = shared_devbox.cmd.exec(f'echo "{test_message}"') + assert result.exit_code == 0 + + logs = 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) + 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) + + test_message = "shell log test" + result = shell.exec(f'echo "{test_message}"') + assert result.exit_code == 0 + + logs = 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