feat(python-sdk): expose EventListener to Python SDK with typed error mapping#408
feat(python-sdk): expose EventListener to Python SDK with typed error mapping#408lilongen wants to merge 1 commit intoboxlite-ai:mainfrom
Conversation
… mapping Bridge the Rust EventListener trait (boxlite-ai#403) to Python via PyO3, enabling push-based lifecycle callbacks (on_box_started, on_exec_completed, etc.). Replace generic PyRuntimeError with 15 typed exception classes inheriting from BoxliteError, using exhaustive match for compile-time completeness. Key changes: - PyEventListener bridge with duck-typing (missing methods silently skipped) - map_boxlite_err() maps all 18 BoxliteError variants to typed Python exceptions - event_listeners parameter on BoxliteOptions, propagated through RuntimeImpl - 165 Python tests covering exception hierarchy, isolation, and exports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds Python SDK support for Rust runtime lifecycle callbacks (EventListener) and upgrades Rust→Python error translation from a generic runtime error to a set of typed Python exceptions for programmatic handling.
Changes:
- Bridge Rust
EventListenerinto the Python SDK via aPyEventListeneradapter and plumbevent_listenersthrough runtime options intoBoxImpl. - Introduce typed Python exception classes and an exhaustive
BoxliteError→Python exception mapping (map_boxlite_err), updating SDK call sites to use it. - Expand Python tests to validate exception hierarchy and export completeness; adjust test-utils to accommodate new
BoxliteOptionsfield via..Default::default().
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
boxlite/src/runtime/options.rs |
Adds event_listeners to BoxliteOptions, updates Debug/Default, and adds serialization tests (skipping listeners). |
boxlite/src/runtime/rt_impl.rs |
Stores runtime-level listeners and clones them into each BoxImpl; adds plumbing tests. |
boxlite/src/litebox/box_impl.rs |
Extends BoxImpl::new to accept listeners and stores them. |
sdks/python/src/event_listener.rs |
New PyO3 bridge implementing Rust EventListener by calling optional Python on_* methods. |
sdks/python/src/util.rs |
Adds typed exception imports and map_boxlite_err for exhaustive error mapping. |
sdks/python/src/options.rs |
Extends Python Options to accept event_listeners and converts them to Rust listeners. |
sdks/python/src/runtime.rs |
Switches runtime bindings to map Rust BoxliteError through map_boxlite_err. |
sdks/python/src/box_handle.rs |
Switches box-handle bindings to use map_boxlite_err. |
sdks/python/src/exec.rs |
Uses map_boxlite_err for execution operations that return BoxliteError. |
sdks/python/src/snapshots.rs |
Switches snapshot bindings to use map_boxlite_err. |
sdks/python/src/lib.rs |
Registers the new event_listener module. |
sdks/python/boxlite/errors.py |
Defines the typed Python exception classes and updates __all__. |
sdks/python/boxlite/__init__.py |
Re-exports the new error classes from the package top-level. |
sdks/python/tests/test_errors.py |
Adds comprehensive tests for typed exceptions, hierarchy, and exports. |
test-utils/src/box_test.rs |
Uses struct update syntax to fill new BoxliteOptions fields via defaults. |
test-utils/src/cache.rs |
Uses struct update syntax to fill new BoxliteOptions fields via defaults. |
test-utils/src/config_matrix.rs |
Uses struct update syntax to fill new BoxliteOptions fields via defaults. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// 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::<pyo3::exceptions::PyAttributeError>(py) { | ||
| tracing::warn!("EventListener.{} callback error: {}", method, err); | ||
| } | ||
| } |
There was a problem hiding this comment.
The AttributeError-based “missing method” detection here will also suppress real AttributeError exceptions raised inside a user callback (they’ll be treated as “method not implemented” and only ignored). To avoid silently hiding bugs in listeners, check for the attribute first (e.g., hasattr/getattr) and only skip when the attribute is missing; if the attribute exists, log (or propagate) any exception including AttributeError.
| // SAFETY: `Py<PyAny>` 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<PyAny>`) 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<PyAny>` is never cloned or dropped outside GIL-protected contexts | ||
| // (only `Arc<PyEventListener>` 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 {} |
There was a problem hiding this comment.
The safety justification for unsafe impl Sync claims the Py<PyAny> is “never ... dropped outside GIL-protected contexts”, but PyEventListener can be dropped on any thread when the last Arc is released. Either remove/reword that claim (and rely on PyO3’s thread-safe drop semantics), or make the drop behavior explicit so the safety argument matches reality.
| # 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 |
There was a problem hiding this comment.
This single try/except couples exporting pure-Python error classes to importing CodeBox/ExecResult/SimpleBox. If CodeBox (or another convenience wrapper) fails to import (e.g., missing native extension), the error types won’t be re-exported from boxlite, even though boxlite.errors itself is available. Consider importing/re-exporting errors in a separate try block (or before CodeBox) so typed exceptions remain available independently.
Summary
EventListenertrait (feat(audit): add audit logging for box operations #403) to Python via PyO3, enabling push-basedlifecycle callbacks (
on_box_started,on_exec_completed, etc.) for monitoring andinstrumentation
PyRuntimeErrorwith 15 typed Python exception classes inheriting fromBoxliteError, using exhaustive match on all 18BoxliteErrorvariants for compile-timecompleteness
completeness
Motivation
The Rust core added
EventListenerin #403 but no SDK exposed it — Python users had noway to monitor box lifecycle events. Additionally, all Rust errors were mapped to a generic
RuntimeError, making programmatic error handling impossible.What changed
EventListener Python bindings (new capability)
PyEventListenerbridge wraps a Python object and implements the RustEventListenertraitsilently skipped via
AttributeErrordetectionevent_listenersparameter added toBoxliteOptions, propagated throughRuntimeImplto each
BoxImplunsafe impl Syncwith detailed safety comment (GIL serialization, PEP 703 caveat)Typed error mapping (improved error handling)
import_exception!macros import Python exception classes fromboxlite.errorsmap_boxlite_err()exhaustive match (no wildcard) maps everyBoxliteErrorvariantto the correct typed exception
Rpc+RpcTransport→RpcError,Unsupported→EngineError,MetadataError→InternalErrormap_errcalls inruntime.rs,box_handle.rs,exec.rs,snapshots.rsupdated to use
map_boxlite_errwhere applicableFiles changed (17 files, +1050/-75)
options.rs,rt_impl.rs,box_impl.rsevent_listener.rs(new),util.rs,options.rs,runtime.rs,box_handle.rs,exec.rs,snapshots.rs,lib.rserrors.py,__init__.pytest_errors.py(165 tests)box_test.rs,cache.rs,config_matrix.rsUsage example
Test plan
cargo fmt -- --check— cleancargo clippy -p boxlite --no-default-features --lib -- -D warnings— 0 warnings (macOS + Linux)cargo clippy -p boxlite-python --lib -- -D warnings— 0 warnings (macOS + Linux)cargo test -p boxlite --no-default-features --lib— 600 passed (macOS), 573 passed (Linux, 27 KVM-dependent pre-existing failures)pytest test_errors.py— 165 passed (macOS + Linux)ruff check+ruff format --check— cleanKnown limitations
on_box_created,on_box_removed,on_exec_completed) — bridge is ready, awaiting core call sitesmap_boxlite_errandPyEventListenerrequire built native extension for integration testing (documented)unsafe impl Syncassumes CPython GIL; PEP 703 (free-threaded Python) noted in safety comment🤖 Generated with Claude Code