diff --git a/boxlite/src/litebox/box_impl.rs b/boxlite/src/litebox/box_impl.rs index d2c9a1bd..96290996 100644 --- a/boxlite/src/litebox/box_impl.rs +++ b/boxlite/src/litebox/box_impl.rs @@ -136,6 +136,7 @@ impl BoxImpl { state: BoxState, runtime: SharedRuntimeImpl, shutdown_token: CancellationToken, + event_listeners: Vec>, ) -> Self { Self { config, @@ -143,7 +144,7 @@ impl BoxImpl { runtime, shutdown_token, disk_ops: tokio::sync::Mutex::new(()), - event_listeners: Vec::new(), // populated from runtime options + event_listeners, live: OnceCell::new(), health_check_task: RwLock::new(None), } diff --git a/boxlite/src/runtime/options.rs b/boxlite/src/runtime/options.rs index 48ee261f..b81928ea 100644 --- a/boxlite/src/runtime/options.rs +++ b/boxlite/src/runtime/options.rs @@ -1,11 +1,13 @@ //! Configuration for Boxlite. +use crate::event_listener::EventListener; use crate::runtime::constants::envs as const_envs; use crate::runtime::layout::dirs as const_dirs; use boxlite_shared::errors::BoxliteResult; use dirs::home_dir; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use crate::runtime::advanced_options::{AdvancedBoxOptions, SecurityOptions}; @@ -15,7 +17,7 @@ use crate::runtime::advanced_options::{AdvancedBoxOptions, SecurityOptions}; /// Configuration options for BoxliteRuntime. /// /// Users can create it with defaults and modify fields as needed. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct BoxliteOptions { #[serde(default = "default_home_dir")] pub home_dir: PathBuf, @@ -42,6 +44,46 @@ pub struct BoxliteOptions { /// ``` #[serde(default)] pub image_registries: Vec, + + /// Event listeners for box lifecycle notifications. + /// + /// Listeners receive callbacks when boxes are created, started, stopped, + /// removed, and when commands are executed or files are copied. + /// + /// All callbacks default to no-op — implement only what you need. + /// + /// # Example + /// + /// ```ignore + /// use boxlite::event_listener::EventListener; + /// + /// struct Logger; + /// impl EventListener for Logger { + /// fn on_box_started(&self, box_id: &BoxID) { + /// println!("Box {} started", box_id); + /// } + /// } + /// + /// let opts = BoxliteOptions { + /// event_listeners: vec![Arc::new(Logger)], + /// ..Default::default() + /// }; + /// ``` + #[serde(skip, default)] + pub event_listeners: Vec>, +} + +impl std::fmt::Debug for BoxliteOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BoxliteOptions") + .field("home_dir", &self.home_dir) + .field("image_registries", &self.image_registries) + .field( + "event_listeners", + &format_args!("[{} listeners]", self.event_listeners.len()), + ) + .finish() + } } fn default_home_dir() -> PathBuf { @@ -59,6 +101,7 @@ impl Default for BoxliteOptions { Self { home_dir: default_home_dir(), image_registries: Vec::new(), + event_listeners: Vec::new(), } } } @@ -628,4 +671,86 @@ mod tests { assert!(opts1.resource_limits.max_processes.is_none()); assert_eq!(opts2.resource_limits.max_processes, Some(50)); } + + // ======================================================================== + // event_listeners tests + // ======================================================================== + + #[test] + fn test_boxlite_options_default_has_no_listeners() { + let opts = BoxliteOptions::default(); + assert!( + opts.event_listeners.is_empty(), + "Default options should have no event listeners" + ); + } + + #[test] + fn test_boxlite_options_with_event_listeners() { + use crate::event_listener::EventListener; + + struct TestListener; + impl EventListener for TestListener {} + + let opts = BoxliteOptions { + event_listeners: vec![Arc::new(TestListener)], + ..Default::default() + }; + assert_eq!(opts.event_listeners.len(), 1); + } + + #[test] + fn test_boxlite_options_clone_preserves_listeners() { + use crate::event_listener::EventListener; + + struct TestListener; + impl EventListener for TestListener {} + + let opts = BoxliteOptions { + event_listeners: vec![Arc::new(TestListener), Arc::new(TestListener)], + ..Default::default() + }; + let cloned = opts.clone(); + assert_eq!(cloned.event_listeners.len(), 2); + } + + #[test] + fn test_boxlite_options_serde_skips_listeners() { + use crate::event_listener::EventListener; + + struct TestListener; + impl EventListener for TestListener {} + + let opts = BoxliteOptions { + event_listeners: vec![Arc::new(TestListener)], + ..Default::default() + }; + + // Serialization should succeed (event_listeners skipped) + let json = serde_json::to_string(&opts).unwrap(); + assert!(!json.contains("event_listeners")); + + // Deserialization should default to empty vec + let deserialized: BoxliteOptions = serde_json::from_str(&json).unwrap(); + assert!(deserialized.event_listeners.is_empty()); + } + + #[test] + fn test_boxlite_options_debug_shows_listener_count() { + use crate::event_listener::EventListener; + + struct TestListener; + impl EventListener for TestListener {} + + let opts = BoxliteOptions { + event_listeners: vec![Arc::new(TestListener), Arc::new(TestListener)], + ..Default::default() + }; + let debug_str = format!("{:?}", opts); + assert!( + debug_str.contains("[2 listeners]"), + "Debug should show listener count, got: {}", + debug_str + ); + } } diff --git a/boxlite/src/runtime/rt_impl.rs b/boxlite/src/runtime/rt_impl.rs index 4331bd1f..4dc43c35 100644 --- a/boxlite/src/runtime/rt_impl.rs +++ b/boxlite/src/runtime/rt_impl.rs @@ -93,6 +93,10 @@ pub struct RuntimeImpl { /// Use `.is_cancelled()` for sync checks, `.cancelled()` for async select!. /// Child tokens are passed to each box via `.child_token()`. pub(crate) shutdown_token: CancellationToken, + + /// Event listeners registered at runtime level. + /// Cloned into each BoxImpl on construction. + pub(crate) event_listeners: Vec>, } /// Synchronized state protected by RwLock. @@ -233,6 +237,7 @@ impl RuntimeImpl { lock_manager, _runtime_lock: runtime_lock, shutdown_token: CancellationToken::new(), + event_listeners: options.event_listeners, }); tracing::debug!("initialized runtime"); @@ -1386,7 +1391,13 @@ impl RuntimeImpl { // Create new BoxImpl and cache in both maps // Pass a child token so box can be cancelled independently or via runtime shutdown let box_token = self.shutdown_token.child_token(); - let box_impl = Arc::new(BoxImpl::new(config, state, Arc::clone(self), box_token)); + let box_impl = Arc::new(BoxImpl::new( + config, + state, + Arc::clone(self), + box_token, + self.event_listeners.clone(), + )); let weak = Arc::downgrade(&box_impl); sync.active_boxes_by_id.insert(box_id.clone(), weak.clone()); @@ -1575,6 +1586,7 @@ mod tests { let options = BoxliteOptions { home_dir: temp_dir.path().to_path_buf(), image_registries: vec![], + ..Default::default() }; let runtime = RuntimeImpl::new(options).expect("Failed to create runtime"); (runtime, temp_dir) @@ -2372,4 +2384,70 @@ mod tests { let result = runtime.remove_box(&config_a.id, true); assert!(result.is_ok()); } + + // ==================================================================== + // event_listeners plumbing tests + // ==================================================================== + + #[test] + fn test_runtime_stores_event_listeners() { + use crate::event_listener::EventListener; + + struct TestListener; + impl EventListener for TestListener {} + + let temp_dir = TempDir::new_in("/tmp").expect("Failed to create temp dir"); + let options = BoxliteOptions { + home_dir: temp_dir.path().to_path_buf(), + image_registries: vec![], + event_listeners: vec![std::sync::Arc::new(TestListener)], + }; + let runtime = RuntimeImpl::new(options).expect("Failed to create runtime"); + assert_eq!( + runtime.event_listeners.len(), + 1, + "Runtime should store event listeners from options" + ); + } + + #[test] + fn test_runtime_passes_listeners_to_box_impl() { + use crate::event_listener::EventListener; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static CALL_COUNT: AtomicUsize = AtomicUsize::new(0); + + struct CountingListener; + impl EventListener for CountingListener { + fn on_box_created(&self, _box_id: &BoxID) { + CALL_COUNT.fetch_add(1, Ordering::SeqCst); + } + } + + CALL_COUNT.store(0, Ordering::SeqCst); + + let temp_dir = TempDir::new_in("/tmp").expect("Failed to create temp dir"); + let options = BoxliteOptions { + home_dir: temp_dir.path().to_path_buf(), + image_registries: vec![], + event_listeners: vec![std::sync::Arc::new(CountingListener)], + }; + let runtime = RuntimeImpl::new(options).expect("Failed to create runtime"); + + // Create a BoxImpl via get_or_create_box_impl — listeners should be cloned + let config = test_box_config(false); + let box_id = config.id.clone(); + let state = BoxState::new(); + let (box_impl, _created) = runtime.get_or_create_box_impl(config, state); + + assert_eq!( + box_impl.event_listeners.len(), + 1, + "BoxImpl should have listeners from runtime" + ); + + // Invoke the listener directly to verify it works + box_impl.event_listeners[0].on_box_created(&box_id); + assert_eq!(CALL_COUNT.load(Ordering::SeqCst), 1); + } } diff --git a/sdks/python/boxlite/__init__.py b/sdks/python/boxlite/__init__.py index ae906ca4..16d4b594 100644 --- a/sdks/python/boxlite/__init__.py +++ b/sdks/python/boxlite/__init__.py @@ -65,7 +65,27 @@ # Import Python convenience wrappers (re-exported via __all__) try: from .codebox import CodeBox # noqa: F401 - from .errors import BoxliteError, ExecError, ParseError, TimeoutError # noqa: F401 + from .errors import ( # noqa: F401 + AlreadyExistsError, + BoxliteError, + ConfigError, + DatabaseError, + EngineError, + ExecError, + ExecutionError, + ImageError, + InternalError, + InvalidArgumentError, + InvalidStateError, + NetworkError, + NotFoundError, + ParseError, + PortalError, + RpcError, + StoppedError, + StorageError, + TimeoutError, + ) from .exec import ExecResult # noqa: F401 from .simplebox import SimpleBox # noqa: F401 @@ -75,8 +95,25 @@ "SimpleBox", "CodeBox", "ExecResult", - # Error types + # Error types (base) "BoxliteError", + # Error types (mapped from Rust) + "EngineError", + "ConfigError", + "StorageError", + "ImageError", + "PortalError", + "NetworkError", + "RpcError", + "InternalError", + "ExecutionError", + "NotFoundError", + "AlreadyExistsError", + "InvalidStateError", + "DatabaseError", + "InvalidArgumentError", + "StoppedError", + # Error types (Python convenience) "ExecError", "TimeoutError", "ParseError", diff --git a/sdks/python/boxlite/errors.py b/sdks/python/boxlite/errors.py index 482c7301..49d8165f 100644 --- a/sdks/python/boxlite/errors.py +++ b/sdks/python/boxlite/errors.py @@ -1,10 +1,31 @@ """ BoxLite error types. -Provides a hierarchy of exceptions for different failure modes. +Provides a hierarchy of exceptions matching the Rust BoxliteError variants. """ -__all__ = ["BoxliteError", "ExecError", "TimeoutError", "ParseError"] +__all__ = [ + "BoxliteError", + "EngineError", + "ConfigError", + "StorageError", + "ImageError", + "PortalError", + "NetworkError", + "RpcError", + "InternalError", + "ExecutionError", + "NotFoundError", + "AlreadyExistsError", + "InvalidStateError", + "DatabaseError", + "InvalidArgumentError", + "StoppedError", + # Convenience aliases + "ExecError", + "TimeoutError", + "ParseError", +] class BoxliteError(Exception): @@ -13,6 +34,102 @@ class BoxliteError(Exception): pass +# ── Mapped from Rust BoxliteError variants ─────────────────────────────── + + +class EngineError(BoxliteError): + """Raised when the VM engine reports an error.""" + + pass + + +class ConfigError(BoxliteError): + """Raised for configuration errors (invalid options, incompatible settings).""" + + pass + + +class StorageError(BoxliteError): + """Raised when a filesystem or storage operation fails.""" + + pass + + +class ImageError(BoxliteError): + """Raised when image pull, resolution, or extraction fails.""" + + pass + + +class PortalError(BoxliteError): + """Raised when host-guest communication (gRPC portal) fails.""" + + pass + + +class NetworkError(BoxliteError): + """Raised when a networking operation fails.""" + + pass + + +class RpcError(BoxliteError): + """Raised when a gRPC or transport-level error occurs.""" + + pass + + +class InternalError(BoxliteError): + """Raised for unexpected internal errors.""" + + pass + + +class ExecutionError(BoxliteError): + """Raised when command execution fails at the runtime level.""" + + pass + + +class NotFoundError(BoxliteError): + """Raised when a box or resource is not found.""" + + pass + + +class AlreadyExistsError(BoxliteError): + """Raised when a box or resource already exists.""" + + pass + + +class InvalidStateError(BoxliteError): + """Raised when a box is in the wrong state for the requested operation.""" + + pass + + +class DatabaseError(BoxliteError): + """Raised when a database operation fails.""" + + pass + + +class InvalidArgumentError(BoxliteError): + """Raised when an invalid argument is provided.""" + + pass + + +class StoppedError(BoxliteError): + """Raised when operating on a stopped box or shutdown runtime.""" + + pass + + +# ── Convenience exceptions (Python-side only) ──────────────────────────── + + class ExecError(BoxliteError): """ Raised when a command execution fails (non-zero exit code). diff --git a/sdks/python/src/box_handle.rs b/sdks/python/src/box_handle.rs index a2c2ad20..b4e8a757 100644 --- a/sdks/python/src/box_handle.rs +++ b/sdks/python/src/box_handle.rs @@ -5,7 +5,7 @@ use crate::info::PyBoxInfo; use crate::metrics::PyBoxMetrics; use crate::snapshot_options::{PyCloneOptions, PyExportOptions}; use crate::snapshots::PySnapshotHandle; -use crate::util::map_err; +use crate::util::map_boxlite_err; use boxlite::{BoxCommand, CloneOptions, ExportOptions, LiteBox}; use pyo3::prelude::*; @@ -78,7 +78,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_boxlite_err)?; Ok(PyExecution { execution: Arc::new(execution), @@ -91,7 +91,7 @@ impl PyBox { let handle = Arc::clone(&self.handle); pyo3_async_runtimes::tokio::future_into_py(py, async move { - handle.start().await.map_err(map_err)?; + handle.start().await.map_err(map_boxlite_err)?; Ok(()) }) } @@ -101,7 +101,7 @@ impl PyBox { let handle = Arc::clone(&self.handle); pyo3_async_runtimes::tokio::future_into_py(py, async move { - handle.stop().await.map_err(map_err)?; + handle.stop().await.map_err(map_boxlite_err)?; Ok(()) }) } @@ -110,7 +110,7 @@ impl PyBox { let handle = Arc::clone(&self.handle); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let metrics = handle.metrics().await.map_err(map_err)?; + let metrics = handle.metrics().await.map_err(map_boxlite_err)?; Ok(PyBoxMetrics::from(metrics)) }) } @@ -129,7 +129,7 @@ impl PyBox { let archive = handle .export(options, std::path::Path::new(&dest)) .await - .map_err(map_err)?; + .map_err(map_boxlite_err)?; Ok(archive.path().to_string_lossy().to_string()) }) } @@ -145,7 +145,10 @@ impl PyBox { let handle = Arc::clone(&self.handle); let options: CloneOptions = options.map(Into::into).unwrap_or_default(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let cloned = handle.clone_box(options, name).await.map_err(map_err)?; + let cloned = handle + .clone_box(options, name) + .await + .map_err(map_boxlite_err)?; Ok(PyBox { handle: Arc::new(cloned), }) @@ -169,7 +172,7 @@ impl PyBox { handle .copy_into(std::path::Path::new(&host_path), &container_dest, opts) .await - .map_err(map_err)?; + .map_err(map_boxlite_err)?; Ok(()) }) } @@ -191,7 +194,7 @@ impl PyBox { handle .copy_out(&container_src, std::path::Path::new(&host_dest), opts) .await - .map_err(map_err)?; + .map_err(map_boxlite_err)?; Ok(()) }) } @@ -202,7 +205,7 @@ impl PyBox { pyo3_async_runtimes::tokio::future_into_py(py, async move { // Auto-start on context entry - handle.start().await.map_err(map_err)?; + handle.start().await.map_err(map_boxlite_err)?; Ok(PyBox { handle }) }) } @@ -218,7 +221,7 @@ impl PyBox { let handle = Arc::clone(&slf.handle); pyo3_async_runtimes::tokio::future_into_py(py, async move { - handle.stop().await.map_err(map_err)?; + handle.stop().await.map_err(map_boxlite_err)?; Ok(()) }) } diff --git a/sdks/python/src/event_listener.rs b/sdks/python/src/event_listener.rs new file mode 100644 index 00000000..178fe25b --- /dev/null +++ b/sdks/python/src/event_listener.rs @@ -0,0 +1,161 @@ +//! Python bindings for the EventListener trait. +//! +//! Bridges Python callback objects to the Rust `EventListener` trait. +//! Python users pass an object with optional `on_*` methods; only implemented +//! methods are called. + +use std::time::Duration; + +use boxlite::BoxID; +use boxlite::event_listener::EventListener; +use pyo3::prelude::*; + +/// Python-side event listener that delegates to a Python object. +/// +/// The Python object can implement any subset of the callback methods: +/// +/// ```python +/// class MyListener: +/// def on_box_created(self, box_id: str) -> None: ... +/// def on_box_started(self, box_id: str) -> None: ... +/// def on_box_stopped(self, box_id: str, exit_code: int | None) -> None: ... +/// def on_box_removed(self, box_id: str) -> None: ... +/// def on_exec_started(self, box_id: str, command: str, args: list[str]) -> None: ... +/// def on_exec_completed(self, box_id: str, command: str, exit_code: int, duration_secs: float) -> None: ... +/// def on_file_copied_in(self, box_id: str, host_src: str, container_dst: str) -> None: ... +/// def on_file_copied_out(self, box_id: str, container_src: str, host_dst: str) -> None: ... +/// ``` +/// +/// Missing methods are silently skipped (no-op). +pub(crate) struct PyEventListener { + callback: Py, +} + +// SAFETY: `Py` is `Send` in PyO3 0.27 but not `Sync`. We need `Sync` +// because `EventListener: Send + Sync` (listeners are shared across async tasks +// via `Arc`). This `impl Sync` is sound because: +// +// 1. The only field (`callback: Py`) is never accessed outside +// `Python::attach` blocks, which acquire the GIL before any interaction. +// 2. `Py::call_method1` with a valid `Python<'_>` token is safe to invoke +// from multiple threads — the GIL serializes all Python execution. +// 3. The `Py` is never cloned or dropped outside GIL-protected contexts +// (only `Arc` is cloned, which is an atomic refcount op). +// +// INVARIANT: Do NOT access `self.callback` outside a `Python::attach` closure. +// Violating this invariant would be undefined behavior. +// +// NOTE: This assumes CPython's GIL. Free-threaded Python (PEP 703) would +// require revisiting this safety argument. +unsafe impl Sync for PyEventListener {} + +impl PyEventListener { + pub(crate) fn new(callback: Py) -> Self { + Self { callback } + } +} + +/// Log non-AttributeError callback failures. AttributeError means the Python +/// object didn't implement that particular method, which is expected. +fn log_callback_err(py: Python<'_>, method: &str, err: &PyErr) { + if !err.is_instance_of::(py) { + tracing::warn!("EventListener.{} callback error: {}", method, err); + } +} + +impl EventListener for PyEventListener { + fn on_box_created(&self, box_id: &BoxID) { + let id = box_id.to_string(); + Python::attach(|py| { + if let Err(ref e) = self.callback.call_method1(py, "on_box_created", (id,)) { + log_callback_err(py, "on_box_created", e); + } + }); + } + + fn on_box_started(&self, box_id: &BoxID) { + let id = box_id.to_string(); + Python::attach(|py| { + if let Err(ref e) = self.callback.call_method1(py, "on_box_started", (id,)) { + log_callback_err(py, "on_box_started", e); + } + }); + } + + fn on_box_stopped(&self, box_id: &BoxID, exit_code: Option) { + let id = box_id.to_string(); + Python::attach(|py| { + if let Err(ref e) = self + .callback + .call_method1(py, "on_box_stopped", (id, exit_code)) + { + log_callback_err(py, "on_box_stopped", e); + } + }); + } + + fn on_box_removed(&self, box_id: &BoxID) { + let id = box_id.to_string(); + Python::attach(|py| { + if let Err(ref e) = self.callback.call_method1(py, "on_box_removed", (id,)) { + log_callback_err(py, "on_box_removed", e); + } + }); + } + + fn on_exec_started(&self, box_id: &BoxID, command: &str, args: &[String]) { + let id = box_id.to_string(); + let cmd = command.to_string(); + let a = args.to_vec(); + Python::attach(|py| { + if let Err(ref e) = self + .callback + .call_method1(py, "on_exec_started", (id, cmd, a)) + { + log_callback_err(py, "on_exec_started", e); + } + }); + } + + fn on_exec_completed(&self, box_id: &BoxID, command: &str, exit_code: i32, duration: Duration) { + let id = box_id.to_string(); + let cmd = command.to_string(); + let secs = duration.as_secs_f64(); + Python::attach(|py| { + if let Err(ref e) = + self.callback + .call_method1(py, "on_exec_completed", (id, cmd, exit_code, secs)) + { + log_callback_err(py, "on_exec_completed", e); + } + }); + } + + fn on_file_copied_in(&self, box_id: &BoxID, host_src: &str, container_dst: &str) { + let id = box_id.to_string(); + let src = host_src.to_string(); + let dst = container_dst.to_string(); + Python::attach(|py| { + if let Err(ref e) = self + .callback + .call_method1(py, "on_file_copied_in", (id, src, dst)) + { + log_callback_err(py, "on_file_copied_in", e); + } + }); + } + + fn on_file_copied_out(&self, box_id: &BoxID, container_src: &str, host_dst: &str) { + let id = box_id.to_string(); + let src = container_src.to_string(); + let dst = host_dst.to_string(); + Python::attach(|py| { + if let Err(ref e) = self + .callback + .call_method1(py, "on_file_copied_out", (id, src, dst)) + { + log_callback_err(py, "on_file_copied_out", e); + } + }); + } +} diff --git a/sdks/python/src/exec.rs b/sdks/python/src/exec.rs index 269cf4f1..d1593e13 100644 --- a/sdks/python/src/exec.rs +++ b/sdks/python/src/exec.rs @@ -1,4 +1,4 @@ -use crate::util::map_err; +use crate::util::{map_boxlite_err, map_err}; use boxlite::Execution; use pyo3::{Bound, PyAny, PyRef, PyResult, Python, pyclass, pymethods}; use std::sync::Arc; @@ -170,7 +170,7 @@ impl PyExecution { pyo3_async_runtimes::tokio::future_into_py(py, async move { let execution_mut = unsafe { &mut *(Arc::as_ptr(&execution) as *mut Execution) }; - let exec_result = execution_mut.wait().await.map_err(map_err)?; + let exec_result = execution_mut.wait().await.map_err(map_boxlite_err)?; Ok(PyExecResult { exit_code: exec_result.exit_code, error_message: exec_result.error_message, @@ -183,7 +183,7 @@ impl PyExecution { pyo3_async_runtimes::tokio::future_into_py(py, async move { let execution_mut = unsafe { &mut *(Arc::as_ptr(&execution) as *mut Execution) }; - execution_mut.kill().await.map_err(map_err)?; + execution_mut.kill().await.map_err(map_boxlite_err)?; Ok(()) }) } @@ -195,7 +195,10 @@ impl PyExecution { let execution = Arc::clone(&self.execution); pyo3_async_runtimes::tokio::future_into_py(py, async move { - execution.resize_tty(rows, cols).await.map_err(map_err)?; + execution + .resize_tty(rows, cols) + .await + .map_err(map_boxlite_err)?; Ok(()) }) } diff --git a/sdks/python/src/lib.rs b/sdks/python/src/lib.rs index de51dc8c..0bd714a6 100644 --- a/sdks/python/src/lib.rs +++ b/sdks/python/src/lib.rs @@ -2,6 +2,7 @@ mod advanced_options; mod box_handle; +mod event_listener; mod exec; mod info; mod metrics; diff --git a/sdks/python/src/options.rs b/sdks/python/src/options.rs index b4c82ee1..af4eb186 100644 --- a/sdks/python/src/options.rs +++ b/sdks/python/src/options.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; +use std::sync::Arc; use boxlite::BoxliteRestOptions; +use boxlite::event_listener::EventListener; use boxlite::litebox::copy::CopyOptions; use boxlite::runtime::advanced_options::{HealthCheckOptions, SecurityOptions}; use boxlite::runtime::constants::images; @@ -12,33 +14,55 @@ use pyo3::prelude::*; use pyo3::types::{PyAnyMethods, PyDict, PyTuple}; use crate::advanced_options::PyAdvancedBoxOptions; +use crate::event_listener::PyEventListener; +/// Runtime configuration options. +/// +/// Args: +/// home_dir: Override the default BoxLite home directory (~/.boxlite). +/// image_registries: Registries to search for unqualified image references. +/// Tried in order; first successful pull wins. +/// event_listeners: List of event listener objects for lifecycle callbacks. +/// Each object can implement any subset of: on_box_created, on_box_started, +/// on_box_stopped, on_box_removed, on_exec_started, on_exec_completed, +/// on_file_copied_in, on_file_copied_out. #[pyclass(name = "Options")] -#[derive(Clone, Debug)] +#[derive(Clone)] pub(crate) struct PyOptions { #[pyo3(get, set)] pub(crate) home_dir: Option, - /// Registries to search for unqualified image references. - /// Tried in order; first successful pull wins. #[pyo3(get, set)] pub(crate) image_registries: Vec, + /// Converted from Python objects to Arc at construction time. + pub(crate) event_listeners: Vec>, } #[pymethods] impl PyOptions { #[new] - #[pyo3(signature = (home_dir=None, image_registries=vec![]))] - fn new(home_dir: Option, image_registries: Vec) -> Self { + #[pyo3(signature = (home_dir=None, image_registries=vec![], event_listeners=vec![]))] + fn new( + home_dir: Option, + image_registries: Vec, + event_listeners: Vec>, + ) -> Self { + let listeners: Vec> = event_listeners + .into_iter() + .map(|obj| Arc::new(PyEventListener::new(obj)) as Arc) + .collect(); Self { home_dir, image_registries, + event_listeners: listeners, } } fn __repr__(&self) -> String { format!( - "Options(home_dir={:?}, image_registries={:?})", - self.home_dir, self.image_registries + "Options(home_dir={:?}, image_registries={:?}, event_listeners=[{} listeners])", + self.home_dir, + self.image_registries, + self.event_listeners.len() ) } } @@ -52,6 +76,7 @@ impl From for BoxliteOptions { } config.image_registries = py_opts.image_registries; + config.event_listeners = py_opts.event_listeners; config } diff --git a/sdks/python/src/runtime.rs b/sdks/python/src/runtime.rs index 49aeb0d5..f508a5d9 100644 --- a/sdks/python/src/runtime.rs +++ b/sdks/python/src/runtime.rs @@ -7,7 +7,7 @@ use crate::box_handle::PyBox; use crate::info::PyBoxInfo; use crate::metrics::PyRuntimeMetrics; use crate::options::{PyBoxOptions, PyBoxliteRestOptions, PyOptions}; -use crate::util::map_err; +use crate::util::map_boxlite_err; #[pyclass(name = "Boxlite")] pub(crate) struct PyBoxlite { @@ -18,7 +18,7 @@ pub(crate) struct PyBoxlite { impl PyBoxlite { #[new] fn new(options: PyOptions) -> PyResult { - let runtime = BoxliteRuntime::new(options.into()).map_err(map_err)?; + let runtime = BoxliteRuntime::new(options.into()).map_err(map_boxlite_err)?; Ok(Self { runtime: Arc::new(runtime), @@ -47,7 +47,7 @@ impl PyBoxlite { /// runtime = boxlite.Boxlite.rest(opts) #[staticmethod] fn rest(options: PyBoxliteRestOptions) -> PyResult { - let runtime = BoxliteRuntime::rest(options.into()).map_err(map_err)?; + let runtime = BoxliteRuntime::rest(options.into()).map_err(map_boxlite_err)?; Ok(Self { runtime: Arc::new(runtime), }) @@ -55,7 +55,7 @@ impl PyBoxlite { #[staticmethod] fn init_default(options: PyOptions) -> PyResult<()> { - BoxliteRuntime::init_default_runtime(options.into()).map_err(map_err) + BoxliteRuntime::init_default_runtime(options.into()).map_err(map_boxlite_err) } #[pyo3(signature = (options, name=None))] @@ -68,7 +68,7 @@ impl PyBoxlite { let runtime = Arc::clone(&self.runtime); let opts = options.into(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let handle = runtime.create(opts, name).await.map_err(map_err)?; + let handle = runtime.create(opts, name).await.map_err(map_boxlite_err)?; Ok(PyBox { handle: Arc::new(handle), }) @@ -83,7 +83,7 @@ impl PyBoxlite { ) -> PyResult> { let runtime = Arc::clone(&self.runtime); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let infos = runtime.list_info().await.map_err(map_err)?; + let infos = runtime.list_info().await.map_err(map_boxlite_err)?; Ok(infos.into_iter().map(PyBoxInfo::from).collect::>()) }) } @@ -95,7 +95,7 @@ impl PyBoxlite { Ok(runtime .get_info(&id_or_name) .await - .map_err(map_err)? + .map_err(map_boxlite_err)? .map(PyBoxInfo::from)) }) } @@ -106,7 +106,7 @@ impl PyBoxlite { pyo3_async_runtimes::tokio::future_into_py(py, async move { tracing::trace!("Python get() called with id_or_name={}", id_or_name); - let result = runtime.get(&id_or_name).await.map_err(map_err)?; + let result = runtime.get(&id_or_name).await.map_err(map_boxlite_err)?; tracing::trace!("Rust get() returned: is_some={}", result.is_some()); @@ -133,7 +133,10 @@ impl PyBoxlite { let runtime = Arc::clone(&self.runtime); let opts = options.into(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let (handle, created) = runtime.get_or_create(opts, name).await.map_err(map_err)?; + let (handle, created) = runtime + .get_or_create(opts, name) + .await + .map_err(map_boxlite_err)?; Ok(( PyBox { handle: Arc::new(handle), @@ -146,7 +149,7 @@ impl PyBoxlite { fn metrics<'py>(&self, py: Python<'py>) -> PyResult> { let runtime = Arc::clone(&self.runtime); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let metrics = runtime.metrics().await.map_err(map_err)?; + let metrics = runtime.metrics().await.map_err(map_boxlite_err)?; Ok(PyRuntimeMetrics::from(metrics)) }) } @@ -161,7 +164,10 @@ impl PyBoxlite { ) -> PyResult> { let runtime = Arc::clone(&self.runtime); pyo3_async_runtimes::tokio::future_into_py(py, async move { - runtime.remove(&id_or_name, force).await.map_err(map_err)?; + runtime + .remove(&id_or_name, force) + .await + .map_err(map_boxlite_err)?; Ok(()) }) } @@ -175,7 +181,7 @@ impl PyBoxlite { fn shutdown<'py>(&self, py: Python<'py>, timeout: Option) -> PyResult> { let runtime = Arc::clone(&self.runtime); pyo3_async_runtimes::tokio::future_into_py(py, async move { - runtime.shutdown(timeout).await.map_err(map_err)?; + runtime.shutdown(timeout).await.map_err(map_boxlite_err)?; Ok(()) }) } @@ -198,7 +204,10 @@ impl PyBoxlite { let runtime = Arc::clone(&self.runtime); let archive = BoxArchive::new(archive_path); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let handle = runtime.import_box(archive, name).await.map_err(map_err)?; + let handle = runtime + .import_box(archive, name) + .await + .map_err(map_boxlite_err)?; Ok(PyBox { handle: Arc::new(handle), }) diff --git a/sdks/python/src/snapshots.rs b/sdks/python/src/snapshots.rs index 36c50153..1bee2281 100644 --- a/sdks/python/src/snapshots.rs +++ b/sdks/python/src/snapshots.rs @@ -6,7 +6,7 @@ use boxlite::{LiteBox, SnapshotInfo, SnapshotOptions}; use pyo3::prelude::*; use crate::snapshot_options::PySnapshotOptions; -use crate::util::map_err; +use crate::util::map_boxlite_err; /// Snapshot metadata. #[pyclass(name = "SnapshotInfo")] @@ -73,7 +73,7 @@ impl PySnapshotHandle { .snapshots() .create(options, &name) .await - .map_err(map_err)?; + .map_err(map_boxlite_err)?; Ok(PySnapshotInfo::from(info)) }) } @@ -82,7 +82,7 @@ impl PySnapshotHandle { fn list<'py>(&self, py: Python<'py>) -> PyResult> { let handle = Arc::clone(&self.handle); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let infos = handle.snapshots().list().await.map_err(map_err)?; + let infos = handle.snapshots().list().await.map_err(map_boxlite_err)?; Ok(infos .into_iter() .map(PySnapshotInfo::from) @@ -94,7 +94,11 @@ impl PySnapshotHandle { fn get<'py>(&self, py: Python<'py>, name: String) -> PyResult> { let handle = Arc::clone(&self.handle); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let info = handle.snapshots().get(&name).await.map_err(map_err)?; + let info = handle + .snapshots() + .get(&name) + .await + .map_err(map_boxlite_err)?; Ok(info.map(PySnapshotInfo::from)) }) } @@ -103,7 +107,11 @@ impl PySnapshotHandle { fn remove<'py>(&self, py: Python<'py>, name: String) -> PyResult> { let handle = Arc::clone(&self.handle); pyo3_async_runtimes::tokio::future_into_py(py, async move { - handle.snapshots().remove(&name).await.map_err(map_err)?; + handle + .snapshots() + .remove(&name) + .await + .map_err(map_boxlite_err)?; Ok(()) }) } @@ -112,7 +120,11 @@ impl PySnapshotHandle { fn restore<'py>(&self, py: Python<'py>, name: String) -> PyResult> { let handle = Arc::clone(&self.handle); pyo3_async_runtimes::tokio::future_into_py(py, async move { - handle.snapshots().restore(&name).await.map_err(map_err)?; + handle + .snapshots() + .restore(&name) + .await + .map_err(map_boxlite_err)?; Ok(()) }) } diff --git a/sdks/python/src/util.rs b/sdks/python/src/util.rs index ea544c5b..8ac7a979 100644 --- a/sdks/python/src/util.rs +++ b/sdks/python/src/util.rs @@ -1,5 +1,55 @@ -use pyo3::{exceptions::PyRuntimeError, prelude::*}; +use boxlite::BoxliteError; +use pyo3::exceptions::PyRuntimeError; +use pyo3::import_exception; +use pyo3::prelude::*; +// Import Python exception classes defined in boxlite.errors +import_exception!(boxlite.errors, EngineError); +import_exception!(boxlite.errors, ConfigError); +import_exception!(boxlite.errors, StorageError); +import_exception!(boxlite.errors, ImageError); +import_exception!(boxlite.errors, PortalError); +import_exception!(boxlite.errors, NetworkError); +import_exception!(boxlite.errors, RpcError); +import_exception!(boxlite.errors, InternalError); +import_exception!(boxlite.errors, ExecutionError); +import_exception!(boxlite.errors, NotFoundError); +import_exception!(boxlite.errors, AlreadyExistsError); +import_exception!(boxlite.errors, InvalidStateError); +import_exception!(boxlite.errors, DatabaseError); +import_exception!(boxlite.errors, InvalidArgumentError); +import_exception!(boxlite.errors, StoppedError); + +/// Map a BoxliteError to its corresponding typed Python exception. +pub(crate) fn map_boxlite_err(err: BoxliteError) -> PyErr { + let msg = err.to_string(); + match err { + BoxliteError::UnsupportedEngine => EngineError::new_err(msg), + BoxliteError::Engine(_) => EngineError::new_err(msg), + BoxliteError::Config(_) => ConfigError::new_err(msg), + BoxliteError::Storage(_) => StorageError::new_err(msg), + BoxliteError::Image(_) => ImageError::new_err(msg), + BoxliteError::Portal(_) => PortalError::new_err(msg), + BoxliteError::Network(_) => NetworkError::new_err(msg), + // Rpc + RpcTransport both map to RpcError (low-level gRPC distinction not useful to Python users) + BoxliteError::Rpc(_) | BoxliteError::RpcTransport(_) => RpcError::new_err(msg), + BoxliteError::Internal(_) => InternalError::new_err(msg), + BoxliteError::Execution(_) => ExecutionError::new_err(msg), + // Unsupported -> EngineError: intentional simplification to avoid exception class explosion. + // "Unsupported" errors are rare platform-level constraints (e.g., "feature X not on this OS"). + BoxliteError::Unsupported(_) => EngineError::new_err(msg), + BoxliteError::NotFound(_) => NotFoundError::new_err(msg), + BoxliteError::AlreadyExists(_) => AlreadyExistsError::new_err(msg), + BoxliteError::InvalidState(_) => InvalidStateError::new_err(msg), + BoxliteError::Database(_) => DatabaseError::new_err(msg), + // MetadataError -> InternalError: metadata corruption is an internal concern, not actionable by SDK users. + BoxliteError::MetadataError(_) => InternalError::new_err(msg), + BoxliteError::InvalidArgument(_) => InvalidArgumentError::new_err(msg), + BoxliteError::Stopped(_) => StoppedError::new_err(msg), + } +} + +/// Fallback for non-BoxliteError types (preserves backwards compatibility). pub(crate) fn map_err(err: impl std::fmt::Display) -> PyErr { PyRuntimeError::new_err(err.to_string()) } diff --git a/sdks/python/tests/test_errors.py b/sdks/python/tests/test_errors.py index fc7703c3..8b4f44d6 100644 --- a/sdks/python/tests/test_errors.py +++ b/sdks/python/tests/test_errors.py @@ -5,7 +5,27 @@ """ import pytest -from boxlite.errors import BoxliteError, ExecError, TimeoutError, ParseError +from boxlite.errors import ( + AlreadyExistsError, + BoxliteError, + ConfigError, + DatabaseError, + EngineError, + ExecError, + ExecutionError, + ImageError, + InternalError, + InvalidArgumentError, + InvalidStateError, + NetworkError, + NotFoundError, + ParseError, + PortalError, + RpcError, + StoppedError, + StorageError, + TimeoutError, +) class TestBoxliteError: @@ -104,21 +124,289 @@ def test_can_catch_as_boxlite_error(self): raise ParseError("parse error") +class TestEngineError: + """Test EngineError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(EngineError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(EngineError): + raise EngineError("engine crashed") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise EngineError("engine error") + + def test_message(self): + err = EngineError("unsupported engine: kvm") + assert str(err) == "unsupported engine: kvm" + + +class TestConfigError: + """Test ConfigError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(ConfigError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(ConfigError): + raise ConfigError("invalid config") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise ConfigError("bad option") + + +class TestStorageError: + """Test StorageError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(StorageError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(StorageError): + raise StorageError("disk full") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise StorageError("I/O error") + + +class TestImageError: + """Test ImageError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(ImageError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(ImageError): + raise ImageError("image not found") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise ImageError("pull failed") + + +class TestPortalError: + """Test PortalError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(PortalError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(PortalError): + raise PortalError("portal connection lost") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise PortalError("gRPC portal error") + + +class TestNetworkError: + """Test NetworkError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(NetworkError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(NetworkError): + raise NetworkError("network unreachable") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise NetworkError("DNS resolution failed") + + +class TestRpcError: + """Test RpcError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(RpcError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(RpcError): + raise RpcError("gRPC status: UNAVAILABLE") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise RpcError("transport error") + + +class TestInternalError: + """Test InternalError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(InternalError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(InternalError): + raise InternalError("unexpected state") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise InternalError("internal error") + + +class TestExecutionError: + """Test ExecutionError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(ExecutionError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(ExecutionError): + raise ExecutionError("execution failed") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise ExecutionError("runtime exec error") + + +class TestNotFoundError: + """Test NotFoundError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(NotFoundError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(NotFoundError): + raise NotFoundError("box abc123 not found") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise NotFoundError("resource not found") + + +class TestAlreadyExistsError: + """Test AlreadyExistsError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(AlreadyExistsError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(AlreadyExistsError): + raise AlreadyExistsError("box already exists") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise AlreadyExistsError("duplicate") + + +class TestInvalidStateError: + """Test InvalidStateError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(InvalidStateError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(InvalidStateError): + raise InvalidStateError("box is stopped") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise InvalidStateError("wrong state") + + +class TestDatabaseError: + """Test DatabaseError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(DatabaseError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(DatabaseError): + raise DatabaseError("SQLite error: table not found") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise DatabaseError("db error") + + +class TestInvalidArgumentError: + """Test InvalidArgumentError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(InvalidArgumentError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(InvalidArgumentError): + raise InvalidArgumentError("invalid memory: -1") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise InvalidArgumentError("bad argument") + + +class TestStoppedError: + """Test StoppedError exception.""" + + def test_inherits_boxlite_error(self): + assert issubclass(StoppedError, BoxliteError) + + def test_can_raise(self): + with pytest.raises(StoppedError): + raise StoppedError("runtime is shut down") + + def test_can_catch_as_boxlite_error(self): + with pytest.raises(BoxliteError): + raise StoppedError("stopped") + + +# ── All typed exceptions: parametrized tests ───────────────────────────── + +# Complete list of the 15 Rust-mapped exception classes +RUST_MAPPED_EXCEPTIONS = [ + EngineError, + ConfigError, + StorageError, + ImageError, + PortalError, + NetworkError, + RpcError, + InternalError, + ExecutionError, + NotFoundError, + AlreadyExistsError, + InvalidStateError, + DatabaseError, + InvalidArgumentError, + StoppedError, +] + +# All 18 exception classes (Rust-mapped + Python convenience) +ALL_EXCEPTIONS = RUST_MAPPED_EXCEPTIONS + [ExecError, TimeoutError, ParseError] + + class TestErrorHierarchy: """Test the complete error hierarchy.""" - def test_all_errors_inherit_from_base(self): - """Test that all error types inherit from BoxliteError.""" - assert issubclass(ExecError, BoxliteError) - assert issubclass(TimeoutError, BoxliteError) - assert issubclass(ParseError, BoxliteError) - - def test_all_errors_are_exceptions(self): - """Test that all error types are Exceptions.""" - assert issubclass(BoxliteError, Exception) - assert issubclass(ExecError, Exception) - assert issubclass(TimeoutError, Exception) - assert issubclass(ParseError, Exception) + @pytest.mark.parametrize( + "exc_class", + RUST_MAPPED_EXCEPTIONS, + ids=lambda c: c.__name__, + ) + def test_rust_mapped_errors_inherit_from_base(self, exc_class): + """Every Rust-mapped exception inherits from BoxliteError.""" + assert issubclass(exc_class, BoxliteError) + + @pytest.mark.parametrize( + "exc_class", + ALL_EXCEPTIONS, + ids=lambda c: c.__name__, + ) + def test_all_errors_are_exceptions(self, exc_class): + """Every exception type is a subclass of Exception.""" + assert issubclass(exc_class, Exception) + + @pytest.mark.parametrize( + "exc_class", + RUST_MAPPED_EXCEPTIONS, + ids=lambda c: c.__name__, + ) + def test_rust_mapped_errors_directly_inherit_base(self, exc_class): + """Rust-mapped exceptions inherit directly from BoxliteError (flat hierarchy).""" + assert BoxliteError in exc_class.__bases__ def test_catch_all_with_base_class(self): """Test catching all boxlite errors with base class.""" @@ -127,6 +415,21 @@ def test_catch_all_with_base_class(self): ExecError("cmd", 1, "err"), TimeoutError("timeout"), ParseError("parse"), + EngineError("engine"), + ConfigError("config"), + StorageError("storage"), + ImageError("image"), + PortalError("portal"), + NetworkError("network"), + RpcError("rpc"), + InternalError("internal"), + ExecutionError("execution"), + NotFoundError("not found"), + AlreadyExistsError("exists"), + InvalidStateError("state"), + DatabaseError("db"), + InvalidArgumentError("arg"), + StoppedError("stopped"), ] for error in errors: @@ -135,27 +438,72 @@ def test_catch_all_with_base_class(self): except BoxliteError as e: assert e is error + @pytest.mark.parametrize( + "exc_class", + RUST_MAPPED_EXCEPTIONS, + ids=lambda c: c.__name__, + ) + def test_specific_catch_does_not_catch_siblings(self, exc_class): + """Catching one typed exception does not catch a different one.""" + # Pick a sibling that is different from exc_class + sibling = next(e for e in RUST_MAPPED_EXCEPTIONS if e is not exc_class) + with pytest.raises(sibling): + # This should NOT be caught by exc_class + try: + raise sibling("test") + except exc_class: + pytest.fail( + f"Catching {exc_class.__name__} should not catch {sibling.__name__}" + ) + class TestErrorExports: """Test that errors are properly exported.""" - def test_errors_in_module(self): - """Test that errors are exported from boxlite module.""" + EXPECTED_EXPORTS = [ + "BoxliteError", + # Rust-mapped + "EngineError", + "ConfigError", + "StorageError", + "ImageError", + "PortalError", + "NetworkError", + "RpcError", + "InternalError", + "ExecutionError", + "NotFoundError", + "AlreadyExistsError", + "InvalidStateError", + "DatabaseError", + "InvalidArgumentError", + "StoppedError", + # Python convenience + "ExecError", + "TimeoutError", + "ParseError", + ] + + @pytest.mark.parametrize("name", EXPECTED_EXPORTS) + def test_errors_in_module(self, name): + """Test that each error is exported from the boxlite module.""" import boxlite - assert hasattr(boxlite, "BoxliteError") - assert hasattr(boxlite, "ExecError") - assert hasattr(boxlite, "TimeoutError") - assert hasattr(boxlite, "ParseError") + assert hasattr(boxlite, name), f"boxlite.{name} should be exported" + + @pytest.mark.parametrize("name", EXPECTED_EXPORTS) + def test_errors_from_errors_module(self, name): + """Test that each error can be imported from boxlite.errors.""" + import boxlite.errors + + assert hasattr(boxlite.errors, name), f"boxlite.errors.{name} should exist" - def test_errors_from_errors_module(self): - """Test that errors can be imported from errors module.""" - from boxlite.errors import BoxliteError, ExecError, TimeoutError, ParseError + def test_errors_in_errors_module_all(self): + """Test that all 18 exceptions are listed in errors.__all__.""" + from boxlite import errors - assert BoxliteError is not None - assert ExecError is not None - assert TimeoutError is not None - assert ParseError is not None + for name in self.EXPECTED_EXPORTS: + assert name in errors.__all__, f"{name} should be in errors.__all__" if __name__ == "__main__": diff --git a/test-utils/src/box_test.rs b/test-utils/src/box_test.rs index 3b7f1f13..c88dd651 100644 --- a/test-utils/src/box_test.rs +++ b/test-utils/src/box_test.rs @@ -67,6 +67,7 @@ impl BoxTestBase { let runtime = BoxliteRuntime::new(BoxliteOptions { home_dir: home.path.clone(), image_registries: test_registries(), + ..Default::default() }) .expect("create BoxTestBase runtime"); @@ -88,6 +89,7 @@ impl BoxTestBase { let runtime = BoxliteRuntime::new(BoxliteOptions { home_dir: home.path.clone(), image_registries: test_registries(), + ..Default::default() }) .expect("create isolated BoxTestBase runtime"); diff --git a/test-utils/src/cache.rs b/test-utils/src/cache.rs index 77f8108d..d976a680 100644 --- a/test-utils/src/cache.rs +++ b/test-utils/src/cache.rs @@ -176,6 +176,7 @@ impl SharedResources { let runtime = BoxliteRuntime::new(BoxliteOptions { home_dir: home.clone(), image_registries: test_registries(), + ..Default::default() }) .unwrap(); diff --git a/test-utils/src/config_matrix.rs b/test-utils/src/config_matrix.rs index ac978f57..616e91ea 100644 --- a/test-utils/src/config_matrix.rs +++ b/test-utils/src/config_matrix.rs @@ -267,6 +267,7 @@ where let runtime = boxlite::BoxliteRuntime::new(BoxliteOptions { home_dir: home.path.clone(), image_registries: crate::test_registries(), + ..Default::default() }) .expect("create runtime for config matrix"); @@ -336,6 +337,7 @@ macro_rules! config_matrix_tests { ::boxlite::runtime::options::BoxliteOptions { home_dir: home.path.clone(), image_registries: $crate::test_registries(), + ..Default::default() } ).expect("create runtime for config matrix test");