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    /// Start a server-streaming call (FIDIUS-I-0026). Calls the method to obtain
162    /// its iterator/generator and wraps it in a [`crate::stream::PythonStream`]
163    /// the host then pumps. Input is JSON like [`Self::call_typed_json`];
164    /// streaming methods use the typed (non-raw) path.
165    pub fn call_streaming_start(
166        &self,
167        method_index: usize,
168        input_json: &[u8],
169    ) -> Result<crate::stream::PythonStream, PythonCallError> {
170        let method = self.lookup_method(method_index, false)?;
171        let input_value: serde_json::Value = serde_json::from_slice(input_json)
172            .map_err(|e| PythonCallError::InputDecode(e.to_string()))?;
173
174        Python::with_gil(|py| {
175            let callable = method.callable.bind(py);
176            let py_args = build_call_args(py, &input_value)
177                .map_err(|e| PythonCallError::InputDecode(e.to_string()))?;
178            let result = callable
179                .call(py_args, None::<&Bound<'_, PyDict>>)
180                .map_err(|e| PythonCallError::Plugin(pyerr_to_plugin_error(e)))?;
181            // A generator is its own iterator (so `close()` still reaches it);
182            // any other iterable yields a plain iterator (cancel is then a no-op).
183            let iter = result.try_iter().map_err(|e| {
184                PythonCallError::OutputEncode(format!(
185                    "streaming method must return an iterable/generator, got: {e}"
186                ))
187            })?;
188            Ok(crate::stream::PythonStream::new(iter.into_any().unbind()))
189        })
190    }
191
192    /// Raw dispatch — pass bytes in, get bytes out, no encoding.
193    pub fn call_raw(&self, method_index: usize, input: &[u8]) -> Result<Vec<u8>, PythonCallError> {
194        let method = self.lookup_method(method_index, true)?;
195
196        Python::with_gil(|py| {
197            let callable = method.callable.bind(py);
198            let arg = PyBytes::new(py, input);
199            let result = callable
200                .call1((arg,))
201                .map_err(|e| PythonCallError::Plugin(pyerr_to_plugin_error(e)))?;
202
203            // Allow plugins to return `bytes`, `bytearray`, or anything
204            // implementing the buffer protocol via PyBytes::extract.
205            let bytes: Vec<u8> = result.extract().map_err(|e| {
206                PythonCallError::OutputEncode(format!(
207                    "raw method must return bytes/bytearray, got: {e}"
208                ))
209            })?;
210            Ok(bytes)
211        })
212    }
213
214    fn lookup_method(
215        &self,
216        index: usize,
217        attempting_raw: bool,
218    ) -> Result<MethodLookup<'_>, PythonCallError> {
219        if index >= self.method_callables.len() {
220            return Err(PythonCallError::InvalidMethodIndex {
221                index,
222                count: self.method_callables.len(),
223            });
224        }
225        let desc = &self.descriptor.methods[index];
226        if desc.wire_raw != attempting_raw {
227            return Err(PythonCallError::WireModeMismatch {
228                method: desc.name,
229                declared: desc.wire_raw,
230                attempted: attempting_raw,
231            });
232        }
233        Ok(MethodLookup {
234            callable: &self.method_callables[index],
235        })
236    }
237}
238
239struct MethodLookup<'a> {
240    callable: &'a Py<PyAny>,
241}
242
243/// Build positional args for `callable.call(...)` from a JSON value.
244///
245/// The host's typed encoding is a tuple `(arg1, arg2, ...)` — this surfaces
246/// as a JSON array. We unpack each element as a positional Python arg so
247/// `def greet(name)` works rather than `def greet((name,))`. Non-array
248/// values (degenerate case for zero-arg methods that produce JSON `null`)
249/// dispatch as zero-arg calls.
250fn build_call_args<'py>(
251    py: Python<'py>,
252    input: &serde_json::Value,
253) -> PyResult<Bound<'py, PyTuple>> {
254    match input {
255        serde_json::Value::Array(items) => {
256            let py_items: Vec<Bound<'_, PyAny>> = items
257                .iter()
258                .map(|v| value_to_pyobject(py, v))
259                .collect::<PyResult<_>>()?;
260            PyTuple::new(py, py_items)
261        }
262        serde_json::Value::Null => PyTuple::new(py, Vec::<Bound<'_, PyAny>>::new()),
263        other => {
264            // Single non-array, non-null value — treat as one positional arg.
265            let pyobj = value_to_pyobject(py, other)?;
266            PyTuple::new(py, vec![pyobj])
267        }
268    }
269}