Skip to main content

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}