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}