This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new f091bc2  [python] Add TableRead.read(splits) returning Arrow batches 
(#428)
f091bc2 is described below

commit f091bc225f28a87b9a9f38e0ce8235aa13e15a02
Author: Junrui Lee <[email protected]>
AuthorDate: Wed Jul 1 21:19:18 2026 +0800

    [python] Add TableRead.read(splits) returning Arrow batches (#428)
---
 bindings/python/Cargo.toml                         |  1 +
 .../python/python/pypaimon_rust/datafusion.pyi     |  4 +
 bindings/python/src/context.rs                     |  1 +
 bindings/python/src/read.rs                        | 99 +++++++++++++++++++---
 bindings/python/tests/test_read.py                 | 79 +++++++++++++++++
 5 files changed, 173 insertions(+), 11 deletions(-)

diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml
index 79d7483..c2e853b 100644
--- a/bindings/python/Cargo.toml
+++ b/bindings/python/Cargo.toml
@@ -30,6 +30,7 @@ doc = false
 arrow = { workspace = true, features = ["pyarrow"] }
 datafusion = { workspace = true }
 datafusion-ffi = { workspace = true }
+futures = "0.3"
 paimon = { path = "../../crates/paimon", features = ["storage-all"] }
 paimon-datafusion = { path = "../../crates/integrations/datafusion", features 
= ["fulltext"] }
 pyo3 = { version = "0.28", features = ["abi3-py310"] }
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index d7c7131..184c79e 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -47,11 +47,15 @@ class Plan:
 class TableScan:
     def plan(self) -> Plan: ...
 
+class TableRead:
+    def read(self, splits: Sequence[Split]) -> List[pyarrow.RecordBatch]: ...
+
 class ReadBuilder:
     def with_projection(self, columns: List[str]) -> "ReadBuilder": ...
     def with_limit(self, limit: int) -> "ReadBuilder": ...
     def with_filter(self, predicate: dict) -> "ReadBuilder": ...
     def new_scan(self) -> TableScan: ...
+    def new_read(self) -> "TableRead": ...
 
 class Table:
     def identifier(self) -> str: ...
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index d61b94a..43b1fdb 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -272,6 +272,7 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, 
PyModule>) -> PyResult<()>
     this.add_class::<crate::read::PyReadBuilder>()?;
     this.add_class::<crate::read::PyTableScan>()?;
     this.add_class::<crate::read::PyPlan>()?;
+    this.add_class::<crate::read::PyTableRead>()?;
     this.add_class::<crate::read::PySplit>()?;
     this.add_class::<crate::schema::PyTableSchema>()?;
     this.add_class::<crate::schema::PyDataField>()?;
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index 3a059b1..b708457 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -17,16 +17,57 @@
 
 use std::sync::Arc;
 
+use arrow::pyarrow::ToPyArrow;
+use futures::TryStreamExt;
 use paimon::spec::Predicate;
 use paimon::table::{DataSplit, Table};
 use paimon_datafusion::runtime::runtime;
-use pyo3::exceptions::PyValueError;
+use pyo3::exceptions::{PyTypeError, PyValueError};
 use pyo3::prelude::*;
 use pyo3::types::{PyBytes, PyDict};
 
 use crate::error::to_py_err;
 use crate::predicate::dict_to_predicate;
 
+/// Apply projection/limit/filter from a config snapshot onto a core 
ReadBuilder.
+/// Shared by PyTableScan::plan and PyTableRead::read so scan and read stay 
consistent.
+fn apply_read_config(
+    builder: &mut paimon::table::ReadBuilder<'_>,
+    projection: &Option<Vec<String>>,
+    limit: Option<usize>,
+    filter: &Option<Predicate>,
+) {
+    if let Some(projection) = projection {
+        let cols: Vec<&str> = projection.iter().map(String::as_str).collect();
+        builder.with_projection(&cols);
+    }
+    if let Some(limit) = limit {
+        builder.with_limit(limit);
+    }
+    if let Some(filter) = filter {
+        builder.with_filter(filter.clone());
+    }
+}
+
+/// Extract a sequence of Python `Split` objects into core `DataSplit`s. 
Accepts
+/// any iterable (list/tuple/generator). Runs under the GIL since it touches
+/// Python objects. A non-iterable argument or a non-`Split` element raises
+/// `TypeError`.
+fn extract_splits(splits: &Bound<'_, PyAny>) -> PyResult<Vec<DataSplit>> {
+    let iter = splits
+        .try_iter()
+        .map_err(|_| PyTypeError::new_err("read() expects a sequence of Split 
objects"))?;
+    let mut out = Vec::new();
+    for item in iter {
+        let item = item?;
+        let split: PyRef<PySplit> = item
+            .extract()
+            .map_err(|_| PyTypeError::new_err("read() expects a sequence of 
Split objects"))?;
+        out.push(split.inner.clone());
+    }
+    Ok(out)
+}
+
 #[pyclass(name = "ReadBuilder", module = "pypaimon_rust.datafusion")]
 pub struct PyReadBuilder {
     table: Arc<Table>,
@@ -79,6 +120,15 @@ impl PyReadBuilder {
             filter: self.filter.clone(),
         }
     }
+
+    fn new_read(&self) -> PyTableRead {
+        PyTableRead {
+            table: Arc::clone(&self.table),
+            projection: self.projection.clone(),
+            limit: self.limit,
+            filter: self.filter.clone(),
+        }
+    }
 }
 
 #[pyclass(name = "TableScan", module = "pypaimon_rust.datafusion")]
@@ -96,16 +146,7 @@ impl PyTableScan {
         let splits = py.detach(|| {
             rt.block_on(async {
                 let mut builder = self.table.new_read_builder();
-                if let Some(projection) = &self.projection {
-                    let cols: Vec<&str> = 
projection.iter().map(String::as_str).collect();
-                    builder.with_projection(&cols);
-                }
-                if let Some(limit) = self.limit {
-                    builder.with_limit(limit);
-                }
-                if let Some(filter) = &self.filter {
-                    builder.with_filter(filter.clone());
-                }
+                apply_read_config(&mut builder, &self.projection, self.limit, 
&self.filter);
                 let plan = builder.new_scan().plan().await.map_err(to_py_err)?;
                 Ok::<_, PyErr>(plan.splits().to_vec())
             })
@@ -114,6 +155,42 @@ impl PyTableScan {
     }
 }
 
+#[pyclass(name = "TableRead", module = "pypaimon_rust.datafusion")]
+pub struct PyTableRead {
+    table: Arc<Table>,
+    projection: Option<Vec<String>>,
+    limit: Option<usize>,
+    filter: Option<Predicate>,
+}
+
+#[pymethods]
+impl PyTableRead {
+    /// Read the given splits into a list of PyArrow RecordBatches.
+    fn read(&self, py: Python<'_>, splits: &Bound<'_, PyAny>) -> 
PyResult<Vec<Py<PyAny>>> {
+        let splits = extract_splits(splits)?;
+        let rt = runtime();
+        let batches = py.detach(|| {
+            rt.block_on(async {
+                let mut builder = self.table.new_read_builder();
+                apply_read_config(&mut builder, &self.projection, self.limit, 
&self.filter);
+                // Validate config (e.g. projection) before the empty-splits 
fast
+                // path so an invalid projection fails consistently regardless 
of
+                // how many splits are passed.
+                let read = builder.new_read().map_err(to_py_err)?;
+                if splits.is_empty() {
+                    return Ok(Vec::new());
+                }
+                let stream = read.to_arrow(&splits).map_err(to_py_err)?;
+                stream.try_collect::<Vec<_>>().await.map_err(to_py_err)
+            })
+        })?;
+        batches
+            .iter()
+            .map(|batch| Ok(batch.to_pyarrow(py)?.unbind()))
+            .collect()
+    }
+}
+
 #[pyclass(name = "Plan", module = "pypaimon_rust.datafusion")]
 pub struct PyPlan {
     splits: Vec<DataSplit>,
diff --git a/bindings/python/tests/test_read.py 
b/bindings/python/tests/test_read.py
index 57f814e..6d0f8f0 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -18,6 +18,7 @@
 import pickle
 import tempfile
 
+import pyarrow as pa
 import pytest
 
 from pypaimon_rust.datafusion import PaimonCatalog, SQLContext
@@ -280,3 +281,81 @@ def test_filter_overwrite():
             .with_filter({"method": "equal", "field": "dt", "literals": 
["p2"]})
             .new_scan().plan().splits())
         assert overwritten == only_p2
+
+
+def test_read_returns_expected_rows():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        b = table.new_read_builder()
+        splits = b.new_scan().plan().splits()
+        batches = b.new_read().read(splits)
+        t = pa.Table.from_batches(batches)
+        assert t.num_rows == 3
+        assert t.sort_by("id").to_pydict() == {"id": [1, 2, 3], "name": ["a", 
"b", "c"]}
+
+
+def test_read_empty_splits():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        assert table.new_read_builder().new_read().read([]) == []
+
+
+def test_read_empty_splits_still_validates_projection():
+    # An invalid projection must fail regardless of split count; the 
empty-splits
+    # fast path should not bypass config validation.
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        read = 
table.new_read_builder().with_projection(["does_not_exist"]).new_read()
+        with pytest.raises(Exception):
+            read.read([])
+
+
+def test_read_non_split_raises_typeerror():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        with pytest.raises(TypeError):
+            table.new_read_builder().new_read().read([object()])
+
+
+def test_read_pickled_split():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        b = table.new_read_builder()
+        splits = b.new_scan().plan().splits()
+        pickled = [pickle.loads(pickle.dumps(s)) for s in splits]
+        batches = b.new_read().read(pickled)
+        t = pa.Table.from_batches(batches)
+        assert t.num_rows == 3
+        assert sorted(t.column("id").to_pylist()) == [1, 2, 3]
+
+
+def test_read_projection_applied():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        b = table.new_read_builder().with_projection(["id"])
+        splits = b.new_scan().plan().splits()
+        batches = b.new_read().read(splits)
+        t = pa.Table.from_batches(batches)
+        assert t.schema.names == ["id"]
+        assert sorted(t.column("id").to_pylist()) == [1, 2, 3]
+
+
+def test_read_is_config_snapshot():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        b = table.new_read_builder().with_projection(["id"])
+        splits = b.new_scan().plan().splits()
+        read = b.new_read()
+        b.with_projection(["name"])  # mutate builder AFTER new_read()
+        t = pa.Table.from_batches(read.read(splits))
+        assert t.schema.names == ["id"]
+
+
+def test_read_with_filter_smoke():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        b = table.new_read_builder().with_filter(
+            {"method": "greaterOrEqual", "field": "id", "literals": [1]})
+        splits = b.new_scan().plan().splits()
+        batches = b.new_read().read(splits)
+        assert isinstance(batches, list)

Reply via email to