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    /// **Client-streaming** call (FIDIUS-I-0030 CS2.4): the host produces the stream
162    /// items (`items_json`, a JSON array); the plugin method receives them as a
163    /// host-backed iterator (its **first** positional arg) and returns a value.
164    /// `args_json` are the method's non-stream args (a JSON array).
165    pub fn call_client_streaming_json(
166        &self,
167        method_index: usize,
168        items: Box<dyn Iterator<Item = serde_json::Value> + Send>,
169        args_json: &[u8],
170    ) -> Result<Vec<u8>, PythonCallError> {
171        let method = self.lookup_method(method_index, false)?;
172        let args: serde_json::Value = serde_json::from_slice(args_json)
173            .map_err(|e| PythonCallError::InputDecode(e.to_string()))?;
174
175        let result_value = Python::with_gil(|py| -> Result<serde_json::Value, PythonCallError> {
176            let callable = method.callable.bind(py);
177            // The stream argument: a host-fed Python iterator (first positional, lazy).
178            let stream = Py::new(
179                py,
180                HostFedStream {
181                    items: std::sync::Mutex::new(items),
182                },
183            )
184            .map_err(|e| PythonCallError::Plugin(pyerr_to_plugin_error(e)))?
185            .into_bound(py)
186            .into_any();
187            let mut py_args: Vec<Bound<'_, PyAny>> = vec![stream];
188            match &args {
189                serde_json::Value::Array(a) => {
190                    for v in a {
191                        py_args.push(
192                            value_to_pyobject(py, v)
193                                .map_err(|e| PythonCallError::InputDecode(e.to_string()))?,
194                        );
195                    }
196                }
197                serde_json::Value::Null => {}
198                other => py_args.push(
199                    value_to_pyobject(py, other)
200                        .map_err(|e| PythonCallError::InputDecode(e.to_string()))?,
201                ),
202            }
203            let args_tuple = PyTuple::new(py, py_args)
204                .map_err(|e| PythonCallError::InputDecode(e.to_string()))?;
205            let result = callable
206                .call(args_tuple, None::<&Bound<'_, PyDict>>)
207                .map_err(|e| PythonCallError::Plugin(pyerr_to_plugin_error(e)))?;
208            pyobject_to_value(&result).map_err(|e| PythonCallError::OutputEncode(e.to_string()))
209        })?;
210        serde_json::to_vec(&result_value).map_err(|e| PythonCallError::OutputEncode(e.to_string()))
211    }
212
213    /// **Bidirectional** streaming (FIDIUS-I-0032 / ADR-0010): the method receives the
214    /// host-fed input iterator (its **first** positional arg, CS2.4) AND returns a
215    /// generator (ST). The host feeds `items_json` (input) and pumps the returned
216    /// generator (output); pulling the output pulls the input — the synchronous lazy-pull
217    /// composition. `args_json` are the non-stream args (a JSON array).
218    pub fn call_bidi_streaming_start(
219        &self,
220        method_index: usize,
221        items: Box<dyn Iterator<Item = serde_json::Value> + Send>,
222        args_json: &[u8],
223    ) -> Result<crate::stream::PythonStream, PythonCallError> {
224        let method = self.lookup_method(method_index, false)?;
225        let args: serde_json::Value = serde_json::from_slice(args_json)
226            .map_err(|e| PythonCallError::InputDecode(e.to_string()))?;
227
228        Python::with_gil(|py| {
229            let callable = method.callable.bind(py);
230            // First positional arg: the host-fed input iterator (CS2.4, lazy).
231            let stream = Py::new(
232                py,
233                HostFedStream {
234                    items: std::sync::Mutex::new(items),
235                },
236            )
237            .map_err(|e| PythonCallError::Plugin(pyerr_to_plugin_error(e)))?
238            .into_bound(py)
239            .into_any();
240            let mut py_args: Vec<Bound<'_, PyAny>> = vec![stream];
241            match &args {
242                serde_json::Value::Array(a) => {
243                    for v in a {
244                        py_args.push(
245                            value_to_pyobject(py, v)
246                                .map_err(|e| PythonCallError::InputDecode(e.to_string()))?,
247                        );
248                    }
249                }
250                serde_json::Value::Null => {}
251                other => py_args.push(
252                    value_to_pyobject(py, other)
253                        .map_err(|e| PythonCallError::InputDecode(e.to_string()))?,
254                ),
255            }
256            let args_tuple = PyTuple::new(py, py_args)
257                .map_err(|e| PythonCallError::InputDecode(e.to_string()))?;
258            let result = callable
259                .call(args_tuple, None::<&Bound<'_, PyDict>>)
260                .map_err(|e| PythonCallError::Plugin(pyerr_to_plugin_error(e)))?;
261            // The returned generator/iterable becomes the output stream (ST).
262            let iter = result.try_iter().map_err(|e| {
263                PythonCallError::OutputEncode(format!(
264                    "bidirectional method must return an iterable/generator, got: {e}"
265                ))
266            })?;
267            Ok(crate::stream::PythonStream::new(iter.into_any().unbind()))
268        })
269    }
270
271    /// Start a server-streaming call (FIDIUS-I-0026). Calls the method to obtain
272    /// its iterator/generator and wraps it in a [`crate::stream::PythonStream`]
273    /// the host then pumps. Input is JSON like [`Self::call_typed_json`];
274    /// streaming methods use the typed (non-raw) path.
275    pub fn call_streaming_start(
276        &self,
277        method_index: usize,
278        input_json: &[u8],
279    ) -> Result<crate::stream::PythonStream, PythonCallError> {
280        let method = self.lookup_method(method_index, false)?;
281        let input_value: serde_json::Value = serde_json::from_slice(input_json)
282            .map_err(|e| PythonCallError::InputDecode(e.to_string()))?;
283
284        Python::with_gil(|py| {
285            let callable = method.callable.bind(py);
286            let py_args = build_call_args(py, &input_value)
287                .map_err(|e| PythonCallError::InputDecode(e.to_string()))?;
288            let result = callable
289                .call(py_args, None::<&Bound<'_, PyDict>>)
290                .map_err(|e| PythonCallError::Plugin(pyerr_to_plugin_error(e)))?;
291            // A generator is its own iterator (so `close()` still reaches it);
292            // any other iterable yields a plain iterator (cancel is then a no-op).
293            let iter = result.try_iter().map_err(|e| {
294                PythonCallError::OutputEncode(format!(
295                    "streaming method must return an iterable/generator, got: {e}"
296                ))
297            })?;
298            Ok(crate::stream::PythonStream::new(iter.into_any().unbind()))
299        })
300    }
301
302    /// Raw dispatch — pass bytes in, get bytes out, no encoding.
303    pub fn call_raw(&self, method_index: usize, input: &[u8]) -> Result<Vec<u8>, PythonCallError> {
304        let method = self.lookup_method(method_index, true)?;
305
306        Python::with_gil(|py| {
307            let callable = method.callable.bind(py);
308            let arg = PyBytes::new(py, input);
309            let result = callable
310                .call1((arg,))
311                .map_err(|e| PythonCallError::Plugin(pyerr_to_plugin_error(e)))?;
312
313            // Allow plugins to return `bytes`, `bytearray`, or anything
314            // implementing the buffer protocol via PyBytes::extract.
315            let bytes: Vec<u8> = result.extract().map_err(|e| {
316                PythonCallError::OutputEncode(format!(
317                    "raw method must return bytes/bytearray, got: {e}"
318                ))
319            })?;
320            Ok(bytes)
321        })
322    }
323
324    fn lookup_method(
325        &self,
326        index: usize,
327        attempting_raw: bool,
328    ) -> Result<MethodLookup<'_>, PythonCallError> {
329        if index >= self.method_callables.len() {
330            return Err(PythonCallError::InvalidMethodIndex {
331                index,
332                count: self.method_callables.len(),
333            });
334        }
335        let desc = &self.descriptor.methods[index];
336        if desc.wire_raw != attempting_raw {
337            return Err(PythonCallError::WireModeMismatch {
338                method: desc.name,
339                declared: desc.wire_raw,
340                attempted: attempting_raw,
341            });
342        }
343        Ok(MethodLookup {
344            callable: &self.method_callables[index],
345        })
346    }
347}
348
349struct MethodLookup<'a> {
350    callable: &'a Py<PyAny>,
351}
352
353/// Build positional args for `callable.call(...)` from a JSON value.
354///
355/// The host's typed encoding is a tuple `(arg1, arg2, ...)` — this surfaces
356/// as a JSON array. We unpack each element as a positional Python arg so
357/// `def greet(name)` works rather than `def greet((name,))`. Non-array
358/// values (degenerate case for zero-arg methods that produce JSON `null`)
359/// dispatch as zero-arg calls.
360fn build_call_args<'py>(
361    py: Python<'py>,
362    input: &serde_json::Value,
363) -> PyResult<Bound<'py, PyTuple>> {
364    match input {
365        serde_json::Value::Array(items) => {
366            let py_items: Vec<Bound<'_, PyAny>> = items
367                .iter()
368                .map(|v| value_to_pyobject(py, v))
369                .collect::<PyResult<_>>()?;
370            PyTuple::new(py, py_items)
371        }
372        serde_json::Value::Null => PyTuple::new(py, Vec::<Bound<'_, PyAny>>::new()),
373        other => {
374            // Single non-array, non-null value — treat as one positional arg.
375            let pyobj = value_to_pyobject(py, other)?;
376            PyTuple::new(py, vec![pyobj])
377        }
378    }
379}
380
381/// A host-fed Python iterator (FIDIUS-I-0030 CS2.4): yields the host's stream items
382/// to a client-streaming plugin method. `__next__` returns `None` at the end, which
383/// PyO3 surfaces as `StopIteration` — so plain `for x in rows:` works.
384#[pyclass]
385struct HostFedStream {
386    // Lazy: the host streams items in on demand (FIDIUS-T-0174), so an unbounded input
387    // isn't materialized up front. Boxed so the typed item iterator stays generic upstream;
388    // the `Mutex` makes the `#[pyclass]` `Sync` (PyO3 requires it) without forcing the
389    // upstream iterator to be `Sync` — it's pulled only under the GIL, so uncontended.
390    items: std::sync::Mutex<Box<dyn Iterator<Item = serde_json::Value> + Send>>,
391}
392
393#[pymethods]
394impl HostFedStream {
395    fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
396        slf
397    }
398
399    fn __next__(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
400        let next = self.items.lock().unwrap().next();
401        match next {
402            Some(v) => Ok(Some(value_to_pyobject(py, &v)?.unbind())),
403            None => Ok(None),
404        }
405    }
406}