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        // fidius.PluginError-aware (FIDIUS-T-0155): a typed error raised by plugin
45        // code carries `code` / `message` / `details` — round-trip them intact
46        // rather than flattening to class-name / str / traceback.
47        if is_fidius_plugin_error(py, value) {
48            let code = value
49                .getattr("code")
50                .and_then(|c| c.extract::<String>())
51                .unwrap_or_else(|_| "PluginError".to_string());
52            let message = value
53                .getattr("message")
54                .and_then(|m| m.extract::<String>())
55                .or_else(|_| value.str().and_then(|s| s.extract::<String>()))
56                .unwrap_or_default();
57            let details = value
58                .getattr("details")
59                .ok()
60                .filter(|d| !d.is_none())
61                .and_then(|d| json_dumps(py, &d));
62            return PluginError {
63                code,
64                message,
65                details,
66            };
67        }
68
69        // Class name → code. `__class__.__name__` in Python.
70        let code = value
71            .getattr("__class__")
72            .and_then(|cls| cls.getattr("__name__"))
73            .and_then(|name| name.extract::<String>())
74            .unwrap_or_else(|_| "UNKNOWN_PYTHON_ERROR".to_string());
75
76        let message = value
77            .str()
78            .and_then(|s| s.extract::<String>())
79            .unwrap_or_else(|_| "<unprintable Python exception>".to_string());
80
81        let traceback = err
82            .traceback(py)
83            .and_then(|tb| format_traceback(py, tb).ok())
84            .unwrap_or_default();
85
86        let details = json!({ "traceback": traceback }).to_string();
87
88        PluginError {
89            code,
90            message,
91            details: Some(details),
92        }
93    })
94}
95
96/// Format a Python traceback into a plain string by calling
97/// `traceback.format_tb(tb)` and joining the result. Best-effort: returns an
98/// empty string on internal failure rather than recursively raising.
99fn format_traceback(py: Python<'_>, tb: Bound<'_, PyTraceback>) -> PyResult<String> {
100    let traceback_mod = py.import("traceback")?;
101    let frames = traceback_mod.call_method1("format_tb", (tb,))?;
102    let parts: Vec<String> = frames.extract()?;
103    Ok(parts.join(""))
104}
105
106/// Is `value` an instance of `fidius.PluginError` (the SDK's typed error)? Falls
107/// back to `false` if the SDK isn't importable, so other exceptions take the
108/// generic class-name/str/traceback path.
109fn is_fidius_plugin_error(py: Python<'_>, value: &Bound<'_, PyAny>) -> bool {
110    py.import("fidius")
111        .and_then(|m| m.getattr("PluginError"))
112        .and_then(|cls| value.is_instance(&cls))
113        .unwrap_or(false)
114}
115
116/// Serialize a Python object to a JSON string via `json.dumps`. Best-effort:
117/// returns `None` if the object isn't JSON-serialisable.
118fn json_dumps(py: Python<'_>, obj: &Bound<'_, PyAny>) -> Option<String> {
119    py.import("json")
120        .ok()
121        .and_then(|j| j.call_method1("dumps", (obj,)).ok())
122        .and_then(|s| s.extract::<String>().ok())
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn maps_value_error_to_plugin_error() {
131        crate::ensure_initialized();
132        let err = Python::with_gil(|py| -> PyErr {
133            py.eval(
134                std::ffi::CString::new("(_ for _ in ()).throw(ValueError('boom'))")
135                    .unwrap()
136                    .as_c_str(),
137                None,
138                None,
139            )
140            .unwrap_err()
141        });
142
143        let pe = pyerr_to_plugin_error(err);
144        assert_eq!(pe.code, "ValueError");
145        assert!(pe.message.contains("boom"));
146        let details = pe.details.expect("details should be set");
147        assert!(details.contains("traceback"));
148    }
149}