Skip to main content

datafusion_python_util/
lib.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::future::Future;
19use std::ptr::NonNull;
20use std::sync::{Arc, OnceLock};
21use std::time::Duration;
22
23use datafusion::datasource::TableProvider;
24use datafusion::execution::TaskContext;
25use datafusion::execution::context::SessionContext;
26use datafusion::logical_expr::Volatility;
27use datafusion::physical_optimizer::PhysicalOptimizerRule;
28use datafusion_ffi::execution::FFI_TaskContextProvider;
29use datafusion_ffi::physical_optimizer::FFI_PhysicalOptimizerRule;
30use datafusion_ffi::proto::logical_extension_codec::FFI_LogicalExtensionCodec;
31use datafusion_ffi::proto::physical_extension_codec::FFI_PhysicalExtensionCodec;
32use datafusion_ffi::table_provider::FFI_TableProvider;
33use datafusion_proto::physical_plan::PhysicalExtensionCodec;
34use pyo3::exceptions::{PyImportError, PyTypeError, PyValueError};
35use pyo3::prelude::*;
36use pyo3::types::{PyCapsule, PyType};
37use tokio::runtime::Runtime;
38use tokio::task::JoinHandle;
39use tokio::time::sleep;
40
41pub mod errors;
42pub use crate::errors::to_datafusion_err;
43use crate::errors::{PyDataFusionError, PyDataFusionResult};
44
45/// Utility to get the Tokio Runtime from Python
46#[inline]
47pub fn get_tokio_runtime() -> &'static Runtime {
48    // NOTE: Other pyo3 python libraries have had issues with using tokio
49    // behind a forking app-server like `gunicorn`
50    // If we run into that problem, in the future we can look to `delta-rs`
51    // which adds a check in that disallows calls from a forked process
52    // https://github.com/delta-io/delta-rs/blob/87010461cfe01563d91a4b9cd6fa468e2ad5f283/python/src/utils.rs#L10-L31
53    static RUNTIME: OnceLock<Runtime> = OnceLock::new();
54    RUNTIME.get_or_init(|| Runtime::new().unwrap())
55}
56
57#[inline]
58pub fn is_ipython_env(py: Python) -> &'static bool {
59    static IS_IPYTHON_ENV: OnceLock<bool> = OnceLock::new();
60    IS_IPYTHON_ENV.get_or_init(|| {
61        py.import("IPython")
62            .and_then(|ipython| ipython.call_method0("get_ipython"))
63            .map(|ipython| !ipython.is_none())
64            .unwrap_or(false)
65    })
66}
67
68/// Utility to get the Global Datafussion CTX
69#[inline]
70pub fn get_global_ctx() -> &'static Arc<SessionContext> {
71    static CTX: OnceLock<Arc<SessionContext>> = OnceLock::new();
72    CTX.get_or_init(|| Arc::new(SessionContext::new()))
73}
74
75/// Utility to collect rust futures with GIL released and respond to
76/// Python interrupts such as ``KeyboardInterrupt``. If a signal is
77/// received while the future is running, the future is aborted and the
78/// corresponding Python exception is raised.
79pub fn wait_for_future<F>(py: Python, fut: F) -> PyResult<F::Output>
80where
81    F: Future + Send,
82    F::Output: Send,
83{
84    let runtime: &Runtime = get_tokio_runtime();
85    const INTERVAL_CHECK_SIGNALS: Duration = Duration::from_millis(1_000);
86
87    // Some fast running processes that generate many `wait_for_future` calls like
88    // PartitionedDataFrameStreamReader::next require checking for interrupts early
89    py.run(cr"pass", None, None)?;
90    py.check_signals()?;
91
92    py.detach(|| {
93        runtime.block_on(async {
94            tokio::pin!(fut);
95            loop {
96                tokio::select! {
97                    res = &mut fut => break Ok(res),
98                    _ = sleep(INTERVAL_CHECK_SIGNALS) => {
99                        Python::attach(|py| {
100                                // Execute a no-op Python statement to trigger signal processing.
101                                // This is necessary because py.check_signals() alone doesn't
102                                // actually check for signals - it only raises an exception if
103                                // a signal was already set during a previous Python API call.
104                                // Running even trivial Python code forces the interpreter to
105                                // process any pending signals (like KeyboardInterrupt).
106                                py.run(cr"pass", None, None)?;
107                                py.check_signals()
108                        })?;
109                    }
110                }
111            }
112        })
113    })
114}
115
116/// Spawn a [`Future`] on the Tokio runtime and wait for completion
117/// while respecting Python signal handling.
118pub fn spawn_future<F, T>(py: Python, fut: F) -> PyDataFusionResult<T>
119where
120    F: Future<Output = datafusion::common::Result<T>> + Send + 'static,
121    T: Send + 'static,
122{
123    let rt = get_tokio_runtime();
124    let handle: JoinHandle<datafusion::common::Result<T>> = rt.spawn(fut);
125    // Wait for the join handle while respecting Python signal handling.
126    // We handle errors in two steps so `?` maps the error types correctly:
127    // 1) convert any Python-related error from `wait_for_future` into `PyDataFusionError`
128    // 2) convert any DataFusion error (inner result) into `PyDataFusionError`
129    let inner_result = wait_for_future(py, async {
130        // handle.await yields `Result<datafusion::common::Result<T>, JoinError>`
131        // map JoinError into a DataFusion error so the async block returns
132        // `datafusion::common::Result<T>` (i.e. Result<T, DataFusionError>)
133        match handle.await {
134            Ok(inner) => inner,
135            Err(join_err) => Err(to_datafusion_err(join_err)),
136        }
137    })?; // converts PyErr -> PyDataFusionError
138
139    // `inner_result` is `datafusion::common::Result<T>`; use `?` to convert
140    // the inner DataFusion error into `PyDataFusionError` via `From` and
141    // return the inner `T` on success.
142    Ok(inner_result?)
143}
144
145pub fn parse_volatility(value: &str) -> PyDataFusionResult<Volatility> {
146    Ok(match value {
147        "immutable" => Volatility::Immutable,
148        "stable" => Volatility::Stable,
149        "volatile" => Volatility::Volatile,
150        value => {
151            return Err(PyDataFusionError::Common(format!(
152                "Unsupported volatility type: `{value}`, supported \
153                 values are: immutable, stable and volatile."
154            )));
155        }
156    })
157}
158
159pub fn validate_pycapsule(capsule: &Bound<PyCapsule>, name: &str) -> PyResult<()> {
160    let capsule_name = capsule.name()?;
161    if capsule_name.is_none() {
162        return Err(PyValueError::new_err(format!(
163            "Expected {name} PyCapsule to have name set."
164        )));
165    }
166
167    let capsule_name = unsafe { capsule_name.unwrap().as_cstr().to_str()? };
168    if capsule_name != name {
169        return Err(PyValueError::new_err(format!(
170            "Expected name '{name}' in PyCapsule, instead got '{capsule_name}'"
171        )));
172    }
173
174    Ok(())
175}
176
177pub fn table_provider_from_pycapsule<'py>(
178    mut obj: Bound<'py, PyAny>,
179    session: Bound<'py, PyAny>,
180) -> PyResult<Option<Arc<dyn TableProvider>>> {
181    if obj.hasattr("__datafusion_table_provider__")? {
182        obj = obj
183            .getattr("__datafusion_table_provider__")?
184            .call1((session,)).map_err(|err| {
185            let py = obj.py();
186            if err.get_type(py).is(PyType::new::<PyTypeError>(py)) {
187                PyImportError::new_err("Incompatible libraries. DataFusion 52.0.0 introduced an incompatible signature change for table providers. Either downgrade DataFusion or upgrade your function library.")
188            } else {
189                err
190            }
191        })?;
192    }
193
194    if let Ok(capsule) = obj.cast::<PyCapsule>() {
195        let data: NonNull<FFI_TableProvider> = capsule
196            .pointer_checked(Some(c"datafusion_table_provider"))?
197            .cast();
198        let provider = unsafe { data.as_ref() };
199        let provider: Arc<dyn TableProvider> = provider.into();
200
201        Ok(Some(provider))
202    } else {
203        Ok(None)
204    }
205}
206
207pub fn create_logical_extension_capsule<'py>(
208    py: Python<'py>,
209    codec: &FFI_LogicalExtensionCodec,
210) -> PyResult<Bound<'py, PyCapsule>> {
211    let name = cr"datafusion_logical_extension_codec".into();
212    let codec = codec.clone();
213
214    PyCapsule::new(py, codec, Some(name))
215}
216
217pub fn ffi_logical_codec_from_pycapsule(obj: Bound<PyAny>) -> PyResult<FFI_LogicalExtensionCodec> {
218    let attr_name = "__datafusion_logical_extension_codec__";
219    let capsule = if obj.hasattr(attr_name)? {
220        obj.getattr(attr_name)?.call0()?
221    } else {
222        obj
223    };
224
225    let capsule = capsule.cast::<PyCapsule>()?;
226    let data: NonNull<FFI_LogicalExtensionCodec> = capsule
227        .pointer_checked(Some(c"datafusion_logical_extension_codec"))?
228        .cast();
229    let codec = unsafe { data.as_ref() };
230
231    Ok(codec.clone())
232}
233
234pub fn create_physical_extension_capsule<'py>(
235    py: Python<'py>,
236    codec: &FFI_PhysicalExtensionCodec,
237) -> PyResult<Bound<'py, PyCapsule>> {
238    let name = cr"datafusion_physical_extension_codec".into();
239    let codec = codec.clone();
240
241    PyCapsule::new(py, codec, Some(name))
242}
243
244/// Define a `<fn_name>(obj) -> PyResult<Arc<$output_type>>` extractor that
245/// accepts either a raw `PyCapsule` carrying `$ffi_type` or any object
246/// exposing `__<capsule_name>__()` that returns one.
247///
248/// Use this when `Arc<$output_type>: From<&$ffi_type>` (infallible
249/// conversion). For fallible conversions use [`try_from_pycapsule!`]
250/// instead.
251#[macro_export]
252macro_rules! from_pycapsule {
253    ($fn_name:ident, $capsule_name:literal, $ffi_type:ty, $output_type:ty) => {
254        pub fn $fn_name(
255            obj: &$crate::pyo3::Bound<$crate::pyo3::PyAny>,
256        ) -> $crate::pyo3::PyResult<std::sync::Arc<$output_type>> {
257            use $crate::pyo3::prelude::*;
258            use $crate::pyo3::types::PyCapsule;
259
260            let mut obj = obj.clone();
261            if obj.hasattr(concat!("__", $capsule_name, "__"))? {
262                obj = obj.getattr(concat!("__", $capsule_name, "__"))?.call0()?;
263            }
264            let capsule = obj.cast::<PyCapsule>().map_err(|_| {
265                $crate::errors::py_datafusion_err(concat!(
266                    "Invalid ",
267                    $capsule_name,
268                    ". Does not contain PyCapsule object."
269                ))
270            })?;
271            $crate::validate_pycapsule(&capsule, $capsule_name)?;
272
273            let expected_name = std::ffi::CString::new($capsule_name)
274                .expect("capsule name must not contain interior NUL bytes");
275            let data: std::ptr::NonNull<$ffi_type> = capsule
276                .pointer_checked(Some(expected_name.as_c_str()))?
277                .cast();
278            let output_obj = unsafe { data.as_ref() };
279            let output_obj: std::sync::Arc<$output_type> = output_obj.into();
280
281            Ok(output_obj)
282        }
283    };
284}
285
286/// Same shape as [`from_pycapsule!`] but for FFI types whose conversion
287/// into `Arc<$output_type>` is fallible (uses `TryFrom`).
288#[macro_export]
289macro_rules! try_from_pycapsule {
290    ($fn_name:ident, $capsule_name:literal, $ffi_type:ty, $output_type:ty) => {
291        pub fn $fn_name(
292            obj: &$crate::pyo3::Bound<$crate::pyo3::PyAny>,
293        ) -> $crate::pyo3::PyResult<std::sync::Arc<$output_type>> {
294            use $crate::pyo3::prelude::*;
295            use $crate::pyo3::types::PyCapsule;
296
297            let mut obj = obj.clone();
298            if obj.hasattr(concat!("__", $capsule_name, "__"))? {
299                obj = obj.getattr(concat!("__", $capsule_name, "__"))?.call0()?;
300            }
301            let capsule = obj.cast::<PyCapsule>().map_err(|_| {
302                $crate::errors::py_datafusion_err(concat!(
303                    "Invalid ",
304                    $capsule_name,
305                    ". Does not contain PyCapsule object."
306                ))
307            })?;
308            $crate::validate_pycapsule(&capsule, $capsule_name)?;
309
310            let expected_name = std::ffi::CString::new($capsule_name)
311                .expect("capsule name must not contain interior NUL bytes");
312            let data: std::ptr::NonNull<$ffi_type> = capsule
313                .pointer_checked(Some(expected_name.as_c_str()))?
314                .cast();
315            let output_obj = unsafe { data.as_ref() };
316            let output_obj: std::sync::Arc<$output_type> = output_obj
317                .try_into()
318                .map_err($crate::errors::py_datafusion_err)?;
319
320            Ok(output_obj)
321        }
322    };
323}
324
325// Re-export pyo3 so the macros expand inside downstream crates without
326// requiring an explicit pyo3 dep at the call site.
327#[doc(hidden)]
328pub use pyo3;
329
330from_pycapsule!(
331    physical_codec_from_pycapsule,
332    "datafusion_physical_extension_codec",
333    FFI_PhysicalExtensionCodec,
334    dyn PhysicalExtensionCodec
335);
336
337from_pycapsule!(
338    physical_optimizer_rule_from_pycapsule,
339    "datafusion_physical_optimizer_rule",
340    FFI_PhysicalOptimizerRule,
341    dyn PhysicalOptimizerRule + Send + Sync
342);
343
344try_from_pycapsule!(
345    task_context_from_pycapsule,
346    "datafusion_task_context_provider",
347    FFI_TaskContextProvider,
348    TaskContext
349);