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}