Skip to main content

fidius_python/
stream.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//! Server-streaming dispatch for Python plugins (FIDIUS-I-0026).
16//!
17//! A streaming plugin method returns a Python **generator** (or any iterable).
18//! [`super::handle::PythonPluginHandle::call_streaming_start`] calls the method
19//! to obtain its iterator and wraps it in a [`PythonStream`].
20//!
21//! [`PythonStream`] is the *sync, GIL-aware* half of the bridge: it advances the
22//! iterator one item at a time ([`PythonStream::next`], holding the GIL only for
23//! that step) and runs the generator's cleanup on cancellation
24//! ([`PythonStream::cancel`] → `gen.close()` → `GeneratorExit`/`finally`). The
25//! *async* half — pumping it onto a `ChunkStream` over a bounded channel with
26//! backpressure — lives in `fidius-host`'s `Pyo3Executor`, which owns the tokio
27//! machinery. Keeping PyO3 here and tokio there respects the existing crate
28//! split (`fidius-python` has no async runtime).
29
30use pyo3::prelude::*;
31use pyo3::types::PyAnyMethods;
32
33use fidius_core::PluginError;
34
35use crate::error::pyerr_to_plugin_error;
36use crate::value_bridge::pyobject_to_value;
37
38/// One step of advancing a Python plugin's server-streaming iterator.
39pub enum PyStreamStep {
40    /// One produced item, already converted to the JSON currency.
41    Item(serde_json::Value),
42    /// Clean end of stream (`StopIteration`).
43    End,
44    /// The generator raised, or an item failed to convert. Terminal.
45    Error(PluginError),
46}
47
48/// A handle to an in-flight Python server-stream — the iterator obtained by
49/// calling a streaming plugin method.
50///
51/// `Send` (a `Py<PyAny>` is `Send + Sync`) so the host can drive it from a
52/// dedicated pump thread, acquiring the GIL only for each `next`/`cancel`.
53pub struct PythonStream {
54    iter: Py<PyAny>,
55}
56
57impl PythonStream {
58    pub(crate) fn new(iter: Py<PyAny>) -> Self {
59        Self { iter }
60    }
61
62    /// Advance one item. Holds the GIL only for the duration of this call, so a
63    /// slow downstream consumer never pins the interpreter.
64    pub fn next(&self) -> PyStreamStep {
65        Python::with_gil(|py| {
66            let it = self.iter.bind(py);
67            match it.call_method0("__next__") {
68                Ok(item) => match pyobject_to_value(&item) {
69                    Ok(v) => PyStreamStep::Item(v),
70                    Err(e) => PyStreamStep::Error(PluginError::new("OutputEncode", e.to_string())),
71                },
72                Err(e) if e.is_instance_of::<pyo3::exceptions::PyStopIteration>(py) => {
73                    PyStreamStep::End
74                }
75                Err(e) => PyStreamStep::Error(pyerr_to_plugin_error(e)),
76            }
77        })
78    }
79
80    /// Cancel the stream: run the generator's cleanup by calling `close()`,
81    /// which raises `GeneratorExit` inside the generator so its `finally`/
82    /// context-manager `__exit__` runs (closing DB cursors, releasing handles).
83    /// A no-op for iterables without `close` (e.g. a plain `list_iterator`).
84    pub fn cancel(&self) {
85        Python::with_gil(|py| {
86            let it = self.iter.bind(py);
87            if let Ok(close) = it.getattr("close") {
88                let _ = close.call0();
89            }
90        });
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::interpreter::ensure_initialized;
98
99    /// Build a `PythonStream` from a snippet that evaluates to an iterator.
100    fn stream_from(code: &str) -> PythonStream {
101        ensure_initialized();
102        Python::with_gil(|py| {
103            let obj = py
104                .eval(&std::ffi::CString::new(code).unwrap(), None, None)
105                .expect("eval");
106            let iter = obj.try_iter().expect("iterable");
107            PythonStream::new(iter.into_any().unbind())
108        })
109    }
110
111    fn item_i64(step: PyStreamStep) -> i64 {
112        match step {
113            PyStreamStep::Item(v) => v.as_i64().expect("int item"),
114            other => panic!("expected item, got {}", step_name(&other)),
115        }
116    }
117
118    fn step_name(s: &PyStreamStep) -> &'static str {
119        match s {
120            PyStreamStep::Item(_) => "Item",
121            PyStreamStep::End => "End",
122            PyStreamStep::Error(_) => "Error",
123        }
124    }
125
126    #[test]
127    fn yields_items_then_end() {
128        let s = stream_from("iter([1, 2, 3])");
129        assert_eq!(item_i64(s.next()), 1);
130        assert_eq!(item_i64(s.next()), 2);
131        assert_eq!(item_i64(s.next()), 3);
132        assert!(matches!(s.next(), PyStreamStep::End));
133        // Past the end stays End (fused on the host side anyway).
134        assert!(matches!(s.next(), PyStreamStep::End));
135    }
136
137    #[test]
138    fn generator_exception_becomes_error() {
139        // A generator that yields once, then raises.
140        let s = gen_from_def("def g():\n    yield 7\n    raise ValueError('boom')\nit = g()");
141        assert_eq!(item_i64(s.next()), 7);
142        match s.next() {
143            PyStreamStep::Error(pe) => {
144                assert!(pe.message.contains("boom"), "message was: {}", pe.message)
145            }
146            other => panic!("expected error, got {}", step_name(&other)),
147        }
148        // After a terminal error the iterator is exhausted.
149        assert!(matches!(s.next(), PyStreamStep::End));
150    }
151
152    /// Run a snippet that binds `it` to an iterator/generator in fresh globals.
153    fn gen_from_def(code: &str) -> PythonStream {
154        ensure_initialized();
155        Python::with_gil(|py| {
156            let globals = pyo3::types::PyDict::new(py);
157            py.run(&std::ffi::CString::new(code).unwrap(), Some(&globals), None)
158                .unwrap();
159            let it = globals.get_item("it").unwrap().unwrap();
160            PythonStream::new(it.unbind())
161        })
162    }
163
164    #[test]
165    fn cancel_runs_generator_finally() {
166        ensure_initialized();
167        let (stream, globals) = Python::with_gil(|py| {
168            let globals = pyo3::types::PyDict::new(py);
169            py.run(
170                &std::ffi::CString::new(
171                    "ran = {'cleanup': False}\n\
172                     def g():\n    \
173                         try:\n        \
174                             yield 1\n        \
175                             yield 2\n    \
176                         finally:\n        \
177                             ran['cleanup'] = True\n\
178                     it = g()",
179                )
180                .unwrap(),
181                Some(&globals),
182                None,
183            )
184            .unwrap();
185            let it = globals.get_item("it").unwrap().unwrap();
186            (PythonStream::new(it.unbind()), globals.unbind())
187        });
188
189        // Pull one item, then cancel mid-stream.
190        assert_eq!(item_i64(stream.next()), 1);
191        stream.cancel();
192
193        // The generator's `finally` must have run.
194        Python::with_gil(|py| {
195            let g = globals.bind(py);
196            let ran = g.get_item("ran").unwrap().unwrap();
197            let cleanup: bool = ran.get_item("cleanup").unwrap().extract().unwrap();
198            assert!(cleanup, "generator `finally` should run on cancel()");
199        });
200    }
201}