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 5946150  [python] Expose DataFrame-style scan/split planning API (#415)
5946150 is described below

commit 59461507f70b2449c6b5c04bf673096803f0e8cc
Author: Junrui Lee <[email protected]>
AuthorDate: Sat Jun 27 22:36:47 2026 +0800

    [python] Expose DataFrame-style scan/split planning API (#415)
---
 bindings/python/Cargo.toml                         |   1 +
 .../python/python/pypaimon_rust/datafusion.pyi     |  17 +++
 bindings/python/src/context.rs                     |   4 +
 bindings/python/src/lib.rs                         |   1 +
 bindings/python/src/read.rs                        | 160 +++++++++++++++++++++
 bindings/python/src/table.rs                       |   6 +
 bindings/python/tests/test_read.py                 |  87 +++++++++++
 crates/paimon/src/table/source.rs                  |  21 ++-
 8 files changed, 296 insertions(+), 1 deletion(-)

diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml
index 0c3b487..12f0cbd 100644
--- a/bindings/python/Cargo.toml
+++ b/bindings/python/Cargo.toml
@@ -33,4 +33,5 @@ datafusion-ffi = { workspace = true }
 paimon = { path = "../../crates/paimon", features = ["storage-all"] }
 paimon-datafusion = { path = "../../crates/integrations/datafusion", features 
= ["fulltext"] }
 pyo3 = { version = "0.28", features = ["abi3-py310"] }
+serde_json = "1.0"
 tokio = { workspace = true }
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 172c1fe..cd12609 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -36,10 +36,27 @@ class TableSchema:
     def options(self) -> Dict[str, str]: ...
     def comment(self) -> Optional[str]: ...
 
+class Split:
+    def __init__(self, state: bytes) -> None: ...
+    def row_count(self) -> int: ...
+
+class Plan:
+    def splits(self) -> List[Split]: ...
+    def __len__(self) -> int: ...
+
+class TableScan:
+    def plan(self) -> Plan: ...
+
+class ReadBuilder:
+    def with_projection(self, columns: List[str]) -> "ReadBuilder": ...
+    def with_limit(self, limit: int) -> "ReadBuilder": ...
+    def new_scan(self) -> TableScan: ...
+
 class Table:
     def identifier(self) -> str: ...
     def location(self) -> str: ...
     def schema(self) -> TableSchema: ...
+    def new_read_builder(self) -> ReadBuilder: ...
 
 class PaimonCatalog:
     def __init__(self, catalog_options: Dict[str, str]) -> None: ...
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 2eb9424..0880302 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -269,6 +269,10 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_, 
PyModule>) -> PyResult<()>
     let this = PyModule::new(py, "datafusion")?;
     this.add_class::<PaimonCatalog>()?;
     this.add_class::<crate::table::PyTable>()?;
+    this.add_class::<crate::read::PyReadBuilder>()?;
+    this.add_class::<crate::read::PyTableScan>()?;
+    this.add_class::<crate::read::PyPlan>()?;
+    this.add_class::<crate::read::PySplit>()?;
     this.add_class::<crate::schema::PyTableSchema>()?;
     this.add_class::<crate::schema::PyDataField>()?;
     this.add_class::<PyPythonScalarUDFObject>()?;
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index d0d2002..c4db29a 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -20,6 +20,7 @@ use pyo3::prelude::*;
 mod blob;
 mod context;
 mod error;
+mod read;
 mod schema;
 mod table;
 mod udf;
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
new file mode 100644
index 0000000..4e650d1
--- /dev/null
+++ b/bindings/python/src/read.rs
@@ -0,0 +1,160 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::sync::Arc;
+
+use paimon::table::{DataSplit, Table};
+use paimon_datafusion::runtime::runtime;
+use pyo3::exceptions::PyValueError;
+use pyo3::prelude::*;
+use pyo3::types::PyBytes;
+
+use crate::error::to_py_err;
+
+#[pyclass(name = "ReadBuilder", module = "pypaimon_rust.datafusion")]
+pub struct PyReadBuilder {
+    table: Arc<Table>,
+    projection: Option<Vec<String>>,
+    limit: Option<usize>,
+}
+
+impl PyReadBuilder {
+    pub fn new(table: Arc<Table>) -> Self {
+        Self {
+            table,
+            projection: None,
+            limit: None,
+        }
+    }
+}
+
+#[pymethods]
+impl PyReadBuilder {
+    fn with_projection(mut slf: PyRefMut<'_, Self>, columns: Vec<String>) -> 
PyRefMut<'_, Self> {
+        slf.projection = Some(columns);
+        slf
+    }
+
+    fn with_limit(mut slf: PyRefMut<'_, Self>, limit: usize) -> PyRefMut<'_, 
Self> {
+        slf.limit = Some(limit);
+        slf
+    }
+
+    fn new_scan(&self) -> PyTableScan {
+        PyTableScan {
+            table: Arc::clone(&self.table),
+            projection: self.projection.clone(),
+            limit: self.limit,
+        }
+    }
+}
+
+#[pyclass(name = "TableScan", module = "pypaimon_rust.datafusion")]
+pub struct PyTableScan {
+    table: Arc<Table>,
+    projection: Option<Vec<String>>,
+    limit: Option<usize>,
+}
+
+#[pymethods]
+impl PyTableScan {
+    fn plan(&self, py: Python<'_>) -> PyResult<PyPlan> {
+        let rt = runtime();
+        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);
+                }
+                let plan = builder.new_scan().plan().await.map_err(to_py_err)?;
+                Ok::<_, PyErr>(plan.splits().to_vec())
+            })
+        })?;
+        Ok(PyPlan { splits })
+    }
+}
+
+#[pyclass(name = "Plan", module = "pypaimon_rust.datafusion")]
+pub struct PyPlan {
+    splits: Vec<DataSplit>,
+}
+
+#[pymethods]
+impl PyPlan {
+    fn splits(&self) -> Vec<PySplit> {
+        self.splits
+            .iter()
+            .cloned()
+            .map(|inner| PySplit { inner })
+            .collect()
+    }
+
+    fn __len__(&self) -> usize {
+        self.splits.len()
+    }
+}
+
+#[pyclass(name = "Split", module = "pypaimon_rust.datafusion")]
+pub struct PySplit {
+    pub(crate) inner: DataSplit,
+}
+
+impl PySplit {
+    fn to_bytes(&self) -> PyResult<Vec<u8>> {
+        serde_json::to_vec(&self.inner)
+            .map_err(|e| PyValueError::new_err(format!("failed to serialize 
split: {e}")))
+    }
+
+    fn from_bytes(bytes: &[u8]) -> PyResult<DataSplit> {
+        serde_json::from_slice(bytes)
+            .map_err(|e| PyValueError::new_err(format!("failed to deserialize 
split: {e}")))
+    }
+}
+
+#[pymethods]
+impl PySplit {
+    /// Physical row count: sum of data-file row counts (not a logical result 
count).
+    fn row_count(&self) -> i64 {
+        self.inner.row_count()
+    }
+
+    /// Reduce to `Split(bytes)` for pickle/copy. The bytes are an opaque,
+    /// implementation-detail encoding; only same/compatible-version round-trip
+    /// is guaranteed.
+    fn __reduce__<'py>(
+        slf: &Bound<'py, Self>,
+        py: Python<'py>,
+    ) -> PyResult<(Py<PyAny>, (Py<PyBytes>,))> {
+        let bytes = slf.borrow().to_bytes()?;
+        let cls = slf.get_type().unbind().into_any();
+        Ok((cls, (PyBytes::new(py, &bytes).unbind(),)))
+    }
+
+    /// Reconstruct a split from opaque bytes produced by pickling. Direct
+    /// construction without those bytes is unsupported; obtain splits from
+    /// `ReadBuilder.new_scan().plan()`.
+    #[new]
+    fn new(state: &Bound<'_, PyBytes>) -> PyResult<Self> {
+        Ok(Self {
+            inner: Self::from_bytes(state.as_bytes())?,
+        })
+    }
+}
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index 0a0c35c..263c821 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -19,6 +19,7 @@ use std::sync::Arc;
 
 use pyo3::prelude::*;
 
+use crate::read::PyReadBuilder;
 use crate::schema::PyTableSchema;
 
 #[pyclass(name = "Table", module = "pypaimon_rust.datafusion")]
@@ -46,4 +47,9 @@ impl PyTable {
     fn schema(&self) -> PyTableSchema {
         PyTableSchema::new(self.inner.schema().clone())
     }
+
+    /// Create a [`PyReadBuilder`] for DataFrame-style scan planning.
+    fn new_read_builder(&self) -> PyReadBuilder {
+        PyReadBuilder::new(Arc::clone(&self.inner))
+    }
 }
diff --git a/bindings/python/tests/test_read.py 
b/bindings/python/tests/test_read.py
new file mode 100644
index 0000000..7b3442f
--- /dev/null
+++ b/bindings/python/tests/test_read.py
@@ -0,0 +1,87 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pickle
+import tempfile
+
+from pypaimon_rust.datafusion import PaimonCatalog, SQLContext
+
+
+def _make_table_with_data(warehouse):
+    ctx = SQLContext()
+    ctx.register_catalog("paimon", {"warehouse": warehouse})
+    ctx.sql("CREATE SCHEMA paimon.rdb")
+    ctx.sql("CREATE TABLE paimon.rdb.t (id INT, name STRING)")
+    ctx.sql("INSERT INTO paimon.rdb.t VALUES (1, 'a'), (2, 'b'), (3, 'c')")
+    catalog = PaimonCatalog({"warehouse": warehouse})
+    return catalog.get_table("rdb.t")
+
+
+def test_read_builder_chain_exists():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        builder = table.new_read_builder()
+        scan = builder.with_projection(["id"]).with_limit(2).new_scan()
+        # plan() returns a Plan; deeper assertions are in later tasks.
+        plan = scan.plan()
+        assert plan is not None
+
+
+def test_new_read_builder_plan():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        plan = table.new_read_builder().new_scan().plan()
+        assert len(plan.splits()) >= 1
+
+
+def test_with_projection():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        plan = 
table.new_read_builder().with_projection(["id"]).new_scan().plan()
+        assert plan is not None
+
+
+def test_with_limit():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        # limit is a planning hint; assert only that planning succeeds.
+        plan = table.new_read_builder().with_limit(1).new_scan().plan()
+        assert plan is not None
+
+
+def test_plan_len():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        plan = table.new_read_builder().new_scan().plan()
+        assert len(plan) == len(plan.splits())
+
+
+def test_plan_without_filter_succeeds():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        plan = table.new_read_builder().new_scan().plan()
+        assert len(plan.splits()) >= 1
+
+
+def test_split_pickle_roundtrip():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_table_with_data(warehouse)
+        splits = table.new_read_builder().new_scan().plan().splits()
+        assert len(splits) >= 1
+        split = splits[0]
+        restored = pickle.loads(pickle.dumps(split))
+        assert restored.row_count() == split.row_count()
diff --git a/crates/paimon/src/table/source.rs 
b/crates/paimon/src/table/source.rs
index 5cc310f..229afa0 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -413,7 +413,7 @@ impl PartitionBucket {
 /// Input split for reading: partition + bucket + list of data files and 
optional deletion files.
 ///
 /// Reference: 
[org.apache.paimon.table.source.DataSplit](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/source/DataSplit.java)
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct DataSplit {
     snapshot_id: i64,
     partition: BinaryRow,
@@ -777,6 +777,25 @@ mod tests {
             .unwrap()
     }
 
+    #[test]
+    fn data_split_serde_json_round_trip() {
+        let split = DataSplit::builder()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path("file:/tmp/bucket-0".to_string())
+            .with_total_buckets(1)
+            .with_data_files(vec![])
+            .build()
+            .unwrap();
+
+        let bytes = serde_json::to_vec(&split).expect("serialize");
+        let restored: DataSplit = 
serde_json::from_slice(&bytes).expect("deserialize");
+        assert_eq!(restored.snapshot_id(), split.snapshot_id());
+        assert_eq!(restored.bucket(), split.bucket());
+        assert_eq!(restored.bucket_path(), split.bucket_path());
+    }
+
     /// Raw convertible split without deletion files: physical sum is exact.
     #[test]
     fn test_merged_row_count_raw_convertible_sums_physical_rows() {

Reply via email to