Skip to content

feat(python-sdk): expose EventListener to Python SDK with typed error mapping#408

Open
lilongen wants to merge 1 commit intoboxlite-ai:mainfrom
lilongen:feat/python-event-listener-typed-errors
Open

feat(python-sdk): expose EventListener to Python SDK with typed error mapping#408
lilongen wants to merge 1 commit intoboxlite-ai:mainfrom
lilongen:feat/python-event-listener-typed-errors

Conversation

@lilongen
Copy link
Copy Markdown

Summary

  • Bridge the Rust EventListener trait (feat(audit): add audit logging for box operations #403) to Python via PyO3, enabling push-based
    lifecycle callbacks (on_box_started, on_exec_completed, etc.) for monitoring and
    instrumentation
  • Replace generic PyRuntimeError with 15 typed Python exception classes inheriting from
    BoxliteError, using exhaustive match on all 18 BoxliteError variants for compile-time
    completeness
  • Add 165 Python tests covering exception hierarchy, sibling isolation, and export
    completeness

Motivation

The Rust core added EventListener in #403 but no SDK exposed it — Python users had no
way 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)

  • PyEventListener bridge wraps a Python object and implements the Rust EventListener trait
  • Duck-typing pattern: users implement only the callbacks they need; missing methods are
    silently skipped via AttributeError detection
  • event_listeners parameter added to BoxliteOptions, propagated through RuntimeImpl
    to each BoxImpl
  • unsafe impl Sync with detailed safety comment (GIL serialization, PEP 703 caveat)

Typed error mapping (improved error handling)

  • 15 import_exception! macros import Python exception classes from boxlite.errors
  • map_boxlite_err() exhaustive match (no wildcard) maps every BoxliteError variant
    to the correct typed exception
  • 3 intentional simplifications documented with inline comments:
    Rpc+RpcTransportRpcError, UnsupportedEngineError,
    MetadataErrorInternalError
  • All existing map_err calls in runtime.rs, box_handle.rs, exec.rs, snapshots.rs
    updated to use map_boxlite_err where applicable

Files changed (17 files, +1050/-75)

Category Files
Rust core (EventListener plumbing) options.rs, rt_impl.rs, box_impl.rs
Python SDK Rust (bridge + errors) event_listener.rs (new), util.rs, options.rs, runtime.rs, box_handle.rs, exec.rs, snapshots.rs, lib.rs
Python SDK (exceptions + exports) errors.py, __init__.py
Tests test_errors.py (165 tests)
Test utils (compatibility) box_test.rs, cache.rs, config_matrix.rs

Usage example

class MyListener:
    def on_box_started(self, box_id: str):
        print(f"Box {box_id} started!")
    def on_exec_completed(self, box_id, command, exit_code, duration_secs):
        print(f"'{command}' finished in {duration_secs:.2f}s (exit={exit_code})")

opts = boxlite.Options(event_listeners=[MyListener()])
runtime = boxlite.Boxlite(opts)

# Typed error handling
try:
    box = await runtime.get("nonexistent")
except boxlite.NotFoundError:
    print("Box not found")
except boxlite.BoxliteError:
    print("Other boxlite error")

Test plan

  • cargo fmt -- --check — clean
  • cargo 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 — clean

Known limitations

  • 3 of 8 callbacks not yet wired in Rust core (on_box_created, on_box_removed, on_exec_completed) — bridge is ready, awaiting core call sites
  • map_boxlite_err and PyEventListener require built native extension for integration testing (documented)
  • unsafe impl Sync assumes CPython GIL; PEP 703 (free-threaded Python) noted in safety comment

🤖 Generated with Claude Code

… 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>
Copilot AI review requested due to automatic review settings March 27, 2026 01:27
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 EventListener into the Python SDK via a PyEventListener adapter and plumb event_listeners through runtime options into BoxImpl.
  • 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 BoxliteOptions field 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.

Comment on lines +58 to +64
/// 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);
}
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +50
// 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 {}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 65 to 90
# 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
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants