fidius_python/handle.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//! Handle to a loaded Python plugin.
16//!
17//! Holds the imported module + a vector of callables aligned with the
18//! interface descriptor's method order. Dispatch happens via two paths:
19//!
20//! - **Typed**: `call_typed` takes raw bincode bytes (the same payload the
21//! cdylib `call_method` would receive), pivots through `serde_json::Value`
22//! to convert to Python primitives, calls the callable, converts the
23//! return back to a `Value`, and re-encodes as bincode for the host.
24//!
25//! - **Raw**: `call_raw` takes `&[u8]`, passes a `bytes` arg directly to
26//! Python, expects `bytes` back. No encoding hops — used by methods opted
27//! into `#[wire(raw)]` (T-0082).
28//!
29//! All Python exceptions become `fidius_core::PluginError` via the
30//! `pyerr_to_plugin_error` helper, with `code = "PluginError"` for typed
31//! `fidius.PluginError` raises (round-trips `code`/`message`/`details`) and
32//! `code = <ExceptionClassName>` otherwise.
33
34use fidius_core::python_descriptor::PythonInterfaceDescriptor;
35use fidius_core::PluginError;
36use pyo3::prelude::*;
37use pyo3::types::{PyAnyMethods, PyBytes, PyDict, PyTuple};
38
39use crate::error::pyerr_to_plugin_error;
40use crate::value_bridge::{pyobject_to_value, value_to_pyobject};
41
42/// Errors a typed call can produce on the Python side.
43#[derive(Debug, thiserror::Error)]
44pub enum PythonCallError {
45 /// `method_index` was past the end of the interface descriptor's methods.
46 #[error("invalid method index {index} (interface has {count} method(s))")]
47 InvalidMethodIndex { index: usize, count: usize },
48
49 /// Tried to call a typed method through the raw path, or vice versa.
50 #[error(
51 "wire-mode mismatch on method '{method}': declared wire_raw={declared}, dispatcher used wire_raw={attempted}"
52 )]
53 WireModeMismatch {
54 method: &'static str,
55 declared: bool,
56 attempted: bool,
57 },
58
59 /// The host-supplied input bytes (typed path) couldn't be decoded.
60 #[error("failed to decode typed input: {0}")]
61 InputDecode(String),
62
63 /// The Python return value couldn't be encoded back for the host.
64 #[error("failed to encode typed output: {0}")]
65 OutputEncode(String),
66
67 /// A Python exception was raised by the plugin.
68 #[error("plugin raised: [{}] {}", .0.code, .0.message)]
69 Plugin(PluginError),
70}
71
72/// Loaded-and-validated handle to one Python plugin.
73#[derive(Debug)]
74pub struct PythonPluginHandle {
75 descriptor: &'static PythonInterfaceDescriptor,
76 /// Imported entry module — kept alive so its callables (and their
77 /// closures over module globals) remain valid.
78 _module: Py<PyAny>,
79 /// One callable per method, in descriptor order. Index here = vtable
80 /// index used by the host's `Client::method_name(...)` call sites.
81 method_callables: Vec<Py<PyAny>>,
82}
83
84impl PythonPluginHandle {
85 pub(crate) fn new(
86 descriptor: &'static PythonInterfaceDescriptor,
87 module: Py<PyAny>,
88 method_callables: Vec<Py<PyAny>>,
89 ) -> Self {
90 Self {
91 descriptor,
92 _module: module,
93 method_callables,
94 }
95 }
96
97 pub fn descriptor(&self) -> &'static PythonInterfaceDescriptor {
98 self.descriptor
99 }
100
101 pub fn method_count(&self) -> usize {
102 self.descriptor.methods.len()
103 }
104
105 /// Typed dispatch.
106 ///
107 /// `input_bincode` is the bincode-encoded args tuple — the same byte
108 /// payload the cdylib path would receive. We use bincode here only
109 /// because every other fidius caller does; on the way into Python we
110 /// pivot through `serde_json::Value` (so the host's `I: Serialize` works
111 /// for any type the macro accepts).
112 pub fn call_typed(
113 &self,
114 method_index: usize,
115 input_bincode: &[u8],
116 ) -> Result<Vec<u8>, PythonCallError> {
117 // bincode → serde_json::Value: round-trip via a String/Vec<u8>.
118 // bincode is not self-describing, so we can't decode straight to
119 // Value. Instead, decode into a `serde_json::Value` indirectly via
120 // an intermediate trait object — actually that doesn't work either.
121 //
122 // What works: re-encode the bincode payload by deserialising into a
123 // typed-erasure crate. We don't have one. So we take a different
124 // approach: the *host* side will switch to JSON for python plugins
125 // (see PluginHandle integration in T-0090). For T-0089 the typed
126 // path receives JSON-encoded input directly; the bincode parameter
127 // name is a holdover documenting future drift if we change the
128 // host wire.
129 //
130 // For now: assume `input_bincode` is in fact JSON bytes. Document
131 // the constraint loudly in the parameter name so callers don't
132 // accidentally pass bincode here.
133 self.call_typed_json(method_index, input_bincode)
134 }
135
136 /// Typed dispatch where the input is already JSON-serialised (the
137 /// host's `serde_json::to_vec(&input)`). Returns JSON bytes the caller
138 /// `serde_json::from_slice::<O>` decodes.
139 pub fn call_typed_json(
140 &self,
141 method_index: usize,
142 input_json: &[u8],
143 ) -> Result<Vec<u8>, PythonCallError> {
144 let method = self.lookup_method(method_index, false)?;
145 let input_value: serde_json::Value = serde_json::from_slice(input_json)
146 .map_err(|e| PythonCallError::InputDecode(e.to_string()))?;
147
148 let result_value = Python::with_gil(|py| -> Result<serde_json::Value, PythonCallError> {
149 let callable = method.callable.bind(py);
150 let py_args = build_call_args(py, &input_value)
151 .map_err(|e| PythonCallError::InputDecode(e.to_string()))?;
152 let result = callable
153 .call(py_args, None::<&Bound<'_, PyDict>>)
154 .map_err(|e| PythonCallError::Plugin(pyerr_to_plugin_error(e)))?;
155 pyobject_to_value(&result).map_err(|e| PythonCallError::OutputEncode(e.to_string()))
156 })?;
157
158 serde_json::to_vec(&result_value).map_err(|e| PythonCallError::OutputEncode(e.to_string()))
159 }
160
161 /// Raw dispatch — pass bytes in, get bytes out, no encoding.
162 pub fn call_raw(&self, method_index: usize, input: &[u8]) -> Result<Vec<u8>, PythonCallError> {
163 let method = self.lookup_method(method_index, true)?;
164
165 Python::with_gil(|py| {
166 let callable = method.callable.bind(py);
167 let arg = PyBytes::new(py, input);
168 let result = callable
169 .call1((arg,))
170 .map_err(|e| PythonCallError::Plugin(pyerr_to_plugin_error(e)))?;
171
172 // Allow plugins to return `bytes`, `bytearray`, or anything
173 // implementing the buffer protocol via PyBytes::extract.
174 let bytes: Vec<u8> = result.extract().map_err(|e| {
175 PythonCallError::OutputEncode(format!(
176 "raw method must return bytes/bytearray, got: {e}"
177 ))
178 })?;
179 Ok(bytes)
180 })
181 }
182
183 fn lookup_method(
184 &self,
185 index: usize,
186 attempting_raw: bool,
187 ) -> Result<MethodLookup<'_>, PythonCallError> {
188 if index >= self.method_callables.len() {
189 return Err(PythonCallError::InvalidMethodIndex {
190 index,
191 count: self.method_callables.len(),
192 });
193 }
194 let desc = &self.descriptor.methods[index];
195 if desc.wire_raw != attempting_raw {
196 return Err(PythonCallError::WireModeMismatch {
197 method: desc.name,
198 declared: desc.wire_raw,
199 attempted: attempting_raw,
200 });
201 }
202 Ok(MethodLookup {
203 callable: &self.method_callables[index],
204 })
205 }
206}
207
208struct MethodLookup<'a> {
209 callable: &'a Py<PyAny>,
210}
211
212/// Build positional args for `callable.call(...)` from a JSON value.
213///
214/// The host's typed encoding is a tuple `(arg1, arg2, ...)` — this surfaces
215/// as a JSON array. We unpack each element as a positional Python arg so
216/// `def greet(name)` works rather than `def greet((name,))`. Non-array
217/// values (degenerate case for zero-arg methods that produce JSON `null`)
218/// dispatch as zero-arg calls.
219fn build_call_args<'py>(
220 py: Python<'py>,
221 input: &serde_json::Value,
222) -> PyResult<Bound<'py, PyTuple>> {
223 match input {
224 serde_json::Value::Array(items) => {
225 let py_items: Vec<Bound<'_, PyAny>> = items
226 .iter()
227 .map(|v| value_to_pyobject(py, v))
228 .collect::<PyResult<_>>()?;
229 PyTuple::new(py, py_items)
230 }
231 serde_json::Value::Null => PyTuple::new(py, Vec::<Bound<'_, PyAny>>::new()),
232 other => {
233 // Single non-array, non-null value — treat as one positional arg.
234 let pyobj = value_to_pyobject(py, other)?;
235 PyTuple::new(py, vec![pyobj])
236 }
237 }
238}