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}