Skip to main content

polars_python/lazyframe/
general.rs

1use std::collections::HashMap;
2use std::num::NonZeroUsize;
3
4use arrow::ffi::export_iterator;
5use either::Either;
6use parking_lot::Mutex;
7#[cfg(feature = "pivot")]
8use polars::frame::PivotColumnNaming;
9use polars::io::RowIndex;
10use polars::prelude::iceberg_sink_state::IcebergSinkState;
11use polars::time::*;
12#[cfg(feature = "csv")]
13use polars_buffer::Buffer;
14use polars_core::prelude::*;
15use polars_core::query_result::QueryResult;
16#[cfg(feature = "parquet")]
17use polars_parquet::arrow::write::StatisticsOptions;
18use polars_plan::dsl::ScanSources;
19use polars_plan::plans::{AExpr, HintIR, IR, Sorted};
20use polars_utils::arena::{Arena, Node};
21use polars_utils::python_function::PythonObject;
22use pyo3::exceptions::{PyTypeError, PyValueError};
23use pyo3::prelude::*;
24use pyo3::pybacked::PyBackedStr;
25use pyo3::types::{PyCapsule, PyDict, PyDictMethods, PyList};
26
27use super::{PyLazyFrame, PyOptFlags};
28use crate::error::PyPolarsErr;
29use crate::expr::ToExprs;
30use crate::expr::datatype::PyDataTypeExpr;
31use crate::expr::selector::PySelector;
32use crate::interop::arrow::to_rust::pyarrow_schema_to_rust;
33#[cfg(feature = "json")]
34use crate::io::cloud_options::OptPyCloudOptions;
35use crate::io::scan_options::PyScanOptions;
36use crate::io::sink_options::PySinkOptions;
37use crate::io::sink_output::PyFileSinkDestination;
38use crate::lazyframe::visit::NodeTraverser;
39use crate::prelude::*;
40use crate::utils::{EnterPolarsExt, to_py_err};
41use crate::{PyDataFrame, PyExpr, PyLazyGroupBy};
42
43fn pyobject_to_first_path_and_scan_sources(
44    obj: Py<PyAny>,
45) -> PyResult<(Option<PlRefPath>, ScanSources)> {
46    use crate::file::{PythonScanSourceInput, get_python_scan_source_input};
47    Ok(match get_python_scan_source_input(obj, false)? {
48        PythonScanSourceInput::Path(path) => (
49            Some(path.clone()),
50            ScanSources::Paths(FromIterator::from_iter([path])),
51        ),
52        PythonScanSourceInput::File(file) => (None, ScanSources::Files([file.into()].into())),
53        PythonScanSourceInput::Buffer(buff) => (None, ScanSources::Buffers([buff].into())),
54    })
55}
56
57fn post_opt_callback(
58    lambda: &Py<PyAny>,
59    root: Node,
60    lp_arena: &mut Arena<IR>,
61    expr_arena: &mut Arena<AExpr>,
62    duration_since_start: Option<std::time::Duration>,
63) -> PolarsResult<()> {
64    Python::attach(|py| {
65        let nt = NodeTraverser::new(root, std::mem::take(lp_arena), std::mem::take(expr_arena));
66
67        // Get a copy of the arenas.
68        let arenas = nt.get_arenas();
69
70        // Pass the node visitor which allows the python callback to replace parts of the query plan.
71        // Remove "cuda" or specify better once we have multiple post-opt callbacks.
72        lambda
73            .call1(py, (nt, duration_since_start.map(|t| t.as_nanos() as u64)))
74            .map_err(|e| polars_err!(ComputeError: "'cuda' conversion failed: {}", e))?;
75
76        // Unpack the arenas.
77        // At this point the `nt` is useless.
78
79        std::mem::swap(lp_arena, &mut *arenas.0.lock().unwrap());
80        std::mem::swap(expr_arena, &mut *arenas.1.lock().unwrap());
81
82        Ok(())
83    })
84}
85
86#[pymethods]
87#[allow(clippy::should_implement_trait)]
88impl PyLazyFrame {
89    #[staticmethod]
90    #[cfg(feature = "json")]
91    #[allow(clippy::too_many_arguments)]
92    #[pyo3(signature = (
93        source, sources, infer_schema_length, schema, schema_overrides, batch_size, n_rows, low_memory, rechunk,
94        row_index, ignore_errors, include_file_paths, cloud_options, credential_provider
95    ))]
96    fn new_from_ndjson(
97        source: Option<Py<PyAny>>,
98        sources: Wrap<ScanSources>,
99        infer_schema_length: Option<usize>,
100        schema: Option<Wrap<Schema>>,
101        schema_overrides: Option<Wrap<Schema>>,
102        batch_size: Option<NonZeroUsize>,
103        n_rows: Option<usize>,
104        low_memory: bool,
105        rechunk: bool,
106        row_index: Option<(String, IdxSize)>,
107        ignore_errors: bool,
108        include_file_paths: Option<String>,
109        cloud_options: OptPyCloudOptions,
110        credential_provider: Option<Py<PyAny>>,
111    ) -> PyResult<Self> {
112        let row_index = row_index.map(|(name, offset)| RowIndex {
113            name: name.into(),
114            offset,
115        });
116
117        let sources = sources.0;
118        let (first_path, sources) = match source {
119            None => (sources.first_path().cloned(), sources),
120            Some(source) => pyobject_to_first_path_and_scan_sources(source)?,
121        };
122
123        let mut r = LazyJsonLineReader::new_with_sources(sources);
124
125        if let Some(first_path) = first_path {
126            let first_path_url = first_path.as_str();
127
128            let cloud_options = cloud_options.extract_opt_cloud_options(
129                CloudScheme::from_path(first_path_url),
130                credential_provider,
131            )?;
132
133            r = r.with_cloud_options(cloud_options);
134        };
135
136        let lf = r
137            .with_infer_schema_length(infer_schema_length.and_then(NonZeroUsize::new))
138            .with_batch_size(batch_size)
139            .with_n_rows(n_rows)
140            .low_memory(low_memory)
141            .with_rechunk(rechunk)
142            .with_schema(schema.map(|schema| Arc::new(schema.0)))
143            .with_schema_overwrite(schema_overrides.map(|x| Arc::new(x.0)))
144            .with_row_index(row_index)
145            .with_ignore_errors(ignore_errors)
146            .with_include_file_paths(include_file_paths.map(|x| x.into()))
147            .finish()
148            .map_err(PyPolarsErr::from)?;
149
150        Ok(lf.into())
151    }
152
153    #[staticmethod]
154    #[cfg(feature = "csv")]
155    #[pyo3(signature = (source, sources, separator, has_header, ignore_errors, skip_rows, skip_lines, n_rows, cache, overwrite_dtype, overwrite_dtype_slice,
156        low_memory, comment_prefix, quote_char, null_values, empty_string_is_null,
157        infer_schema_length, infer_schema_files, new_columns, with_schema_modify, rechunk, skip_rows_after_header,
158        encoding, row_index, try_parse_dates, eol_char, raise_if_empty, truncate_ragged_lines, decimal_comma, glob, schema,
159        cloud_options, credential_provider, include_file_paths, missing_columns
160    )
161    )]
162    fn new_from_csv(
163        source: Option<Py<PyAny>>,
164        sources: Wrap<ScanSources>,
165        separator: &str,
166        has_header: bool,
167        ignore_errors: bool,
168        skip_rows: usize,
169        skip_lines: usize,
170        n_rows: Option<usize>,
171        cache: bool,
172        overwrite_dtype: Option<Vec<(PyBackedStr, Wrap<DataType>)>>,
173        overwrite_dtype_slice: Option<Vec<Wrap<DataType>>>,
174        low_memory: bool,
175        comment_prefix: Option<&str>,
176        quote_char: Option<&str>,
177        null_values: Option<Wrap<NullValues>>,
178        empty_string_is_null: bool,
179        infer_schema_length: Option<usize>,
180        infer_schema_files: NonZeroUsize,
181        new_columns: Option<Wrap<Buffer<PlSmallStr>>>,
182        with_schema_modify: Option<Py<PyAny>>,
183        rechunk: bool,
184        skip_rows_after_header: usize,
185        encoding: Wrap<CsvEncoding>,
186        row_index: Option<(String, IdxSize)>,
187        try_parse_dates: bool,
188        eol_char: &str,
189        raise_if_empty: bool,
190        truncate_ragged_lines: bool,
191        decimal_comma: bool,
192        glob: bool,
193        schema: Option<Wrap<Schema>>,
194        cloud_options: OptPyCloudOptions,
195        credential_provider: Option<Py<PyAny>>,
196        include_file_paths: Option<String>,
197        missing_columns: Option<Wrap<MissingColumnsPolicy>>,
198    ) -> PyResult<Self> {
199        let null_values = null_values.map(|w| w.0);
200        let quote_char = quote_char.and_then(|s| s.as_bytes().first()).copied();
201        let separator = separator
202            .as_bytes()
203            .first()
204            .ok_or_else(|| polars_err!(InvalidOperation: "`separator` cannot be empty"))
205            .copied()
206            .map_err(PyPolarsErr::from)?;
207        let eol_char = eol_char
208            .as_bytes()
209            .first()
210            .ok_or_else(|| polars_err!(InvalidOperation: "`eol_char` cannot be empty"))
211            .copied()
212            .map_err(PyPolarsErr::from)?;
213        let row_index = row_index.map(|(name, offset)| RowIndex {
214            name: name.into(),
215            offset,
216        });
217
218        let overwrite_dtype = overwrite_dtype.map(|overwrite_dtype| {
219            overwrite_dtype
220                .into_iter()
221                .map(|(name, dtype)| Field::new((&*name).into(), dtype.0))
222                .collect::<Schema>()
223        });
224        let overwrite_dtype_slice = overwrite_dtype_slice.map(|overwrite_dtype| {
225            overwrite_dtype
226                .into_iter()
227                .map(|dtype| dtype.0)
228                .collect::<Vec<_>>()
229        });
230
231        let sources = sources.0;
232        let (first_path, sources) = match source {
233            None => (sources.first_path().cloned(), sources),
234            Some(source) => pyobject_to_first_path_and_scan_sources(source)?,
235        };
236
237        let mut r = LazyCsvReader::new_with_sources(sources);
238
239        if let Some(first_path) = first_path {
240            let first_path_url = first_path.as_str();
241            let cloud_options = cloud_options.extract_opt_cloud_options(
242                CloudScheme::from_path(first_path_url),
243                credential_provider,
244            )?;
245            r = r.with_cloud_options(cloud_options);
246        }
247
248        let mut r = r
249            .with_infer_schema_length(infer_schema_length)
250            .with_infer_schema_files(infer_schema_files)
251            .with_separator(separator)
252            .with_has_header(has_header)
253            .with_ignore_errors(ignore_errors)
254            .with_skip_rows(skip_rows)
255            .with_skip_lines(skip_lines)
256            .with_n_rows(n_rows)
257            .with_cache(cache)
258            .with_dtype_overwrite(overwrite_dtype.map(Arc::new))
259            .with_dtype_overwrite_by_position(overwrite_dtype_slice.map(Arc::new))
260            .with_schema(schema.map(|schema| Arc::new(schema.0)))
261            .with_low_memory(low_memory)
262            .with_comment_prefix(comment_prefix.map(|x| x.into()))
263            .with_quote_char(quote_char)
264            .with_eol_char(eol_char)
265            .with_rechunk(rechunk)
266            .with_skip_rows_after_header(skip_rows_after_header)
267            .with_encoding(encoding.0)
268            .with_row_index(row_index)
269            .with_try_parse_dates(try_parse_dates)
270            .with_null_values(null_values)
271            .with_missing_is_null(empty_string_is_null)
272            .with_truncate_ragged_lines(truncate_ragged_lines)
273            .with_decimal_comma(decimal_comma)
274            .with_glob(glob)
275            .with_raise_if_empty(raise_if_empty)
276            .with_include_file_paths(include_file_paths.map(|x| x.into()))
277            .with_missing_columns_policy(missing_columns.map(|x| x.0));
278
279        if let Some(new_columns) = new_columns {
280            r = r.with_column_names_overwrite(new_columns.0);
281        }
282
283        if let Some(lambda) = with_schema_modify {
284            let f = |schema: Schema| {
285                let iter = schema.iter_names().map(|s| s.as_str());
286                Python::attach(|py| {
287                    let names = PyList::new(py, iter).unwrap();
288
289                    let out = lambda.call1(py, (names,)).expect("python function failed");
290                    let new_names = out
291                        .extract::<Vec<String>>(py)
292                        .expect("python function should return List[str]");
293                    polars_ensure!(new_names.len() == schema.len(),
294                        ShapeMismatch: "The length of the new names list should be equal to or less than the original column length",
295                    );
296                    Ok(schema
297                        .iter_values()
298                        .zip(new_names)
299                        .map(|(dtype, name)| Field::new(name.into(), dtype.clone()))
300                        .collect())
301                })
302            };
303            r = r.with_schema_modify(f).map_err(PyPolarsErr::from)?
304        }
305
306        Ok(r.finish().map_err(PyPolarsErr::from)?.into())
307    }
308
309    #[cfg(feature = "parquet")]
310    #[staticmethod]
311    #[pyo3(signature = (
312        sources, schema, scan_options, parallel, low_memory, use_statistics
313    ))]
314    fn new_from_parquet(
315        sources: Wrap<ScanSources>,
316        schema: Option<Wrap<Schema>>,
317        scan_options: PyScanOptions,
318        parallel: Wrap<ParallelStrategy>,
319        low_memory: bool,
320        use_statistics: bool,
321    ) -> PyResult<Self> {
322        use crate::utils::to_py_err;
323
324        let parallel = parallel.0;
325
326        let options = ParquetOptions {
327            schema: schema.map(|x| Arc::new(x.0)),
328            parallel,
329            low_memory,
330            use_statistics,
331        };
332
333        let sources = sources.0;
334        let first_path = sources.first_path();
335
336        let unified_scan_args =
337            scan_options.extract_unified_scan_args(first_path.and_then(|x| x.scheme()))?;
338
339        let lf: LazyFrame = DslBuilder::scan_parquet(sources, options, unified_scan_args)
340            .map_err(to_py_err)?
341            .build()
342            .into();
343
344        Ok(lf.into())
345    }
346
347    #[cfg(feature = "ipc")]
348    #[staticmethod]
349    #[pyo3(signature = (sources, record_batch_statistics, scan_options))]
350    fn new_from_ipc(
351        sources: Wrap<ScanSources>,
352        record_batch_statistics: bool,
353        scan_options: PyScanOptions,
354    ) -> PyResult<Self> {
355        let options = IpcScanOptions {
356            record_batch_statistics,
357            checked: Default::default(),
358        };
359
360        let sources = sources.0;
361        let first_path = sources.first_path().cloned();
362
363        let unified_scan_args =
364            scan_options.extract_unified_scan_args(first_path.as_ref().and_then(|x| x.scheme()))?;
365
366        let lf = LazyFrame::scan_ipc_sources(sources, options, unified_scan_args)
367            .map_err(PyPolarsErr::from)?;
368        Ok(lf.into())
369    }
370
371    #[cfg(feature = "scan_lines")]
372    #[staticmethod]
373    #[pyo3(signature = (sources, scan_options, name))]
374    fn new_from_scan_lines(
375        sources: Wrap<ScanSources>,
376        scan_options: PyScanOptions,
377        name: PyBackedStr,
378    ) -> PyResult<Self> {
379        let sources = sources.0;
380        let first_path = sources.first_path();
381
382        let unified_scan_args =
383            scan_options.extract_unified_scan_args(first_path.and_then(|x| x.scheme()))?;
384
385        let dsl: DslPlan = DslBuilder::scan_lines(sources, unified_scan_args, (&*name).into())
386            .map_err(to_py_err)?
387            .build();
388        let lf: LazyFrame = dsl.into();
389
390        Ok(lf.into())
391    }
392
393    #[cfg(feature = "scan_lines")]
394    #[staticmethod]
395    #[pyo3(signature = (sources, scan_options, name))]
396    fn new_from_expand_paths(
397        sources: Wrap<ScanSources>,
398        scan_options: PyScanOptions,
399        name: PyBackedStr,
400    ) -> PyResult<Self> {
401        let sources = sources.0;
402        let first_path = sources.first_path();
403
404        let unified_scan_args =
405            scan_options.extract_unified_scan_args(first_path.and_then(|x| x.scheme()))?;
406
407        let dsl: DslPlan = DslBuilder::expand_paths(sources, unified_scan_args, (&*name).into())
408            .map_err(to_py_err)?
409            .build();
410        let lf: LazyFrame = dsl.into();
411
412        Ok(lf.into())
413    }
414
415    #[staticmethod]
416    #[pyo3(signature = (
417        dataset_object
418    ))]
419    fn new_from_dataset_object(dataset_object: Py<PyAny>) -> PyResult<Self> {
420        let lf =
421            LazyFrame::from(DslBuilder::scan_python_dataset(PythonObject(dataset_object)).build())
422                .into();
423
424        Ok(lf)
425    }
426
427    #[staticmethod]
428    fn scan_from_python_function_arrow_schema(
429        schema: &Bound<'_, PyList>,
430        scan_fn: Py<PyAny>,
431        pyarrow: bool,
432        validate_schema: bool,
433        is_pure: bool,
434    ) -> PyResult<Self> {
435        let schema = Arc::new(pyarrow_schema_to_rust(schema)?);
436
437        Ok(LazyFrame::scan_from_python_function(
438            Either::Right(schema),
439            scan_fn,
440            pyarrow,
441            validate_schema,
442            is_pure,
443        )
444        .into())
445    }
446
447    #[staticmethod]
448    fn scan_from_python_function_pl_schema(
449        schema: Vec<(PyBackedStr, Wrap<DataType>)>,
450        scan_fn: Py<PyAny>,
451        pyarrow: bool,
452        validate_schema: bool,
453        is_pure: bool,
454    ) -> PyResult<Self> {
455        let schema = Arc::new(Schema::from_iter(
456            schema
457                .into_iter()
458                .map(|(name, dt)| Field::new((&*name).into(), dt.0)),
459        ));
460        Ok(LazyFrame::scan_from_python_function(
461            Either::Right(schema),
462            scan_fn,
463            pyarrow,
464            validate_schema,
465            is_pure,
466        )
467        .into())
468    }
469
470    #[staticmethod]
471    fn scan_from_python_function_schema_function(
472        schema_fn: Py<PyAny>,
473        scan_fn: Py<PyAny>,
474        validate_schema: bool,
475        is_pure: bool,
476    ) -> PyResult<Self> {
477        Ok(LazyFrame::scan_from_python_function(
478            Either::Left(schema_fn),
479            scan_fn,
480            false,
481            validate_schema,
482            is_pure,
483        )
484        .into())
485    }
486
487    fn describe_plan(&self, py: Python) -> PyResult<String> {
488        py.enter_polars(|| self.ldf.read().describe_plan())
489    }
490
491    fn describe_optimized_plan(&self, py: Python) -> PyResult<String> {
492        py.enter_polars(|| self.ldf.read().describe_optimized_plan())
493    }
494
495    fn describe_plan_tree(&self, py: Python) -> PyResult<String> {
496        py.enter_polars(|| self.ldf.read().describe_plan_tree())
497    }
498
499    fn describe_optimized_plan_tree(&self, py: Python) -> PyResult<String> {
500        py.enter_polars(|| self.ldf.read().describe_optimized_plan_tree())
501    }
502
503    fn to_dot(&self, py: Python<'_>, optimized: bool) -> PyResult<String> {
504        py.enter_polars(|| self.ldf.read().to_dot(optimized))
505    }
506
507    #[cfg(feature = "streaming")]
508    fn to_dot_streaming_phys(&self, py: Python, optimized: bool) -> PyResult<String> {
509        py.enter_polars(|| self.ldf.read().to_dot_streaming_phys(optimized))
510    }
511
512    fn sort(
513        &self,
514        by_column: &str,
515        descending: bool,
516        nulls_last: bool,
517        maintain_order: bool,
518        multithreaded: bool,
519    ) -> Self {
520        let ldf = self.ldf.read().clone();
521        ldf.sort(
522            [by_column],
523            SortMultipleOptions {
524                descending: vec![descending],
525                nulls_last: vec![nulls_last],
526                multithreaded,
527                maintain_order,
528                limit: None,
529            },
530        )
531        .into()
532    }
533
534    fn sort_by_exprs(
535        &self,
536        by: Vec<PyExpr>,
537        descending: Vec<bool>,
538        nulls_last: Vec<bool>,
539        maintain_order: bool,
540        multithreaded: bool,
541    ) -> Self {
542        let ldf = self.ldf.read().clone();
543        let exprs = by.to_exprs();
544        ldf.sort_by_exprs(
545            exprs,
546            SortMultipleOptions {
547                descending,
548                nulls_last,
549                maintain_order,
550                multithreaded,
551                limit: None,
552            },
553        )
554        .into()
555    }
556
557    fn top_k(&self, k: IdxSize, by: Vec<PyExpr>, reverse: Vec<bool>) -> Self {
558        let ldf = self.ldf.read().clone();
559        let exprs = by.to_exprs();
560        ldf.top_k(
561            k,
562            exprs,
563            SortMultipleOptions::new().with_order_descending_multi(reverse),
564        )
565        .into()
566    }
567
568    fn bottom_k(&self, k: IdxSize, by: Vec<PyExpr>, reverse: Vec<bool>) -> Self {
569        let ldf = self.ldf.read().clone();
570        let exprs = by.to_exprs();
571        ldf.bottom_k(
572            k,
573            exprs,
574            SortMultipleOptions::new().with_order_descending_multi(reverse),
575        )
576        .into()
577    }
578
579    fn cache(&self) -> Self {
580        let ldf = self.ldf.read().clone();
581        ldf.cache().into()
582    }
583
584    #[pyo3(signature = (optflags))]
585    fn with_optimizations(&self, optflags: PyOptFlags) -> Self {
586        let ldf = self.ldf.read().clone();
587        ldf.with_optimizations(optflags.inner.into_inner()).into()
588    }
589
590    #[pyo3(signature = (lambda_post_opt))]
591    fn profile(
592        &self,
593        py: Python<'_>,
594        lambda_post_opt: Option<Py<PyAny>>,
595    ) -> PyResult<(PyDataFrame, PyDataFrame)> {
596        let (df, time_df) = py.enter_polars(|| {
597            let ldf = self.ldf.read().clone();
598            if let Some(lambda) = lambda_post_opt {
599                ldf._profile_post_opt(|root, lp_arena, expr_arena, duration_since_start| {
600                    post_opt_callback(&lambda, root, lp_arena, expr_arena, duration_since_start)
601                })
602            } else {
603                ldf.profile()
604            }
605        })?;
606        Ok((df.into(), time_df.into()))
607    }
608
609    #[pyo3(signature = (engine, lambda_post_opt))]
610    fn collect(
611        &self,
612        py: Python<'_>,
613        engine: Wrap<Engine>,
614        lambda_post_opt: Option<Py<PyAny>>,
615    ) -> PyResult<PyDataFrame> {
616        py.enter_polars_df(|| {
617            let ldf = self.ldf.read().clone();
618            if let Some(lambda) = lambda_post_opt {
619                ldf._collect_post_opt(|root, lp_arena, expr_arena, _| {
620                    post_opt_callback(&lambda, root, lp_arena, expr_arena, None)
621                })
622            } else {
623                ldf.collect_with_engine(engine.0).map(|r| match r {
624                    QueryResult::Single(df) => df,
625                    // TODO: Should return query results
626                    QueryResult::Multiple(_) => DataFrame::empty(),
627                })
628            }
629        })
630    }
631
632    #[cfg(feature = "async")]
633    #[pyo3(signature = (engine, lambda))]
634    fn collect_with_callback(
635        &self,
636        py: Python<'_>,
637        engine: Wrap<Engine>,
638        lambda: Py<PyAny>,
639    ) -> PyResult<()> {
640        py.enter_polars_ok(|| {
641            let ldf = self.ldf.read().clone();
642
643            // We use a tokio spawn_blocking here as it has a high blocking
644            // thread pool limit.
645            polars_core::runtime::ASYNC.spawn_blocking(move || {
646                let result = ldf
647                    .collect_with_engine(engine.0)
648                    .map(|r| match r {
649                        QueryResult::Single(df) => df,
650                        // TODO: Should return query results
651                        QueryResult::Multiple(_) => DataFrame::empty(),
652                    })
653                    .map(PyDataFrame::new)
654                    .map_err(PyPolarsErr::from);
655
656                Python::attach(|py| match result {
657                    Ok(df) => {
658                        lambda.call1(py, (df,)).map_err(|err| err.restore(py)).ok();
659                    },
660                    Err(err) => {
661                        lambda
662                            .call1(py, (PyErr::from(err),))
663                            .map_err(|err| err.restore(py))
664                            .ok();
665                    },
666                });
667            });
668        })
669    }
670
671    #[cfg(feature = "async")]
672    fn collect_batches(
673        &self,
674        py: Python<'_>,
675        engine: Wrap<Engine>,
676        maintain_order: bool,
677        chunk_size: Option<NonZeroUsize>,
678        lazy: bool,
679    ) -> PyResult<PyCollectBatches> {
680        py.enter_polars(|| {
681            let ldf = self.ldf.read().clone();
682
683            let collect_batches = ldf
684                .clone()
685                .collect_batches(engine.0, maintain_order, chunk_size, lazy)
686                .map_err(PyPolarsErr::from)?;
687
688            PyResult::Ok(PyCollectBatches {
689                inner: Arc::new(Mutex::new(collect_batches)),
690                ldf,
691            })
692        })
693    }
694
695    #[cfg(feature = "parquet")]
696    #[pyo3(signature = (
697        target, sink_options, compression, compression_level, statistics, row_group_size, data_page_size,
698        metadata, arrow_schema
699    ))]
700    fn sink_parquet(
701        &self,
702        py: Python<'_>,
703        target: PyFileSinkDestination,
704        sink_options: PySinkOptions,
705        compression: &str,
706        compression_level: Option<i32>,
707        statistics: Wrap<StatisticsOptions>,
708        row_group_size: Option<usize>,
709        data_page_size: Option<usize>,
710        metadata: Wrap<Option<KeyValueMetadata>>,
711        arrow_schema: Option<Wrap<ArrowSchema>>,
712    ) -> PyResult<PyLazyFrame> {
713        let compression = parse_parquet_compression(compression, compression_level)?;
714
715        let options = ParquetWriteOptions {
716            compression,
717            statistics: statistics.0,
718            row_group_size,
719            data_page_size,
720            key_value_metadata: metadata.0,
721            arrow_schema: arrow_schema.map(|x| Arc::new(x.0)),
722            compat_level: None,
723        };
724
725        let target = target.extract_file_sink_destination()?;
726        let unified_sink_args = sink_options.extract_unified_sink_args(target.cloud_scheme())?;
727
728        py.enter_polars(|| {
729            self.ldf
730                .read()
731                .clone()
732                .sink(
733                    target,
734                    FileWriteFormat::Parquet(Arc::new(options)),
735                    unified_sink_args,
736                )
737                .into()
738        })
739        .map(Into::into)
740        .map_err(Into::into)
741    }
742
743    #[cfg(feature = "ipc")]
744    #[pyo3(signature = (
745        target, sink_options, compression, compat_level, record_batch_size, record_batch_statistics
746    ))]
747    fn sink_ipc(
748        &self,
749        py: Python<'_>,
750        target: PyFileSinkDestination,
751        sink_options: PySinkOptions,
752        compression: Wrap<Option<IpcCompression>>,
753        compat_level: PyCompatLevel,
754        record_batch_size: Option<usize>,
755        record_batch_statistics: bool,
756    ) -> PyResult<PyLazyFrame> {
757        let options = IpcWriterOptions {
758            compression: compression.0,
759            compat_level: compat_level.0,
760            record_batch_size,
761            record_batch_statistics,
762        };
763
764        let target = target.extract_file_sink_destination()?;
765        let unified_sink_args = sink_options.extract_unified_sink_args(target.cloud_scheme())?;
766
767        py.enter_polars(|| {
768            self.ldf
769                .read()
770                .clone()
771                .sink(target, FileWriteFormat::Ipc(options), unified_sink_args)
772                .into()
773        })
774        .map(Into::into)
775        .map_err(Into::into)
776    }
777
778    #[cfg(feature = "csv")]
779    #[pyo3(signature = (
780        target, sink_options, include_bom, compression, compression_level, check_extension,
781        include_header, separator, line_terminator, quote_char, batch_size, datetime_format,
782        date_format, time_format, float_scientific, float_precision, decimal_comma, null_value,
783        quote_style
784    ))]
785    fn sink_csv(
786        &self,
787        py: Python<'_>,
788        target: PyFileSinkDestination,
789        sink_options: PySinkOptions,
790        include_bom: bool,
791        compression: &str,
792        compression_level: Option<u32>,
793        check_extension: bool,
794        include_header: bool,
795        separator: u8,
796        line_terminator: Wrap<PlSmallStr>,
797        quote_char: u8,
798        batch_size: NonZeroUsize,
799        datetime_format: Option<Wrap<PlSmallStr>>,
800        date_format: Option<Wrap<PlSmallStr>>,
801        time_format: Option<Wrap<PlSmallStr>>,
802        float_scientific: Option<bool>,
803        float_precision: Option<usize>,
804        decimal_comma: bool,
805        null_value: Option<Wrap<PlSmallStr>>,
806        quote_style: Option<Wrap<QuoteStyle>>,
807    ) -> PyResult<PyLazyFrame> {
808        let quote_style = quote_style.map_or(QuoteStyle::default(), |wrap| wrap.0);
809        let null_value = null_value
810            .map(|x| x.0)
811            .unwrap_or(SerializeOptions::default().null);
812
813        let serialize_options = SerializeOptions {
814            date_format: date_format.map(|x| x.0),
815            time_format: time_format.map(|x| x.0),
816            datetime_format: datetime_format.map(|x| x.0),
817            float_scientific,
818            float_precision,
819            decimal_comma,
820            separator,
821            quote_char,
822            null: null_value,
823            line_terminator: line_terminator.0,
824            quote_style,
825        };
826
827        let options = CsvWriterOptions {
828            include_bom,
829            compression: ExternalCompression::try_from(compression, compression_level)
830                .map_err(PyPolarsErr::from)?,
831            check_extension,
832            include_header,
833            batch_size,
834            serialize_options: serialize_options.into(),
835        };
836
837        let target = target.extract_file_sink_destination()?;
838        let unified_sink_args = sink_options.extract_unified_sink_args(target.cloud_scheme())?;
839
840        py.enter_polars(|| {
841            self.ldf
842                .read()
843                .clone()
844                .sink(target, FileWriteFormat::Csv(options), unified_sink_args)
845                .into()
846        })
847        .map(Into::into)
848        .map_err(Into::into)
849    }
850
851    #[allow(clippy::too_many_arguments)]
852    #[cfg(feature = "json")]
853    #[pyo3(signature = (target, compression, compression_level, check_extension, sink_options))]
854    fn sink_ndjson(
855        &self,
856        py: Python<'_>,
857        target: PyFileSinkDestination,
858        compression: &str,
859        compression_level: Option<u32>,
860        check_extension: bool,
861        sink_options: PySinkOptions,
862    ) -> PyResult<PyLazyFrame> {
863        let options = NDJsonWriterOptions {
864            compression: ExternalCompression::try_from(compression, compression_level)
865                .map_err(PyPolarsErr::from)?,
866            check_extension,
867        };
868
869        let target = target.extract_file_sink_destination()?;
870        let unified_sink_args = sink_options.extract_unified_sink_args(target.cloud_scheme())?;
871
872        py.enter_polars(|| {
873            self.ldf
874                .read()
875                .clone()
876                .sink(target, FileWriteFormat::NDJson(options), unified_sink_args)
877                .into()
878        })
879        .map(Into::into)
880        .map_err(Into::into)
881    }
882
883    #[pyo3(signature = (function, maintain_order, chunk_size))]
884    pub fn sink_batches(
885        &self,
886        py: Python<'_>,
887        function: Py<PyAny>,
888        maintain_order: bool,
889        chunk_size: Option<NonZeroUsize>,
890    ) -> PyResult<PyLazyFrame> {
891        let ldf = self.ldf.read().clone();
892        py.enter_polars(|| {
893            ldf.sink_batches(
894                PlanCallback::new_python(PythonObject(function)),
895                maintain_order,
896                chunk_size,
897            )
898        })
899        .map(Into::into)
900        .map_err(Into::into)
901    }
902
903    pub fn sink_iceberg(&self, py: Python<'_>, sink_state_obj: Py<PyAny>) -> PyResult<PyLazyFrame> {
904        let sink_state: IcebergSinkState = sink_state_obj.extract(py)?;
905        let mut ldf = { self.ldf.read().clone() };
906
907        ldf.logical_plan = DslPlan::Sink {
908            input: Arc::new(ldf.logical_plan),
909            payload: SinkType::Iceberg(sink_state),
910        };
911
912        Ok(ldf.into())
913    }
914
915    fn filter(&self, predicate: PyExpr) -> Self {
916        self.ldf.read().clone().filter(predicate.inner).into()
917    }
918
919    fn remove(&self, predicate: PyExpr) -> Self {
920        let ldf = self.ldf.read().clone();
921        ldf.remove(predicate.inner).into()
922    }
923
924    fn select(&self, exprs: Vec<PyExpr>) -> Self {
925        let ldf = self.ldf.read().clone();
926        let exprs = exprs.to_exprs();
927        ldf.select(exprs).into()
928    }
929
930    fn select_seq(&self, exprs: Vec<PyExpr>) -> Self {
931        let ldf = self.ldf.read().clone();
932        let exprs = exprs.to_exprs();
933        ldf.select_seq(exprs).into()
934    }
935
936    fn group_by(&self, by: Vec<PyExpr>, maintain_order: bool) -> PyLazyGroupBy {
937        let ldf = self.ldf.read().clone();
938        let by = by.to_exprs();
939        let lazy_gb = if maintain_order {
940            ldf.group_by_stable(by)
941        } else {
942            ldf.group_by(by)
943        };
944
945        PyLazyGroupBy { lgb: Some(lazy_gb) }
946    }
947
948    fn rolling(
949        &self,
950        index_column: PyExpr,
951        period: &str,
952        offset: &str,
953        closed: Wrap<ClosedWindow>,
954        by: Vec<PyExpr>,
955    ) -> PyResult<PyLazyGroupBy> {
956        let closed_window = closed.0;
957        let ldf = self.ldf.read().clone();
958        let by = by
959            .into_iter()
960            .map(|pyexpr| pyexpr.inner)
961            .collect::<Vec<_>>();
962        let lazy_gb = ldf.rolling(
963            index_column.inner,
964            by,
965            RollingGroupOptions {
966                index_column: "".into(),
967                period: Duration::try_parse(period).map_err(PyPolarsErr::from)?,
968                offset: Duration::try_parse(offset).map_err(PyPolarsErr::from)?,
969                closed_window,
970            },
971        );
972
973        Ok(PyLazyGroupBy { lgb: Some(lazy_gb) })
974    }
975
976    fn group_by_dynamic(
977        &self,
978        index_column: PyExpr,
979        every: &str,
980        period: &str,
981        offset: &str,
982        label: Wrap<Label>,
983        include_boundaries: bool,
984        closed: Wrap<ClosedWindow>,
985        group_by: Vec<PyExpr>,
986        start_by: Wrap<StartBy>,
987    ) -> PyResult<PyLazyGroupBy> {
988        let closed_window = closed.0;
989        let group_by = group_by
990            .into_iter()
991            .map(|pyexpr| pyexpr.inner)
992            .collect::<Vec<_>>();
993        let ldf = self.ldf.read().clone();
994        let lazy_gb = ldf.group_by_dynamic(
995            index_column.inner,
996            group_by,
997            DynamicGroupOptions {
998                every: Duration::try_parse(every).map_err(PyPolarsErr::from)?,
999                period: Duration::try_parse(period).map_err(PyPolarsErr::from)?,
1000                offset: Duration::try_parse(offset).map_err(PyPolarsErr::from)?,
1001                label: label.0,
1002                include_boundaries,
1003                closed_window,
1004                start_by: start_by.0,
1005                ..Default::default()
1006            },
1007        );
1008
1009        Ok(PyLazyGroupBy { lgb: Some(lazy_gb) })
1010    }
1011
1012    fn with_context(&self, contexts: Vec<Self>) -> Self {
1013        let contexts = contexts
1014            .into_iter()
1015            .map(|ldf| ldf.ldf.into_inner())
1016            .collect::<Vec<_>>();
1017        self.ldf.read().clone().with_context(contexts).into()
1018    }
1019
1020    #[cfg(feature = "asof_join")]
1021    #[pyo3(signature = (other, left_on, right_on, left_by, right_by, allow_parallel, force_parallel, suffix, strategy, tolerance, tolerance_str, coalesce, allow_eq, check_sortedness))]
1022    fn join_asof(
1023        &self,
1024        other: Self,
1025        left_on: PyExpr,
1026        right_on: PyExpr,
1027        left_by: Option<Vec<PyBackedStr>>,
1028        right_by: Option<Vec<PyBackedStr>>,
1029        allow_parallel: bool,
1030        force_parallel: bool,
1031        suffix: String,
1032        strategy: Wrap<AsofStrategy>,
1033        tolerance: Option<Wrap<AnyValue<'_>>>,
1034        tolerance_str: Option<String>,
1035        coalesce: bool,
1036        allow_eq: bool,
1037        check_sortedness: bool,
1038    ) -> PyResult<Self> {
1039        let coalesce = if coalesce {
1040            JoinCoalesce::CoalesceColumns
1041        } else {
1042            JoinCoalesce::KeepColumns
1043        };
1044        let ldf = self.ldf.read().clone();
1045        let other = other.ldf.into_inner();
1046        let left_on = left_on.inner;
1047        let right_on = right_on.inner;
1048        Ok(ldf
1049            .join_builder()
1050            .with(other)
1051            .left_on([left_on])
1052            .right_on([right_on])
1053            .allow_parallel(allow_parallel)
1054            .force_parallel(force_parallel)
1055            .coalesce(coalesce)
1056            .how(JoinType::AsOf(Box::new(AsOfOptions {
1057                strategy: strategy.0,
1058                left_by: left_by.map(strings_to_pl_smallstr),
1059                right_by: right_by.map(strings_to_pl_smallstr),
1060                tolerance: tolerance.map(|t| {
1061                    let av = t.0.into_static();
1062                    let dtype = av.dtype();
1063                    Scalar::new(dtype, av)
1064                }),
1065                tolerance_str: tolerance_str.map(|s| s.into()),
1066                allow_eq,
1067                check_sortedness,
1068            })))
1069            .suffix(suffix)
1070            .finish()
1071            .into())
1072    }
1073
1074    #[pyo3(signature = (other, left_on, right_on, allow_parallel, force_parallel, nulls_equal, how, suffix, validate, maintain_order, build_side, coalesce=None))]
1075    fn join(
1076        &self,
1077        other: Self,
1078        left_on: Vec<PyExpr>,
1079        right_on: Vec<PyExpr>,
1080        allow_parallel: bool,
1081        force_parallel: bool,
1082        nulls_equal: bool,
1083        how: Wrap<JoinType>,
1084        suffix: String,
1085        validate: Wrap<JoinValidation>,
1086        maintain_order: Wrap<MaintainOrderJoin>,
1087        build_side: Wrap<Option<JoinBuildSide>>,
1088        coalesce: Option<bool>,
1089    ) -> PyResult<Self> {
1090        let coalesce = match coalesce {
1091            None => JoinCoalesce::JoinSpecific,
1092            Some(true) => JoinCoalesce::CoalesceColumns,
1093            Some(false) => JoinCoalesce::KeepColumns,
1094        };
1095        let ldf = self.ldf.read().clone();
1096        let other = other.ldf.into_inner();
1097        let left_on = left_on
1098            .into_iter()
1099            .map(|pyexpr| pyexpr.inner)
1100            .collect::<Vec<_>>();
1101        let right_on = right_on
1102            .into_iter()
1103            .map(|pyexpr| pyexpr.inner)
1104            .collect::<Vec<_>>();
1105
1106        Ok(ldf
1107            .join_builder()
1108            .with(other)
1109            .left_on(left_on)
1110            .right_on(right_on)
1111            .allow_parallel(allow_parallel)
1112            .force_parallel(force_parallel)
1113            .join_nulls(nulls_equal)
1114            .how(how.0)
1115            .suffix(suffix)
1116            .validate(validate.0)
1117            .coalesce(coalesce)
1118            .maintain_order(maintain_order.0)
1119            .build_side(build_side.0)
1120            .finish()
1121            .into())
1122    }
1123
1124    fn join_where(&self, other: Self, predicates: Vec<PyExpr>, suffix: String) -> PyResult<Self> {
1125        let ldf = self.ldf.read().clone();
1126        let other = other.ldf.into_inner();
1127
1128        let predicates = predicates.to_exprs();
1129
1130        Ok(ldf
1131            .join_builder()
1132            .with(other)
1133            .suffix(suffix)
1134            .join_where(predicates)
1135            .into())
1136    }
1137
1138    fn gather(&self, idxs: Self, null_on_oob: bool) -> Self {
1139        let ldf = self.ldf.read().clone();
1140        let idxs = idxs.ldf.into_inner();
1141        ldf.gather(idxs, null_on_oob).into()
1142    }
1143
1144    fn with_columns(&self, exprs: Vec<PyExpr>) -> Self {
1145        let ldf = self.ldf.read().clone();
1146        ldf.with_columns(exprs.to_exprs()).into()
1147    }
1148
1149    fn with_columns_seq(&self, exprs: Vec<PyExpr>) -> Self {
1150        let ldf = self.ldf.read().clone();
1151        ldf.with_columns_seq(exprs.to_exprs()).into()
1152    }
1153
1154    fn match_to_schema<'py>(
1155        &self,
1156        schema: Wrap<Schema>,
1157        missing_columns: &Bound<'py, PyAny>,
1158        missing_struct_fields: &Bound<'py, PyAny>,
1159        extra_columns: Wrap<ExtraColumnsPolicy>,
1160        extra_struct_fields: &Bound<'py, PyAny>,
1161        integer_cast: &Bound<'py, PyAny>,
1162        float_cast: &Bound<'py, PyAny>,
1163    ) -> PyResult<Self> {
1164        fn parse_missing_columns<'py>(
1165            schema: &Schema,
1166            missing_columns: &Bound<'py, PyAny>,
1167        ) -> PyResult<Vec<MissingColumnsPolicyOrExpr>> {
1168            let mut out = Vec::with_capacity(schema.len());
1169            if let Ok(policy) = missing_columns.extract::<Wrap<MissingColumnsPolicyOrExpr>>() {
1170                out.extend(std::iter::repeat_n(policy.0, schema.len()));
1171            } else if let Ok(dict) = missing_columns.cast::<PyDict>() {
1172                out.extend(std::iter::repeat_n(
1173                    MissingColumnsPolicyOrExpr::Raise,
1174                    schema.len(),
1175                ));
1176                for (key, value) in dict.iter() {
1177                    let key = key.extract::<String>()?;
1178                    let value = value.extract::<Wrap<MissingColumnsPolicyOrExpr>>()?;
1179                    out[schema.try_index_of(&key).map_err(to_py_err)?] = value.0;
1180                }
1181            } else {
1182                return Err(PyTypeError::new_err("Invalid value for `missing_columns`"));
1183            }
1184            Ok(out)
1185        }
1186        fn parse_missing_struct_fields<'py>(
1187            schema: &Schema,
1188            missing_struct_fields: &Bound<'py, PyAny>,
1189        ) -> PyResult<Vec<MissingColumnsPolicy>> {
1190            let mut out = Vec::with_capacity(schema.len());
1191            if let Ok(policy) = missing_struct_fields.extract::<Wrap<MissingColumnsPolicy>>() {
1192                out.extend(std::iter::repeat_n(policy.0, schema.len()));
1193            } else if let Ok(dict) = missing_struct_fields.cast::<PyDict>() {
1194                out.extend(std::iter::repeat_n(
1195                    MissingColumnsPolicy::Raise,
1196                    schema.len(),
1197                ));
1198                for (key, value) in dict.iter() {
1199                    let key = key.extract::<String>()?;
1200                    let value = value.extract::<Wrap<MissingColumnsPolicy>>()?;
1201                    out[schema.try_index_of(&key).map_err(to_py_err)?] = value.0;
1202                }
1203            } else {
1204                return Err(PyTypeError::new_err(
1205                    "Invalid value for `missing_struct_fields`",
1206                ));
1207            }
1208            Ok(out)
1209        }
1210        fn parse_extra_struct_fields<'py>(
1211            schema: &Schema,
1212            extra_struct_fields: &Bound<'py, PyAny>,
1213        ) -> PyResult<Vec<ExtraColumnsPolicy>> {
1214            let mut out = Vec::with_capacity(schema.len());
1215            if let Ok(policy) = extra_struct_fields.extract::<Wrap<ExtraColumnsPolicy>>() {
1216                out.extend(std::iter::repeat_n(policy.0, schema.len()));
1217            } else if let Ok(dict) = extra_struct_fields.cast::<PyDict>() {
1218                out.extend(std::iter::repeat_n(ExtraColumnsPolicy::Raise, schema.len()));
1219                for (key, value) in dict.iter() {
1220                    let key = key.extract::<String>()?;
1221                    let value = value.extract::<Wrap<ExtraColumnsPolicy>>()?;
1222                    out[schema.try_index_of(&key).map_err(to_py_err)?] = value.0;
1223                }
1224            } else {
1225                return Err(PyTypeError::new_err(
1226                    "Invalid value for `extra_struct_fields`",
1227                ));
1228            }
1229            Ok(out)
1230        }
1231        fn parse_cast<'py>(
1232            schema: &Schema,
1233            cast: &Bound<'py, PyAny>,
1234        ) -> PyResult<Vec<UpcastOrForbid>> {
1235            let mut out = Vec::with_capacity(schema.len());
1236            if let Ok(policy) = cast.extract::<Wrap<UpcastOrForbid>>() {
1237                out.extend(std::iter::repeat_n(policy.0, schema.len()));
1238            } else if let Ok(dict) = cast.cast::<PyDict>() {
1239                out.extend(std::iter::repeat_n(UpcastOrForbid::Forbid, schema.len()));
1240                for (key, value) in dict.iter() {
1241                    let key = key.extract::<String>()?;
1242                    let value = value.extract::<Wrap<UpcastOrForbid>>()?;
1243                    out[schema.try_index_of(&key).map_err(to_py_err)?] = value.0;
1244                }
1245            } else {
1246                return Err(PyTypeError::new_err(
1247                    "Invalid value for `integer_cast` / `float_cast`",
1248                ));
1249            }
1250            Ok(out)
1251        }
1252
1253        let missing_columns = parse_missing_columns(&schema.0, missing_columns)?;
1254        let missing_struct_fields = parse_missing_struct_fields(&schema.0, missing_struct_fields)?;
1255        let extra_struct_fields = parse_extra_struct_fields(&schema.0, extra_struct_fields)?;
1256        let integer_cast = parse_cast(&schema.0, integer_cast)?;
1257        let float_cast = parse_cast(&schema.0, float_cast)?;
1258
1259        let per_column = (0..schema.0.len())
1260            .map(|i| MatchToSchemaPerColumn {
1261                missing_columns: missing_columns[i].clone(),
1262                missing_struct_fields: missing_struct_fields[i],
1263                extra_struct_fields: extra_struct_fields[i],
1264                integer_cast: integer_cast[i],
1265                float_cast: float_cast[i],
1266            })
1267            .collect();
1268
1269        let ldf = self.ldf.read().clone();
1270        Ok(ldf
1271            .match_to_schema(Arc::new(schema.0), per_column, extra_columns.0)
1272            .into())
1273    }
1274
1275    fn pipe_with_schema(&self, callback: Py<PyAny>) -> Self {
1276        let ldf = self.ldf.read().clone();
1277        let function = PythonObject(callback);
1278        ldf.pipe_with_schema(PlanCallback::new_python(function))
1279            .into()
1280    }
1281
1282    fn rename(&self, existing: Vec<String>, new: Vec<String>, strict: bool) -> Self {
1283        let ldf = self.ldf.read().clone();
1284        ldf.rename(existing, new, strict).into()
1285    }
1286
1287    fn reverse(&self) -> Self {
1288        let ldf = self.ldf.read().clone();
1289        ldf.reverse().into()
1290    }
1291
1292    #[pyo3(signature = (n, fill_value=None))]
1293    fn shift(&self, n: PyExpr, fill_value: Option<PyExpr>) -> Self {
1294        let lf = self.ldf.read().clone();
1295        let out = match fill_value {
1296            Some(v) => lf.shift_and_fill(n.inner, v.inner),
1297            None => lf.shift(n.inner),
1298        };
1299        out.into()
1300    }
1301
1302    fn fill_nan(&self, fill_value: PyExpr) -> Self {
1303        let ldf = self.ldf.read().clone();
1304        ldf.fill_nan(fill_value.inner).into()
1305    }
1306
1307    fn min(&self) -> Self {
1308        let ldf = self.ldf.read().clone();
1309        let out = ldf.min();
1310        out.into()
1311    }
1312
1313    fn max(&self) -> Self {
1314        let ldf = self.ldf.read().clone();
1315        let out = ldf.max();
1316        out.into()
1317    }
1318
1319    fn sum(&self) -> Self {
1320        let ldf = self.ldf.read().clone();
1321        let out = ldf.sum();
1322        out.into()
1323    }
1324
1325    fn mean(&self) -> Self {
1326        let ldf = self.ldf.read().clone();
1327        let out = ldf.mean();
1328        out.into()
1329    }
1330
1331    fn std(&self, ddof: u8) -> Self {
1332        let ldf = self.ldf.read().clone();
1333        let out = ldf.std(ddof);
1334        out.into()
1335    }
1336
1337    fn var(&self, ddof: u8) -> Self {
1338        let ldf = self.ldf.read().clone();
1339        let out = ldf.var(ddof);
1340        out.into()
1341    }
1342
1343    fn median(&self) -> Self {
1344        let ldf = self.ldf.read().clone();
1345        let out = ldf.median();
1346        out.into()
1347    }
1348
1349    fn quantile(&self, quantile: PyExpr, interpolation: Wrap<QuantileMethod>) -> Self {
1350        let ldf = self.ldf.read().clone();
1351        let out = ldf.quantile(quantile.inner, interpolation.0);
1352        out.into()
1353    }
1354
1355    fn explode(&self, subset: PySelector, empty_as_null: bool, keep_nulls: bool) -> Self {
1356        self.ldf
1357            .read()
1358            .clone()
1359            .explode(
1360                subset.inner,
1361                ExplodeOptions {
1362                    empty_as_null,
1363                    keep_nulls,
1364                },
1365            )
1366            .into()
1367    }
1368
1369    fn null_count(&self) -> Self {
1370        let ldf = self.ldf.read().clone();
1371        ldf.null_count().into()
1372    }
1373
1374    #[pyo3(signature = (maintain_order, subset, keep))]
1375    fn unique(
1376        &self,
1377        maintain_order: bool,
1378        subset: Option<Vec<PyExpr>>,
1379        keep: Wrap<UniqueKeepStrategy>,
1380    ) -> Self {
1381        let ldf = self.ldf.read().clone();
1382        let subset = subset.map(|exprs| exprs.into_iter().map(|e| e.inner).collect());
1383        match maintain_order {
1384            true => ldf.unique_stable_generic(subset, keep.0),
1385            false => ldf.unique_generic(subset, keep.0),
1386        }
1387        .into()
1388    }
1389
1390    fn drop_nans(&self, subset: Option<PySelector>) -> Self {
1391        self.ldf
1392            .read()
1393            .clone()
1394            .drop_nans(subset.map(|e| e.inner))
1395            .into()
1396    }
1397
1398    fn drop_nulls(&self, subset: Option<PySelector>) -> Self {
1399        self.ldf
1400            .read()
1401            .clone()
1402            .drop_nulls(subset.map(|e| e.inner))
1403            .into()
1404    }
1405
1406    #[pyo3(signature = (offset, len=None))]
1407    fn slice(&self, offset: i64, len: Option<IdxSize>) -> Self {
1408        let ldf = self.ldf.read().clone();
1409        ldf.slice(offset, len.unwrap_or(IdxSize::MAX)).into()
1410    }
1411
1412    fn tail(&self, n: IdxSize) -> Self {
1413        let ldf = self.ldf.read().clone();
1414        ldf.tail(n).into()
1415    }
1416
1417    #[cfg(feature = "pivot")]
1418    #[pyo3(signature = (on, on_columns, index, values, agg, maintain_order, separator, column_naming))]
1419    fn pivot(
1420        &self,
1421        on: PySelector,
1422        on_columns: PyDataFrame,
1423        index: PySelector,
1424        values: PySelector,
1425        agg: PyExpr,
1426        maintain_order: bool,
1427        separator: String,
1428        column_naming: Wrap<PivotColumnNaming>,
1429    ) -> Self {
1430        let ldf = self.ldf.read().clone();
1431        ldf.pivot(
1432            on.inner,
1433            Arc::new(on_columns.df.read().clone()),
1434            index.inner,
1435            values.inner,
1436            agg.inner,
1437            maintain_order,
1438            separator.into(),
1439            column_naming.0,
1440        )
1441        .into()
1442    }
1443
1444    #[cfg(feature = "pivot")]
1445    #[pyo3(signature = (on, index, value_name, variable_name))]
1446    fn unpivot(
1447        &self,
1448        on: Option<PySelector>,
1449        index: PySelector,
1450        value_name: Option<String>,
1451        variable_name: Option<String>,
1452    ) -> Self {
1453        let args = UnpivotArgsDSL {
1454            on: on.map(|on| on.inner),
1455            index: index.inner,
1456            value_name: value_name.map(|s| s.into()),
1457            variable_name: variable_name.map(|s| s.into()),
1458        };
1459
1460        let ldf = self.ldf.read().clone();
1461        ldf.unpivot(args).into()
1462    }
1463
1464    #[pyo3(signature = (name, offset=None))]
1465    fn with_row_index(&self, name: &str, offset: Option<IdxSize>) -> Self {
1466        let ldf = self.ldf.read().clone();
1467        ldf.with_row_index(name, offset).into()
1468    }
1469
1470    #[pyo3(signature = (function, predicate_pushdown, projection_pushdown, slice_pushdown, streamable, schema, validate_output))]
1471    fn map_batches(
1472        &self,
1473        function: Py<PyAny>,
1474        predicate_pushdown: bool,
1475        projection_pushdown: bool,
1476        slice_pushdown: bool,
1477        streamable: bool,
1478        schema: Option<Wrap<Schema>>,
1479        validate_output: bool,
1480    ) -> Self {
1481        let mut opt = OptFlags::default();
1482        opt.set(OptFlags::PREDICATE_PUSHDOWN, predicate_pushdown);
1483        opt.set(OptFlags::PROJECTION_PUSHDOWN, projection_pushdown);
1484        opt.set(OptFlags::SLICE_PUSHDOWN, slice_pushdown);
1485        opt.set(OptFlags::STREAMING, streamable);
1486
1487        self.ldf
1488            .read()
1489            .clone()
1490            .map_python(
1491                function.into(),
1492                opt,
1493                schema.map(|s| Arc::new(s.0)),
1494                validate_output,
1495            )
1496            .into()
1497    }
1498
1499    fn drop(&self, columns: PySelector) -> Self {
1500        self.ldf.read().clone().drop(columns.inner).into()
1501    }
1502
1503    fn cast(&self, dtypes: HashMap<PyBackedStr, Wrap<DataType>>, strict: bool) -> Self {
1504        let mut cast_map = PlHashMap::with_capacity(dtypes.len());
1505        cast_map.extend(dtypes.iter().map(|(k, v)| (k.as_ref(), v.0.clone())));
1506        self.ldf.read().clone().cast(cast_map, strict).into()
1507    }
1508
1509    fn cast_all(&self, dtype: PyDataTypeExpr, strict: bool) -> Self {
1510        self.ldf.read().clone().cast_all(dtype.inner, strict).into()
1511    }
1512
1513    fn clone(&self) -> Self {
1514        self.ldf.read().clone().into()
1515    }
1516
1517    fn collect_schema<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
1518        let schema = py.enter_polars(|| self.ldf.write().collect_schema())?;
1519
1520        let schema_dict = PyDict::new(py);
1521        schema.iter_fields().for_each(|fld| {
1522            schema_dict
1523                .set_item(fld.name().as_str(), &Wrap(fld.dtype().clone()))
1524                .unwrap()
1525        });
1526        Ok(schema_dict)
1527    }
1528
1529    fn unnest(&self, columns: PySelector, separator: Option<&str>) -> Self {
1530        self.ldf
1531            .read()
1532            .clone()
1533            .unnest(columns.inner, separator.map(PlSmallStr::from_str))
1534            .into()
1535    }
1536
1537    fn count(&self) -> Self {
1538        let ldf = self.ldf.read().clone();
1539        ldf.count().into()
1540    }
1541
1542    #[cfg(feature = "merge_sorted")]
1543    fn merge_sorted(&self, other: Self, key: Vec<String>, maintain_order: bool) -> PyResult<Self> {
1544        let out = self
1545            .ldf
1546            .read()
1547            .clone()
1548            .merge_sorted(other.ldf.into_inner(), key, maintain_order)
1549            .map_err(PyPolarsErr::from)?;
1550        Ok(out.into())
1551    }
1552
1553    fn _node_name(&self) -> &str {
1554        let plan = &self.ldf.read().logical_plan;
1555        plan.into()
1556    }
1557
1558    fn hint_sorted(
1559        &self,
1560        columns: Vec<String>,
1561        descending: Vec<bool>,
1562        nulls_last: Vec<bool>,
1563    ) -> PyResult<Self> {
1564        if columns.len() != descending.len() && descending.len() != 1 {
1565            return Err(PyValueError::new_err(
1566                "`set_sorted` expects the same amount of `columns` as `descending` values.",
1567            ));
1568        }
1569        if columns.len() != nulls_last.len() && nulls_last.len() != 1 {
1570            return Err(PyValueError::new_err(
1571                "`set_sorted` expects the same amount of `columns` as `nulls_last` values.",
1572            ));
1573        }
1574
1575        let mut sorted = columns
1576            .iter()
1577            .map(|c| Sorted {
1578                column: PlSmallStr::from_str(c.as_str()),
1579                descending: Some(false),
1580                nulls_last: Some(false),
1581            })
1582            .collect::<Vec<_>>();
1583
1584        if !columns.is_empty() {
1585            if descending.len() != 1 {
1586                sorted
1587                    .iter_mut()
1588                    .zip(descending)
1589                    .for_each(|(s, d)| s.descending = Some(d));
1590            } else if descending[0] {
1591                sorted.iter_mut().for_each(|s| s.descending = Some(true));
1592            }
1593
1594            if nulls_last.len() != 1 {
1595                sorted
1596                    .iter_mut()
1597                    .zip(nulls_last)
1598                    .for_each(|(s, d)| s.nulls_last = Some(d));
1599            } else if nulls_last[0] {
1600                sorted.iter_mut().for_each(|s| s.nulls_last = Some(true));
1601            }
1602        }
1603
1604        let out = self
1605            .ldf
1606            .read()
1607            .clone()
1608            .hint(HintIR::Sorted(sorted.into()))
1609            .map_err(PyPolarsErr::from)?;
1610        Ok(out.into())
1611    }
1612}
1613
1614#[pyclass(frozen)]
1615struct PyCollectBatches {
1616    inner: Arc<Mutex<CollectBatches>>,
1617    ldf: LazyFrame,
1618}
1619
1620#[pymethods]
1621impl PyCollectBatches {
1622    fn start(&self) {
1623        self.inner.lock().start();
1624    }
1625
1626    fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
1627        slf
1628    }
1629
1630    fn __next__(slf: PyRef<'_, Self>, py: Python) -> PyResult<Option<PyDataFrame>> {
1631        let inner = Arc::clone(&slf.inner);
1632        py.enter_polars(|| PolarsResult::Ok(inner.lock().next().transpose()?.map(PyDataFrame::new)))
1633    }
1634
1635    #[allow(unused_variables)]
1636    #[pyo3(signature = (requested_schema=None))]
1637    fn __arrow_c_stream__<'py>(
1638        &self,
1639        py: Python<'py>,
1640        requested_schema: Option<Py<PyAny>>,
1641    ) -> PyResult<Bound<'py, PyCapsule>> {
1642        let mut ldf = self.ldf.clone();
1643        // Resolving the schema can call back into Python from another thread (e.g. a
1644        // `PythonDataset` scan, as produced by `scan_delta` / `scan_iceberg`). Holding
1645        // the GIL across that deadlocks, so release it for the duration.
1646        let schema = py
1647            .enter_polars(move || ldf.collect_schema())?
1648            .to_arrow(CompatLevel::newest());
1649
1650        let dtype = ArrowDataType::Struct(schema.into_iter_values().collect());
1651
1652        let iter = Box::new(ArrowStreamIterator::new(self.inner.clone(), dtype.clone()));
1653        let field = ArrowField::new(PlSmallStr::EMPTY, dtype, false);
1654        let stream = export_iterator(iter, field);
1655        PyCapsule::new_with_value(py, stream, c"arrow_array_stream")
1656    }
1657}
1658
1659pub struct ArrowStreamIterator {
1660    inner: Arc<Mutex<CollectBatches>>,
1661    dtype: ArrowDataType,
1662}
1663
1664impl ArrowStreamIterator {
1665    fn new(inner: Arc<Mutex<CollectBatches>>, schema: ArrowDataType) -> Self {
1666        Self {
1667            inner,
1668            dtype: schema,
1669        }
1670    }
1671}
1672
1673impl Iterator for ArrowStreamIterator {
1674    type Item = PolarsResult<ArrayRef>;
1675
1676    fn next(&mut self) -> Option<Self::Item> {
1677        let next = self.inner.lock().next();
1678        match next {
1679            None => None,
1680            Some(Err(err)) => Some(Err(err)),
1681            Some(Ok(df)) => {
1682                let height = df.height();
1683                let arrays = df.rechunk_into_arrow(CompatLevel::newest());
1684                Some(Ok(Box::new(arrow::array::StructArray::new(
1685                    self.dtype.clone(),
1686                    height,
1687                    arrays,
1688                    None,
1689                ))))
1690            },
1691        }
1692    }
1693}