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 3e6c43e6 [python] Expose streaming native table reader (#870)
3e6c43e6 is described below
commit 3e6c43e6220fdf0c42a260bdd76c4217a983dae3
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Sep 18 14:14:36 2026 +0800
[python] Expose streaming native table reader (#870)
---
.../python/python/pypaimon_rust/datafusion.pyi | 8 ++
bindings/python/src/context.rs | 1 +
bindings/python/src/read.rs | 100 ++++++++++++++++-----
bindings/python/tests/test_read.py | 14 +++
4 files changed, 100 insertions(+), 23 deletions(-)
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index c8abd01b..e9aadcf6 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -56,7 +56,15 @@ class TableScan:
def with_row_position_shard(self, index: int, count: int) -> "TableScan":
...
def plan(self) -> Plan: ...
+class RecordBatchReader:
+ def __iter__(self) -> "RecordBatchReader": ...
+ def __next__(self) -> pyarrow.RecordBatch: ...
+ def read_next_batch(self) -> Optional[pyarrow.RecordBatch]: ...
+
class TableRead:
+ def read_arrow(self, splits: Sequence[Split]) -> RecordBatchReader:
+ """Lazily read splits as PyArrow RecordBatches."""
+ ...
def read(self, splits: Sequence[Split]) -> List[pyarrow.RecordBatch]: ...
class ReadBuilder:
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 9fea4b9f..1e285fd1 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -542,6 +542,7 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_,
PyModule>) -> PyResult<()>
this.add_class::<crate::read::PyTableScan>()?;
this.add_class::<crate::read::PyPlan>()?;
this.add_class::<crate::read::PyTableRead>()?;
+ this.add_class::<crate::read::PyRecordBatchReader>()?;
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 40eab8b2..0a5ab881 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -16,14 +16,14 @@
// under the License.
use std::collections::HashMap;
-use std::sync::Arc;
+use std::sync::{Arc, Mutex};
use arrow::pyarrow::ToPyArrow;
use futures::TryStreamExt;
use paimon::spec::Predicate;
-use paimon::table::{DataSplit, IncrementalScanMode, RowRange, Table};
+use paimon::table::{ArrowRecordBatchStream, DataSplit, IncrementalScanMode,
RowRange, Table};
use paimon_datafusion::runtime::runtime;
-use pyo3::exceptions::{PyTypeError, PyValueError};
+use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::{PyBytes, PyDict};
@@ -380,32 +380,51 @@ pub struct PyTableRead {
case_sensitive: bool,
}
+impl PyTableRead {
+ fn read_stream(
+ &self,
+ py: Python<'_>,
+ splits: &Bound<'_, PyAny>,
+ ) -> PyResult<ArrowRecordBatchStream> {
+ let splits = extract_splits(splits)?;
+ py.detach(|| {
+ let mut builder = self.table.new_read_builder();
+ apply_read_config(
+ &mut builder,
+ &self.projection,
+ self.limit,
+ &self.filter,
+ self.case_sensitive,
+ )?;
+ // 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)?;
+ read.to_arrow(&splits).map_err(to_py_err)
+ })
+ }
+}
+
#[pymethods]
impl PyTableRead {
+ /// Lazily read the given splits as an iterator of PyArrow RecordBatches.
+ fn read_arrow(
+ &self,
+ py: Python<'_>,
+ splits: &Bound<'_, PyAny>,
+ ) -> PyResult<PyRecordBatchReader> {
+ Ok(PyRecordBatchReader {
+ stream: Mutex::new(self.read_stream(py, splits)?),
+ })
+ }
+
/// 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 stream = self.read_stream(py, 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,
- self.case_sensitive,
- )?;
- // 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)
- })
+ rt.block_on(stream.try_collect::<Vec<_>>())
+ .map_err(to_py_err)
})?;
batches
.iter()
@@ -414,6 +433,41 @@ impl PyTableRead {
}
}
+#[pyclass(name = "RecordBatchReader", module = "pypaimon_rust.datafusion")]
+pub struct PyRecordBatchReader {
+ stream: Mutex<ArrowRecordBatchStream>,
+}
+
+impl PyRecordBatchReader {
+ fn next_batch(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
+ let batch = py.detach(|| {
+ let mut stream = self
+ .stream
+ .lock()
+ .map_err(|_| PyRuntimeError::new_err("native record batch
reader lock poisoned"))?;
+ runtime().block_on(stream.try_next()).map_err(to_py_err)
+ })?;
+ batch
+ .map(|batch| Ok(batch.to_pyarrow(py)?.unbind()))
+ .transpose()
+ }
+}
+
+#[pymethods]
+impl PyRecordBatchReader {
+ fn read_next_batch(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
+ self.next_batch(py)
+ }
+
+ fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
+ slf
+ }
+
+ fn __next__(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
+ self.next_batch(py)
+ }
+}
+
#[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 35f019e8..448cc47a 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -593,10 +593,22 @@ def test_read_returns_expected_rows():
assert t.sort_by("id").to_pydict() == {"id": [1, 2, 3], "name": ["a",
"b", "c"]}
+def test_read_arrow_streams_expected_rows():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ builder = table.new_read_builder().with_projection(["id"])
+ splits = builder.new_scan().plan().splits()
+ reader = builder.new_read().read_arrow(splits)
+ batches = list(reader)
+ assert pa.Table.from_batches(batches).to_pydict() == {"id": [1, 2, 3]}
+ assert reader.read_next_batch() is None
+
+
def test_read_empty_splits():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
assert table.new_read_builder().new_read().read([]) == []
+ assert list(table.new_read_builder().new_read().read_arrow([])) == []
def test_read_empty_splits_still_validates_projection():
@@ -607,6 +619,8 @@ def test_read_empty_splits_still_validates_projection():
read =
table.new_read_builder().with_projection(["does_not_exist"]).new_read()
with pytest.raises(Exception):
read.read([])
+ with pytest.raises(Exception):
+ read.read_arrow([])
def test_read_non_split_raises_typeerror():