fidius_python/error.rs
1// Copyright 2026 Colliery, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Bridge Python exceptions into fidius's `PluginError`.
16//!
17//! Every Python exception raised by plugin code crosses this helper on its
18//! way back to the host. The mapping rules are:
19//!
20//! - `code` ← the exception class name (e.g. `"ValueError"`, `"KeyError"`).
21//! - `message` ← `str(exc)` — the user-facing message Python produced.
22//! - `details` ← a JSON-encoded object containing the formatted traceback
23//! (and, in later tasks, any structured fields a `fidius.PluginError`
24//! raise carried).
25//!
26//! This file deliberately stays minimal: later tasks (FIDIUS-T-0086,
27//! FIDIUS-T-0089) extend it with `fidius.PluginError`-aware unwrapping so
28//! plugin code can raise typed errors without their fields being flattened.
29
30use fidius_core::PluginError;
31use pyo3::prelude::*;
32use pyo3::types::PyTraceback;
33use serde_json::json;
34
35/// Convert a `PyErr` into a `PluginError`, preserving class name, message,
36/// and a formatted traceback in `details`.
37///
38/// Acquires the GIL internally, so callers can pass a `PyErr` they captured
39/// outside `Python::with_gil` (the typical case for `?` propagation).
40pub fn pyerr_to_plugin_error(err: PyErr) -> PluginError {
41 Python::with_gil(|py| {
42 let value = err.value(py);
43
44 // Class name → code. `__class__.__name__` in Python.
45 let code = value
46 .getattr("__class__")
47 .and_then(|cls| cls.getattr("__name__"))
48 .and_then(|name| name.extract::<String>())
49 .unwrap_or_else(|_| "UNKNOWN_PYTHON_ERROR".to_string());
50
51 let message = value
52 .str()
53 .and_then(|s| s.extract::<String>())
54 .unwrap_or_else(|_| "<unprintable Python exception>".to_string());
55
56 let traceback = err
57 .traceback(py)
58 .and_then(|tb| format_traceback(py, tb).ok())
59 .unwrap_or_default();
60
61 let details = json!({ "traceback": traceback }).to_string();
62
63 PluginError {
64 code,
65 message,
66 details: Some(details),
67 }
68 })
69}
70
71/// Format a Python traceback into a plain string by calling
72/// `traceback.format_tb(tb)` and joining the result. Best-effort: returns an
73/// empty string on internal failure rather than recursively raising.
74fn format_traceback(py: Python<'_>, tb: Bound<'_, PyTraceback>) -> PyResult<String> {
75 let traceback_mod = py.import("traceback")?;
76 let frames = traceback_mod.call_method1("format_tb", (tb,))?;
77 let parts: Vec<String> = frames.extract()?;
78 Ok(parts.join(""))
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn maps_value_error_to_plugin_error() {
87 crate::ensure_initialized();
88 let err = Python::with_gil(|py| -> PyErr {
89 py.eval(
90 std::ffi::CString::new("(_ for _ in ()).throw(ValueError('boom'))")
91 .unwrap()
92 .as_c_str(),
93 None,
94 None,
95 )
96 .unwrap_err()
97 });
98
99 let pe = pyerr_to_plugin_error(err);
100 assert_eq!(pe.code, "ValueError");
101 assert!(pe.message.contains("boom"));
102 let details = pe.details.expect("details should be set");
103 assert!(details.contains("traceback"));
104 }
105}