Skip to main content

polars_python/lazyframe/visitor/
nodes.rs

1#[cfg(feature = "iejoin")]
2use polars::prelude::JoinTypeOptionsIR;
3use polars::prelude::deletion::DeletionFilesList;
4use polars::prelude::python_dsl::PythonScanSource;
5use polars::prelude::{ColumnMapping, PredicateFileSkip};
6use polars_core::prelude::IdxSize;
7use polars_io::cloud::CloudOptions;
8#[cfg(feature = "asof_join")]
9use polars_ops::prelude::AsofStrategy;
10use polars_ops::prelude::JoinType;
11use polars_plan::plans::{HintIR, IR};
12use polars_plan::prelude::{FileScanIR, FunctionIR, PythonPredicate, UnifiedScanArgs};
13use pyo3::IntoPyObjectExt;
14use pyo3::exceptions::{PyNotImplementedError, PyValueError};
15use pyo3::prelude::*;
16use pyo3::types::{PyDict, PyList, PyString};
17
18use super::expr_nodes::PyGroupbyOptions;
19use crate::PyDataFrame;
20use crate::lazyframe::visit::PyExprIR;
21
22fn scan_type_to_pyobject(
23    py: Python<'_>,
24    scan_type: &FileScanIR,
25    cloud_options: &Option<CloudOptions>,
26) -> PyResult<Py<PyAny>> {
27    match scan_type {
28        #[cfg(feature = "csv")]
29        FileScanIR::Csv { options } => {
30            let options = serde_json::to_string(options)
31                .map_err(|err| PyValueError::new_err(format!("{err:?}")))?;
32            let cloud_options = serde_json::to_string(cloud_options)
33                .map_err(|err| PyValueError::new_err(format!("{err:?}")))?;
34            Ok(("csv", options, cloud_options).into_py_any(py)?)
35        },
36        #[cfg(feature = "parquet")]
37        FileScanIR::Parquet { options, .. } => {
38            let options = serde_json::to_string(options)
39                .map_err(|err| PyValueError::new_err(format!("{err:?}")))?;
40            let cloud_options = serde_json::to_string(cloud_options)
41                .map_err(|err| PyValueError::new_err(format!("{err:?}")))?;
42            Ok(("parquet", options, cloud_options).into_py_any(py)?)
43        },
44        #[cfg(feature = "ipc")]
45        FileScanIR::Ipc { .. } => Err(PyNotImplementedError::new_err("ipc scan")),
46        #[cfg(feature = "json")]
47        FileScanIR::NDJson { options, .. } => {
48            let options = serde_json::to_string(options)
49                .map_err(|err| PyValueError::new_err(format!("{err:?}")))?;
50            Ok(("ndjson", options).into_py_any(py)?)
51        },
52        #[cfg(feature = "scan_lines")]
53        FileScanIR::Lines { name } => Ok(("lines", name.as_str()).into_py_any(py)?),
54        FileScanIR::ExpandedPaths { name } => {
55            Ok(("expanded-paths", name.as_str()).into_py_any(py)?)
56        },
57        FileScanIR::PythonDataset { .. } => {
58            Err(PyNotImplementedError::new_err("python dataset scan"))
59        },
60        FileScanIR::Anonymous { .. } => Err(PyNotImplementedError::new_err("anonymous scan")),
61    }
62}
63
64#[pyclass(frozen)]
65/// Scan a table with an optional predicate from a python function
66pub struct PythonScan {
67    #[pyo3(get)]
68    options: Py<PyAny>,
69}
70
71#[pyclass(frozen)]
72/// Slice the table
73pub struct Slice {
74    #[pyo3(get)]
75    input: usize,
76    #[pyo3(get)]
77    offset: i64,
78    #[pyo3(get)]
79    len: IdxSize,
80}
81
82#[pyclass(frozen)]
83/// Filter the table with a boolean expression
84pub struct Filter {
85    #[pyo3(get)]
86    input: usize,
87    #[pyo3(get)]
88    predicate: PyExprIR,
89}
90
91#[pyclass(frozen, skip_from_py_object)]
92#[derive(Clone)]
93pub struct PyFileOptions {
94    inner: UnifiedScanArgs,
95}
96
97#[pymethods]
98impl PyFileOptions {
99    #[getter]
100    fn n_rows(&self) -> Option<(i64, IdxSize)> {
101        self.inner
102            .pre_slice
103            .clone()
104            .map(|slice| slice.to_signed_offset_len())
105    }
106    #[getter]
107    fn with_columns(&self) -> Option<Vec<&str>> {
108        self.inner
109            .projection
110            .as_ref()?
111            .iter()
112            .map(|x| x.as_str())
113            .collect::<Vec<_>>()
114            .into()
115    }
116    #[getter]
117    fn cache(&self, _py: Python<'_>) -> bool {
118        self.inner.cache
119    }
120    #[getter]
121    fn row_index(&self) -> Option<(&str, IdxSize)> {
122        self.inner
123            .row_index
124            .as_ref()
125            .map(|n| (n.name.as_str(), n.offset))
126    }
127    #[getter]
128    fn rechunk(&self, _py: Python<'_>) -> bool {
129        self.inner.rechunk
130    }
131    #[getter]
132    fn hive_options(&self, _py: Python<'_>) -> PyResult<Py<PyAny>> {
133        Err(PyNotImplementedError::new_err("hive options"))
134    }
135    #[getter]
136    fn include_file_paths(&self, _py: Python<'_>) -> Option<&str> {
137        self.inner.include_file_paths.as_deref()
138    }
139
140    /// One of:
141    /// * None
142    /// * ("iceberg-position-delete", dict[int, list[str]])
143    #[getter]
144    fn deletion_files(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
145        Ok(match &self.inner.deletion_files {
146            None => py.None().into_any(),
147            Some(DeletionFilesList::IcebergPositionDelete(paths)) => {
148                let out = PyDict::new(py);
149                for (k, v) in paths.iter() {
150                    out.set_item(*k, v.as_ref())?;
151                }
152                ("iceberg-position-delete", out)
153                    .into_pyobject(py)?
154                    .into_any()
155                    .unbind()
156            },
157            Some(DeletionFilesList::Delta(provider)) => {
158                ("delta-deletion-vector", provider.callback().0.clone_ref(py))
159                    .into_pyobject(py)?
160                    .into_any()
161                    .unbind()
162            },
163        })
164    }
165
166    /// One of:
167    /// * None
168    /// * ("iceberg-column-mapping", <unimplemented>)
169    #[getter]
170    fn column_mapping(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
171        Ok(match &self.inner.column_mapping {
172            None => py.None().into_any(),
173
174            Some(ColumnMapping::Iceberg { .. }) => unimplemented!(),
175        })
176    }
177}
178
179#[pyclass(frozen)]
180/// Scan a table from file
181pub struct Scan {
182    #[pyo3(get)]
183    paths: Py<PyAny>,
184    #[pyo3(get)]
185    file_info: Py<PyAny>,
186    #[pyo3(get)]
187    hive_parts: Option<PyDataFrame>,
188    #[pyo3(get)]
189    predicate: Option<PyExprIR>,
190    #[pyo3(get)]
191    file_options: PyFileOptions,
192    #[pyo3(get)]
193    scan_type: Py<PyAny>,
194}
195
196#[pyclass(frozen)]
197/// Scan a table from an existing dataframe
198pub struct DataFrameScan {
199    #[pyo3(get)]
200    df: PyDataFrame,
201    #[pyo3(get)]
202    projection: Py<PyAny>,
203    #[pyo3(get)]
204    selection: Option<PyExprIR>,
205}
206
207#[pyclass(frozen)]
208/// Project out columns from a table
209pub struct SimpleProjection {
210    #[pyo3(get)]
211    input: usize,
212}
213
214#[pyclass(frozen)]
215/// Column selection
216pub struct Select {
217    #[pyo3(get)]
218    input: usize,
219    #[pyo3(get)]
220    expr: Vec<PyExprIR>,
221    #[pyo3(get)]
222    should_broadcast: bool,
223}
224
225#[pyclass(frozen)]
226/// Sort the table
227pub struct Sort {
228    #[pyo3(get)]
229    input: usize,
230    #[pyo3(get)]
231    by_column: Vec<PyExprIR>,
232    #[pyo3(get)]
233    sort_options: (bool, Vec<bool>, Vec<bool>),
234    #[pyo3(get)]
235    slice: Option<(i64, usize, Option<u128>)>,
236}
237
238#[pyclass(frozen)]
239/// Cache the input at this point in the LP
240pub struct Cache {
241    #[pyo3(get)]
242    input: usize,
243    #[pyo3(get)]
244    id_: u128,
245}
246
247#[pyclass(frozen)]
248/// Groupby aggregation
249pub struct GroupBy {
250    #[pyo3(get)]
251    input: usize,
252    #[pyo3(get)]
253    keys: Vec<PyExprIR>,
254    #[pyo3(get)]
255    aggs: Vec<PyExprIR>,
256    #[pyo3(get)]
257    apply: (),
258    #[pyo3(get)]
259    maintain_order: bool,
260    #[pyo3(get)]
261    options: Py<PyAny>,
262}
263
264#[pyclass(frozen)]
265/// Join operation
266pub struct Join {
267    #[pyo3(get)]
268    input_left: usize,
269    #[pyo3(get)]
270    input_right: usize,
271    #[pyo3(get)]
272    left_on: Vec<PyExprIR>,
273    #[pyo3(get)]
274    right_on: Vec<PyExprIR>,
275    #[pyo3(get)]
276    options: Py<PyAny>,
277}
278
279#[pyclass(frozen)]
280/// Join operation
281pub struct Gather {
282    #[pyo3(get)]
283    input: usize,
284    #[pyo3(get)]
285    idxs: usize,
286    #[pyo3(get)]
287    null_on_oob: bool,
288}
289
290#[pyclass(frozen)]
291/// Merge sorted operation
292pub struct MergeSorted {
293    #[pyo3(get)]
294    input_left: usize,
295    #[pyo3(get)]
296    input_right: usize,
297    #[pyo3(get)]
298    key: Vec<String>,
299    #[pyo3(get)]
300    maintain_order: bool,
301}
302
303#[pyclass(frozen)]
304/// Adding columns to the table without a Join
305pub struct HStack {
306    #[pyo3(get)]
307    input: usize,
308    #[pyo3(get)]
309    exprs: Vec<PyExprIR>,
310    #[pyo3(get)]
311    should_broadcast: bool,
312}
313
314#[pyclass(frozen)]
315/// Like Select, but all operations produce a single row.
316pub struct Reduce {
317    #[pyo3(get)]
318    input: usize,
319    #[pyo3(get)]
320    exprs: Vec<PyExprIR>,
321}
322
323#[pyclass(frozen)]
324/// Remove duplicates from the table
325pub struct Distinct {
326    #[pyo3(get)]
327    input: usize,
328    #[pyo3(get)]
329    options: Py<PyAny>,
330}
331#[pyclass(frozen)]
332/// A (User Defined) Function
333pub struct MapFunction {
334    #[pyo3(get)]
335    input: usize,
336    #[pyo3(get)]
337    function: Py<PyAny>,
338}
339#[pyclass(frozen)]
340pub struct Union {
341    #[pyo3(get)]
342    inputs: Vec<usize>,
343    #[pyo3(get)]
344    slice: Option<(i64, usize)>,
345    #[pyo3(get)]
346    rows: (Option<usize>, usize),
347    #[pyo3(get)]
348    maintain_order: bool,
349}
350#[pyclass(frozen)]
351/// Horizontal concatenation of multiple plans
352pub struct HConcat {
353    #[pyo3(get)]
354    inputs: Vec<usize>,
355    #[pyo3(get)]
356    options: Py<PyAny>,
357}
358#[pyclass(frozen)]
359/// This allows expressions to access other tables
360pub struct ExtContext {
361    #[pyo3(get)]
362    input: usize,
363    #[pyo3(get)]
364    contexts: Vec<usize>,
365}
366
367#[pyclass(frozen)]
368pub struct Sink {
369    #[pyo3(get)]
370    input: usize,
371    #[pyo3(get)]
372    payload: Py<PyAny>,
373}
374
375pub(crate) fn into_py(py: Python<'_>, plan: &IR) -> PyResult<Py<PyAny>> {
376    match plan {
377        IR::PythonScan { options } => {
378            let python_src = match options.python_source {
379                PythonScanSource::Pyarrow => "pyarrow",
380                PythonScanSource::Cuda => "cuda",
381                PythonScanSource::IOPlugin => "io_plugin",
382            };
383
384            PythonScan {
385                options: (
386                    options
387                        .scan_fn
388                        .as_ref()
389                        .map_or_else(|| py.None(), |s| s.0.clone_ref(py)),
390                    options.with_columns.as_ref().map_or_else(
391                        || Ok(py.None()),
392                        |cols| {
393                            cols.iter()
394                                .map(|x| x.as_str())
395                                .collect::<Vec<_>>()
396                                .into_py_any(py)
397                        },
398                    )?,
399                    python_src,
400                    match &options.predicate {
401                        PythonPredicate::None => py.None(),
402                        PythonPredicate::PyArrow(p) => (
403                            "pyarrow",
404                            format!("{:?}", p),
405                            "has_residual",
406                            p.has_residual,
407                        )
408                            .into_py_any(py)?,
409                        PythonPredicate::Polars(e) => ("polars", e.node().0).into_py_any(py)?,
410                    },
411                    options
412                        .n_rows
413                        .map_or_else(|| Ok(py.None()), |s| s.into_py_any(py))?,
414                )
415                    .into_py_any(py)?,
416            }
417            .into_py_any(py)
418        },
419        IR::Slice { input, offset, len } => Slice {
420            input: input.0,
421            offset: *offset,
422            len: *len,
423        }
424        .into_py_any(py),
425        IR::Filter { input, predicate } => Filter {
426            input: input.0,
427            predicate: predicate.into(),
428        }
429        .into_py_any(py),
430        IR::Scan {
431            sources,
432            file_info: _,
433            hive_parts,
434            predicate,
435            predicate_file_skip_applied,
436            output_schema: _,
437            scan_type,
438            unified_scan_args,
439        } => {
440            Scan {
441                paths: {
442                    let paths = sources
443                        .into_paths()
444                        .ok_or_else(|| PyNotImplementedError::new_err("scan with BytesIO"))?;
445
446                    let out = PyList::new(py, [] as [(); 0])?;
447
448                    // Manual conversion to preserve `uri://...` - converting Rust `Path` to `PosixPath`
449                    // will corrupt to `uri:/...`
450                    for path in paths.iter() {
451                        out.append(path.as_str())?;
452                    }
453
454                    out.into_py_any(py)?
455                },
456                // TODO: file info
457                file_info: py.None(),
458                hive_parts: hive_parts
459                    .as_ref()
460                    .map(|h| PyDataFrame::new(h.df().clone())),
461                predicate: predicate
462                    .as_ref()
463                    .filter(|_| {
464                        !matches!(
465                            predicate_file_skip_applied,
466                            Some(PredicateFileSkip {
467                                no_residual_predicate: true,
468                                original_len: _,
469                            })
470                        )
471                    })
472                    .map(|e| e.into()),
473                file_options: PyFileOptions {
474                    inner: (**unified_scan_args).clone(),
475                },
476                scan_type: scan_type_to_pyobject(py, scan_type, &unified_scan_args.cloud_options)?,
477            }
478        }
479        .into_py_any(py),
480        IR::DataFrameScan {
481            df,
482            schema: _,
483            output_schema,
484        } => DataFrameScan {
485            df: PyDataFrame::new((**df).clone()),
486            projection: output_schema.as_ref().map_or_else(
487                || Ok(py.None()),
488                |s| {
489                    s.iter_names()
490                        .map(|s| s.as_str())
491                        .collect::<Vec<_>>()
492                        .into_py_any(py)
493                },
494            )?,
495            selection: None,
496        }
497        .into_py_any(py),
498        IR::SimpleProjection { input, columns: _ } => {
499            SimpleProjection { input: input.0 }.into_py_any(py)
500        },
501        IR::Select {
502            input,
503            expr,
504            schema: _,
505            options,
506        } => Select {
507            expr: expr.iter().map(|e| e.into()).collect(),
508            input: input.0,
509            should_broadcast: options.should_broadcast,
510        }
511        .into_py_any(py),
512        IR::Sort {
513            input,
514            by_column,
515            slice,
516            sort_options,
517        } => Sort {
518            input: input.0,
519            by_column: by_column.iter().map(|e| e.into()).collect(),
520            sort_options: (
521                sort_options.maintain_order,
522                sort_options.nulls_last.clone(),
523                sort_options.descending.clone(),
524            ),
525            slice: slice
526                .as_ref()
527                .map(|t| (t.0, t.1, t.2.as_ref().map(|p| p.id().as_u128()))),
528        }
529        .into_py_any(py),
530        IR::Cache { input, id } => Cache {
531            input: input.0,
532            id_: id.as_u128(),
533        }
534        .into_py_any(py),
535        IR::GroupBy {
536            input,
537            keys,
538            aggs,
539            schema: _,
540            apply,
541            maintain_order,
542            options,
543        } => GroupBy {
544            input: input.0,
545            keys: keys.iter().map(|e| e.into()).collect(),
546            aggs: aggs.iter().map(|e| e.into()).collect(),
547            apply: apply.as_ref().map_or(Ok(()), |_| {
548                Err(PyNotImplementedError::new_err(format!(
549                    "apply inside GroupBy {plan:?}"
550                )))
551            })?,
552            maintain_order: *maintain_order,
553            options: PyGroupbyOptions::new(options.as_ref().clone()).into_py_any(py)?,
554        }
555        .into_py_any(py),
556        IR::Join {
557            input_left,
558            input_right,
559            schema: _,
560            left_on,
561            right_on,
562            options,
563        } => {
564            Join {
565                input_left: input_left.0,
566                input_right: input_right.0,
567                left_on: left_on.iter().map(|e| e.into()).collect(),
568                right_on: right_on.iter().map(|e| e.into()).collect(),
569                options: {
570                    let how = &options.args.how;
571                    let name = Into::<&str>::into(how).into_pyobject(py)?;
572                    (
573                        match how {
574                            #[cfg(feature = "asof_join")]
575                            JoinType::AsOf(asof_options) => {
576                                let strategy = match asof_options.strategy {
577                                    AsofStrategy::Backward => "backward",
578                                    AsofStrategy::Forward => "forward",
579                                    AsofStrategy::Nearest => "nearest",
580                                };
581                                let left_by = asof_options.left_by.as_ref().map(|cols| {
582                                    cols.iter().map(|c| c.as_str()).collect::<Vec<_>>()
583                                });
584                                let right_by = asof_options.right_by.as_ref().map(|cols| {
585                                    cols.iter().map(|c| c.as_str()).collect::<Vec<_>>()
586                                });
587                                (
588                                    name,
589                                    strategy,
590                                    asof_options.tolerance.as_ref().map_or_else(
591                                        || Ok(py.None()),
592                                        |t| crate::Wrap(t.as_any_value()).into_py_any(py),
593                                    )?,
594                                    asof_options.tolerance_str.as_ref().map(|s| s.as_str()),
595                                    left_by,
596                                    right_by,
597                                    asof_options.allow_eq,
598                                    asof_options.check_sortedness,
599                                )
600                                    .into_py_any(py)?
601                            },
602                            #[cfg(feature = "iejoin")]
603                            JoinType::IEJoin => {
604                                let Some(JoinTypeOptionsIR::IEJoin(ie_options)) = &options.options
605                                else {
606                                    unreachable!()
607                                };
608                                (
609                                    name,
610                                    crate::Wrap(ie_options.operator1).into_py_any(py)?,
611                                    ie_options.operator2.as_ref().map_or_else(
612                                        || Ok(py.None()),
613                                        |op| crate::Wrap(*op).into_py_any(py),
614                                    )?,
615                                )
616                                    .into_py_any(py)?
617                            },
618                            // This is a cross join fused with a predicate. Shown in the IR::explain as
619                            // NESTED LOOP JOIN
620                            JoinType::Cross if options.options.is_some() => {
621                                return Err(PyNotImplementedError::new_err("nested loop join"));
622                            },
623                            _ => name.into_any().unbind(),
624                        },
625                        options.args.nulls_equal,
626                        options.args.slice,
627                        options.args.suffix().as_str(),
628                        options.args.coalesce.coalesce(how),
629                        Into::<&str>::into(options.args.maintain_order),
630                    )
631                        .into_py_any(py)?
632                },
633            }
634            .into_py_any(py)
635        },
636        IR::Gather {
637            input,
638            idxs,
639            null_on_oob,
640        } => Gather {
641            input: input.0,
642            idxs: idxs.0,
643            null_on_oob: *null_on_oob,
644        }
645        .into_py_any(py),
646        IR::HStack {
647            input,
648            exprs,
649            schema: _,
650            options,
651        } => HStack {
652            input: input.0,
653            exprs: exprs.iter().map(|e| e.into()).collect(),
654            should_broadcast: options.should_broadcast,
655        }
656        .into_py_any(py),
657        IR::Distinct { input, options } => Distinct {
658            input: input.0,
659            options: (
660                Into::<&str>::into(options.keep_strategy),
661                options.subset.as_ref().map_or_else(
662                    || Ok(py.None()),
663                    |f| {
664                        f.iter()
665                            .map(|s| s.as_ref())
666                            .collect::<Vec<&str>>()
667                            .into_py_any(py)
668                    },
669                )?,
670                options.maintain_order,
671                options.slice,
672            )
673                .into_py_any(py)?,
674        }
675        .into_py_any(py),
676        IR::MapFunction { input, function } => MapFunction {
677            input: input.0,
678            function: match function {
679                FunctionIR::OpaquePython(_) => {
680                    return Err(PyNotImplementedError::new_err("opaque python mapfunction"));
681                },
682                FunctionIR::Opaque {
683                    function: _,
684                    schema: _,
685                    predicate_pd: _,
686                    projection_pd: _,
687                    streamable: _,
688                    fmt_str: _,
689                } => return Err(PyNotImplementedError::new_err("opaque rust mapfunction")),
690                FunctionIR::Unnest { columns, separator } => (
691                    "unnest",
692                    columns.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
693                    separator.as_ref().map(|s| s.to_string()),
694                )
695                    .into_py_any(py)?,
696                FunctionIR::Rechunk => ("rechunk",).into_py_any(py)?,
697                FunctionIR::Explode {
698                    columns,
699                    options,
700                    schema: _,
701                } => (
702                    "explode",
703                    columns.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
704                    options.empty_as_null,
705                    options.keep_nulls,
706                )
707                    .into_py_any(py)?,
708                #[cfg(feature = "pivot")]
709                FunctionIR::Unpivot { args, schema: _ } => (
710                    "unpivot",
711                    args.index.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
712                    args.on.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
713                    args.variable_name.as_str().into_py_any(py)?,
714                    args.value_name.as_str().into_py_any(py)?,
715                )
716                    .into_py_any(py)?,
717                FunctionIR::RowIndex {
718                    name,
719                    schema: _,
720                    offset,
721                } => ("row_index", name.to_string(), offset.unwrap_or(0)).into_py_any(py)?,
722                FunctionIR::FastCount {
723                    sources,
724                    scan_type,
725                    alias,
726                    ..
727                } => {
728                    let sources = sources
729                        .into_paths()
730                        .ok_or_else(|| {
731                            PyNotImplementedError::new_err("FastCount with BytesIO sources")
732                        })?
733                        .iter()
734                        .map(|p| p.as_str())
735                        .collect::<Vec<_>>()
736                        .into_py_any(py)?;
737
738                    // FastCount does not support cloud options.
739                    let cloud_options = None;
740
741                    let scan_type = scan_type_to_pyobject(py, scan_type, &cloud_options)?;
742
743                    let alias = alias
744                        .as_ref()
745                        .map(|a| a.as_str())
746                        .map_or_else(|| Ok(py.None()), |s| s.into_py_any(py))?;
747
748                    ("fast_count", sources, scan_type, alias).into_py_any(py)?
749                },
750                FunctionIR::Hint(hint) => match hint {
751                    HintIR::Sorted(sorted_vec) => {
752                        let sorted_info: Vec<_> = sorted_vec
753                            .iter()
754                            .map(|s| (s.column.as_str(), s.descending, s.nulls_last))
755                            .collect();
756                        ("hint_sorted", sorted_info).into_py_any(py)?
757                    },
758                },
759            },
760        }
761        .into_py_any(py),
762        IR::Union { inputs, options } => Union {
763            inputs: inputs.iter().map(|n| n.0).collect(),
764            // Remaining options are implementation detail, not logical
765            slice: options.slice,
766            rows: options.rows,
767            maintain_order: options.maintain_order,
768        }
769        .into_py_any(py),
770        IR::HConcat {
771            inputs,
772            schema: _,
773            options,
774        } => HConcat {
775            inputs: inputs.iter().map(|n| n.0).collect(),
776            options: (
777                options.parallel,
778                options.strict,
779                options.broadcast_unit_length,
780            )
781                .into_py_any(py)?,
782        }
783        .into_py_any(py),
784        IR::ExtContext {
785            input,
786            contexts,
787            schema: _,
788        } => ExtContext {
789            input: input.0,
790            contexts: contexts.iter().map(|n| n.0).collect(),
791        }
792        .into_py_any(py),
793        IR::Sink { input, payload } => Sink {
794            input: input.0,
795            payload: PyString::new(
796                py,
797                &serde_json::to_string(payload)
798                    .map_err(|err| PyValueError::new_err(format!("{err:?}")))?,
799            )
800            .into(),
801        }
802        .into_py_any(py),
803        IR::SinkMultiple { .. } => Err(PyNotImplementedError::new_err(
804            "Not expecting to see a SinkMultiple node",
805        )),
806        #[cfg(feature = "merge_sorted")]
807        IR::MergeSorted {
808            input_left,
809            input_right,
810            key,
811            maintain_order,
812        } => MergeSorted {
813            input_left: input_left.0,
814            input_right: input_right.0,
815            key: key.iter().map(|k| k.to_string()).collect(),
816            maintain_order: *maintain_order,
817        }
818        .into_py_any(py),
819        IR::UnoptimizedDispatch { .. } => Err(PyNotImplementedError::new_err(
820            "Not expecting to see a UnoptimizedDispatch node",
821        )),
822        IR::Invalid => Err(PyNotImplementedError::new_err("Invalid")),
823    }
824}