diff --git a/sdks/python/boxlite/errors.py b/sdks/python/boxlite/errors.py index 482c7301a..55ac83803 100644 --- a/sdks/python/boxlite/errors.py +++ b/sdks/python/boxlite/errors.py @@ -7,6 +7,14 @@ __all__ = ["BoxliteError", "ExecError", "TimeoutError", "ParseError"] +def _exec_start_exit_code(stderr: str) -> int: + """Map command-start errors to shell-compatible exit codes.""" + detail = stderr.lower() + if "no such file or directory" in detail: + return 127 + return 126 + + class BoxliteError(Exception): """Base exception for all boxlite errors.""" diff --git a/sdks/python/boxlite/simplebox.py b/sdks/python/boxlite/simplebox.py index bc56cc638..be08cd35f 100644 --- a/sdks/python/boxlite/simplebox.py +++ b/sdks/python/boxlite/simplebox.py @@ -9,8 +9,19 @@ from enum import IntEnum from typing import Optional, TYPE_CHECKING +from .errors import ExecError, _exec_start_exit_code from .exec import ExecResult +try: + from .boxlite import _ExecStartError +except ImportError: + + class _ExecStartError(RuntimeError): + """Import placeholder when the native extension is unavailable.""" + + pass + + if TYPE_CHECKING: from .boxlite import Boxlite @@ -266,10 +277,18 @@ async def exec( # Convert env dict to list of tuples if provided env_list = list(env.items()) if env else None + command_display = " ".join([cmd, *args]) + # Execute via Rust (returns PyExecution) - execution = await self._box.exec( - cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd - ) + try: + execution = await self._box.exec( + cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd + ) + except _ExecStartError as e: + message = str(e) + raise ExecError( + command_display, _exec_start_exit_code(message), message + ) from e # Get streams from Rust execution try: diff --git a/sdks/python/boxlite/sync_api/_simplebox.py b/sdks/python/boxlite/sync_api/_simplebox.py index 6406e81af..274a9642e 100644 --- a/sdks/python/boxlite/sync_api/_simplebox.py +++ b/sdks/python/boxlite/sync_api/_simplebox.py @@ -9,8 +9,19 @@ import logging from typing import TYPE_CHECKING, Dict, Optional +from ..errors import ExecError, _exec_start_exit_code from ..exec import ExecResult +try: + from ..boxlite import _ExecStartError +except ImportError: + + class _ExecStartError(RuntimeError): + """Import placeholder when the native extension is unavailable.""" + + pass + + if TYPE_CHECKING: from ._boxlite import SyncBoxlite from ._box import SyncBox @@ -190,11 +201,18 @@ def exec( # Access the underlying async Box directly async_box = self._box._box + command_display = " ".join([cmd, *args]) async def _exec_and_collect(): - execution = await async_box.exec( - cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd - ) + try: + execution = await async_box.exec( + cmd, arg_list, env_list, user=user, timeout_secs=timeout, cwd=cwd + ) + except _ExecStartError as e: + message = str(e) + raise ExecError( + command_display, _exec_start_exit_code(message), message + ) from e stdout_lines = [] stderr_lines = [] diff --git a/sdks/python/src/box_handle.rs b/sdks/python/src/box_handle.rs index 3a49adddf..cafdd061c 100644 --- a/sdks/python/src/box_handle.rs +++ b/sdks/python/src/box_handle.rs @@ -6,7 +6,7 @@ use crate::metrics::PyBoxMetrics; use crate::network::PyNetworkHandle; use crate::snapshot_options::{PyCloneOptions, PyExportOptions}; use crate::snapshots::PySnapshotHandle; -use crate::util::map_err; +use crate::util::{map_err, map_exec_start_err}; use boxlite::{BoxCommand, CloneOptions, ExportOptions, LiteBox}; use pyo3::prelude::*; @@ -87,7 +87,7 @@ impl PyBox { cmd = cmd.working_dir(cwd); } - let execution = handle.exec(cmd).await.map_err(map_err)?; + let execution = handle.exec(cmd).await.map_err(map_exec_start_err)?; Ok(PyExecution { execution: Arc::new(execution), diff --git a/sdks/python/src/lib.rs b/sdks/python/src/lib.rs index 6ff730b17..5faa9937c 100644 --- a/sdks/python/src/lib.rs +++ b/sdks/python/src/lib.rs @@ -28,6 +28,7 @@ use crate::options::{ use crate::runtime::PyBoxlite; use crate::snapshot_options::{PyCloneOptions, PyExportOptions, PySnapshotOptions}; use crate::snapshots::{PySnapshotHandle, PySnapshotInfo}; +use crate::util::ExecStartError; use crate::volumes::{PyVolumeHandle, PyVolumeInfo}; use pyo3::prelude::*; @@ -71,6 +72,7 @@ fn boxlite_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add("_ExecStartError", m.py().get_type::())?; Ok(()) } diff --git a/sdks/python/src/util.rs b/sdks/python/src/util.rs index ea544c5b5..753d0cff7 100644 --- a/sdks/python/src/util.rs +++ b/sdks/python/src/util.rs @@ -1,5 +1,27 @@ -use pyo3::{exceptions::PyRuntimeError, prelude::*}; +use boxlite::BoxliteError; +use pyo3::{create_exception, exceptions::PyRuntimeError, prelude::*}; + +create_exception!(boxlite_python, ExecStartError, PyRuntimeError); + +const EXEC_START_REASON: &str = "spawn_failed"; pub(crate) fn map_err(err: impl std::fmt::Display) -> PyErr { PyRuntimeError::new_err(err.to_string()) } + +pub(crate) fn map_exec_start_err(err: BoxliteError) -> PyErr { + if is_exec_start_failure(&err) { + ExecStartError::new_err(err.to_string()) + } else { + map_err(err) + } +} + +fn is_exec_start_failure(err: &BoxliteError) -> bool { + let BoxliteError::Internal(message) = err else { + return false; + }; + message + .strip_prefix(EXEC_START_REASON) + .is_some_and(|detail| detail.starts_with(": ")) +} diff --git a/sdks/python/tests/test_errors.py b/sdks/python/tests/test_errors.py index fc7703c3d..316c88c65 100644 --- a/sdks/python/tests/test_errors.py +++ b/sdks/python/tests/test_errors.py @@ -5,7 +5,7 @@ """ import pytest -from boxlite.errors import BoxliteError, ExecError, TimeoutError, ParseError +from boxlite.errors import BoxliteError, ExecError, ParseError, TimeoutError class TestBoxliteError: diff --git a/sdks/python/tests/test_simplebox.py b/sdks/python/tests/test_simplebox.py index 3f6d9601c..8ac475fd1 100644 --- a/sdks/python/tests/test_simplebox.py +++ b/sdks/python/tests/test_simplebox.py @@ -16,3 +16,24 @@ async def test_simplebox_metrics(shared_runtime): metrics = await box.metrics() assert metrics is not None assert metrics.commands_executed_total >= 1 + + +async def test_simplebox_command_not_found_raises_exec_error(shared_runtime): + """Direct command start failures raise ExecError, not bare RuntimeError.""" + async with boxlite.SimpleBox(image="alpine:latest", runtime=shared_runtime) as box: + with pytest.raises(boxlite.ExecError) as exc: + await box.exec("definitely-not-a-boxlite-command-xyz") + assert exc.value.exit_code == 127 + stderr = exc.value.stderr.lower() + assert "failed to spawn" in stderr + + +async def test_simplebox_unexecutable_command_raises_exit_code_126(shared_runtime): + async with boxlite.SimpleBox(image="alpine:latest", runtime=shared_runtime) as box: + await box.exec( + "sh", "-c", "printf '#!/bin/sh\\n' > /tmp/noexec && chmod 644 /tmp/noexec" + ) + with pytest.raises(boxlite.ExecError) as exc: + await box.exec("/tmp/noexec") + assert exc.value.exit_code == 126 + assert "permission denied" in exc.value.stderr.lower() diff --git a/sdks/python/tests/test_sync_simplebox.py b/sdks/python/tests/test_sync_simplebox.py index 928cfd031..6f7eaaa78 100644 --- a/sdks/python/tests/test_sync_simplebox.py +++ b/sdks/python/tests/test_sync_simplebox.py @@ -11,7 +11,7 @@ # Try to import sync API - skip if greenlet not installed try: - from boxlite import SyncSimpleBox + from boxlite import ExecError, SyncSimpleBox SYNC_AVAILABLE = True except ImportError: @@ -82,6 +82,25 @@ def test_metrics(self, shared_sync_runtime): assert metrics is not None assert metrics.commands_executed_total >= 1 + def test_command_not_found_raises_exec_error(self, shared_sync_runtime): + """Direct command start failures raise ExecError, not bare RuntimeError.""" + with SyncSimpleBox(image="alpine:latest", runtime=shared_sync_runtime) as box: + with pytest.raises(ExecError) as exc: + box.exec("definitely-not-a-boxlite-command-xyz") + assert exc.value.exit_code == 127 + + def test_unexecutable_command_raises_exit_code_126(self, shared_sync_runtime): + with SyncSimpleBox(image="alpine:latest", runtime=shared_sync_runtime) as box: + box.exec( + "sh", + "-c", + "printf '#!/bin/sh\\n' > /tmp/noexec && chmod 644 /tmp/noexec", + ) + with pytest.raises(ExecError) as exc: + box.exec("/tmp/noexec") + assert exc.value.exit_code == 126 + assert "permission" in exc.value.stderr.lower() + def test_custom_working_dir(self, shared_sync_runtime): """Can set custom working directory.""" with SyncSimpleBox(