Skip to main content

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}