Skip to content
Open
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: 8 additions & 0 deletions sdks/python/boxlite/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
25 changes: 22 additions & 3 deletions sdks/python/boxlite/simplebox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
24 changes: 21 additions & 3 deletions sdks/python/boxlite/sync_api/_simplebox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Comment thread
G4614 marked this conversation as resolved.
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 = []
Expand Down
4 changes: 2 additions & 2 deletions sdks/python/src/box_handle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;

Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions sdks/python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;

Expand Down Expand Up @@ -71,6 +72,7 @@ fn boxlite_python(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyApiKeyCredential>()?;
m.add_class::<PyAccessToken>()?;
m.add_class::<PySecret>()?;
m.add("_ExecStartError", m.py().get_type::<ExecStartError>())?;

Ok(())
}
24 changes: 23 additions & 1 deletion sdks/python/src/util.rs
Original file line number Diff line number Diff line change
@@ -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(": "))
}
2 changes: 1 addition & 1 deletion sdks/python/tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"""

import pytest
from boxlite.errors import BoxliteError, ExecError, TimeoutError, ParseError
from boxlite.errors import BoxliteError, ExecError, ParseError, TimeoutError


class TestBoxliteError:
Expand Down
21 changes: 21 additions & 0 deletions sdks/python/tests/test_simplebox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
21 changes: 20 additions & 1 deletion sdks/python/tests/test_sync_simplebox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
Loading