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 3ee8580 [python] Expose DataFrame-style batch write + commit loop
(#420)
3ee8580 is described below
commit 3ee85808214c473acc8b0925f7abf08a4b3dabb3
Author: Junrui Lee <[email protected]>
AuthorDate: Wed Jul 1 14:57:21 2026 +0800
[python] Expose DataFrame-style batch write + commit loop (#420)
---
.../python/python/pypaimon_rust/datafusion.pyi | 14 ++
bindings/python/src/context.rs | 4 +
bindings/python/src/lib.rs | 1 +
bindings/python/src/table.rs | 6 +
bindings/python/src/write.rs | 225 +++++++++++++++++++++
bindings/python/tests/test_write.py | 179 ++++++++++++++++
6 files changed, 429 insertions(+)
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 772f31f..d7c7131 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -58,6 +58,20 @@ class Table:
def location(self) -> str: ...
def schema(self) -> TableSchema: ...
def new_read_builder(self) -> ReadBuilder: ...
+ def new_write_builder(self) -> "WriteBuilder": ...
+
+class CommitMessage: ...
+
+class TableWrite:
+ def write_arrow(self, batch: pyarrow.RecordBatch) -> None: ...
+ def prepare_commit(self) -> List[CommitMessage]: ...
+
+class TableCommit:
+ def commit(self, messages: Sequence[CommitMessage]) -> None: ...
+
+class WriteBuilder:
+ def new_write(self) -> TableWrite: ...
+ def new_commit(self) -> TableCommit: ...
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 0880302..d61b94a 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -277,6 +277,10 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_,
PyModule>) -> PyResult<()>
this.add_class::<crate::schema::PyDataField>()?;
this.add_class::<PyPythonScalarUDFObject>()?;
this.add_class::<PySQLContext>()?;
+ this.add_class::<crate::write::PyWriteBuilder>()?;
+ this.add_class::<crate::write::PyTableWrite>()?;
+ this.add_class::<crate::write::PyTableCommit>()?;
+ this.add_class::<crate::write::PyCommitMessage>()?;
this.add_function(wrap_pyfunction!(udf, &this)?)?;
m.add_submodule(&this)?;
py.import("sys")?
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 0415412..dd7177b 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -25,6 +25,7 @@ mod read;
mod schema;
mod table;
mod udf;
+mod write;
#[pymodule]
fn pypaimon_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index 263c821..2864b02 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -21,6 +21,7 @@ use pyo3::prelude::*;
use crate::read::PyReadBuilder;
use crate::schema::PyTableSchema;
+use crate::write::PyWriteBuilder;
#[pyclass(name = "Table", module = "pypaimon_rust.datafusion")]
pub struct PyTable {
@@ -52,4 +53,9 @@ impl PyTable {
fn new_read_builder(&self) -> PyReadBuilder {
PyReadBuilder::new(Arc::clone(&self.inner))
}
+
+ /// Create a [`PyWriteBuilder`] for the batch write loop.
+ fn new_write_builder(&self) -> PyWriteBuilder {
+ PyWriteBuilder::new(Arc::clone(&self.inner))
+ }
}
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
new file mode 100644
index 0000000..2b60429
--- /dev/null
+++ b/bindings/python/src/write.rs
@@ -0,0 +1,225 @@
+// 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 arrow::datatypes::Schema as ArrowSchema;
+use arrow::pyarrow::FromPyArrow;
+use arrow::record_batch::RecordBatch;
+use paimon::table::{CommitMessage, Table, TableCommit, TableWrite};
+use paimon_datafusion::runtime::runtime;
+use pyo3::exceptions::{PyTypeError, PyValueError};
+use pyo3::prelude::*;
+
+use crate::error::to_py_err;
+
+/// Validate an incoming batch schema against the table's target Arrow schema:
+/// field count, order, and names must match, and types must match exactly. The
+/// nullable flag is intentionally NOT compared, since
`build_target_arrow_schema`
+/// derives nullability from the Paimon field while pyarrow-constructed batches
+/// infer nullable=true. No cast — callers supply correctly-typed batches.
+///
+/// Type matching is strict (no binary-family interchange): the lower write
path
+/// downcasts to the exact Arrow array for each Paimon type (e.g. a `Binary` /
+/// `VarBinary` field requires `arrow_array::BinaryArray`, not `LargeBinary` /
+/// `FixedSizeBinary`). Accepting a near-equivalent type here would pass
+/// validation but then fail deeper with a type-mismatch (or write files whose
+/// Arrow schema differs from the table), so it is rejected up front.
+fn validate_batch_schema(input: &ArrowSchema, target: &ArrowSchema) ->
PyResult<()> {
+ let mismatch = || {
+ PyValueError::new_err(format!(
+ "Input schema is not consistent with the table schema. \
+ input: {input:?}, table: {target:?}"
+ ))
+ };
+ if input.fields().len() != target.fields().len() {
+ return Err(mismatch());
+ }
+ for (i, t) in input.fields().iter().zip(target.fields().iter()) {
+ if i.name() != t.name() {
+ return Err(mismatch());
+ }
+ if i.data_type() != t.data_type() {
+ return Err(mismatch());
+ }
+ }
+ Ok(())
+}
+
+/// Builder for the batch write loop, created via
[`crate::table::PyTable::new_write_builder`].
+///
+/// Holds the owning table plus a single fixed `commit_user`, generated once
and
+/// shared by both `new_write()` and `new_commit()` so that writers and the
+/// committer agree on the commit user (Paimon uses it for duplicate-commit
+/// detection). Creating a fresh `WriteBuilder` per call would otherwise mint a
+/// new random UUID each time.
+#[pyclass(name = "WriteBuilder", module = "pypaimon_rust.datafusion")]
+pub struct PyWriteBuilder {
+ table: Arc<Table>,
+ commit_user: String,
+}
+
+impl PyWriteBuilder {
+ pub fn new(table: Arc<Table>) -> Self {
+ let commit_user = table.new_write_builder().commit_user().to_string();
+ Self { table, commit_user }
+ }
+}
+
+#[pymethods]
+impl PyWriteBuilder {
+ /// Create a writer for accumulating Arrow batches.
+ fn new_write(&self) -> PyResult<PyTableWrite> {
+ let builder = self
+ .table
+ .new_write_builder()
+ .with_commit_user(self.commit_user.clone())
+ .map_err(to_py_err)?;
+ let target_schema =
paimon::arrow::build_target_arrow_schema(self.table.schema().fields())
+ .map_err(to_py_err)?;
+ Ok(PyTableWrite {
+ inner: builder.new_write().map_err(to_py_err)?,
+ target_schema,
+ table_location: self.table.location().to_string(),
+ commit_user: self.commit_user.clone(),
+ })
+ }
+
+ /// Create a committer for persisting prepared commit messages.
+ fn new_commit(&self) -> PyResult<PyTableCommit> {
+ let builder = self
+ .table
+ .new_write_builder()
+ .with_commit_user(self.commit_user.clone())
+ .map_err(to_py_err)?;
+ Ok(PyTableCommit {
+ inner: builder.new_commit(),
+ table_location: self.table.location().to_string(),
+ commit_user: self.commit_user.clone(),
+ })
+ }
+}
+
+/// A stateful writer that accumulates Arrow batches until `prepare_commit`.
+///
+/// Marked `unsendable`: the underlying `TableWrite` holds file writers that
are
+/// not `Sync`, so the object enforces single-thread access at runtime.
+#[pyclass(name = "TableWrite", module = "pypaimon_rust.datafusion",
unsendable)]
+pub struct PyTableWrite {
+ inner: TableWrite,
+ /// The table's target Arrow schema, used to validate incoming batches.
+ target_schema: Arc<ArrowSchema>,
+ /// The owning table's location, stamped onto produced commit messages so a
+ /// committer can reject messages prepared for a different table.
+ table_location: String,
+ /// The originating builder's `commit_user`, stamped onto produced
messages so
+ /// a committer can reject messages prepared by a different `WriteBuilder`
+ /// (writers and committers from the same builder must share one
commit_user;
+ /// it drives snapshot duplicate detection and postpone-bucket file
naming).
+ commit_user: String,
+}
+
+#[pymethods]
+impl PyTableWrite {
+ /// Write a single PyArrow RecordBatch into the table's writers.
+ fn write_arrow(&mut self, py: Python<'_>, batch: &Bound<'_, PyAny>) ->
PyResult<()> {
+ let batch = RecordBatch::from_pyarrow_bound(batch)?;
+ validate_batch_schema(&batch.schema(), &self.target_schema)?;
+ let rt = runtime();
+ py.detach(|| rt.block_on(async {
self.inner.write_arrow_batch(&batch).await }))
+ .map_err(to_py_err)
+ }
+
+ /// Close writers and return the commit messages (opaque; pass to
commit()).
+ fn prepare_commit(&mut self, py: Python<'_>) ->
PyResult<Vec<PyCommitMessage>> {
+ let rt = runtime();
+ let messages = py
+ .detach(|| rt.block_on(async { self.inner.prepare_commit().await
}))
+ .map_err(to_py_err)?;
+ Ok(messages
+ .into_iter()
+ .map(|inner| PyCommitMessage {
+ inner,
+ table_location: self.table_location.clone(),
+ commit_user: self.commit_user.clone(),
+ })
+ .collect())
+ }
+}
+
+/// A committer that persists prepared commit messages as a snapshot.
+#[pyclass(name = "TableCommit", module = "pypaimon_rust.datafusion")]
+pub struct PyTableCommit {
+ inner: TableCommit,
+ /// The owning table's location, used to reject commit messages that were
+ /// prepared for a different table (which would otherwise persist a
snapshot
+ /// referencing data files written under another table).
+ table_location: String,
+ /// The committer's `commit_user`, used to reject messages prepared by a
+ /// different `WriteBuilder` — even for the same table — since the writer
and
+ /// committer must share one commit_user.
+ commit_user: String,
+}
+
+#[pymethods]
+impl PyTableCommit {
+ /// Commit the given commit messages. Empty input is a no-op success.
+ fn commit(&self, py: Python<'_>, messages: &Bound<'_, PyAny>) ->
PyResult<()> {
+ let mut inner_messages = Vec::new();
+ let iter = messages.try_iter().map_err(|_| {
+ PyTypeError::new_err("commit() expects a sequence of CommitMessage
objects")
+ })?;
+ for item in iter {
+ let item = item?;
+ let msg: PyRef<PyCommitMessage> = item.extract().map_err(|_| {
+ PyTypeError::new_err("commit() expects a sequence of
CommitMessage objects")
+ })?;
+ if msg.table_location != self.table_location {
+ return Err(PyValueError::new_err(format!(
+ "commit message was prepared for a different table \
+ (message table '{}', committer table '{}')",
+ msg.table_location, self.table_location
+ )));
+ }
+ if msg.commit_user != self.commit_user {
+ return Err(PyValueError::new_err(
+ "commit message was prepared by a different WriteBuilder \
+ (writer and committer must come from the same \
+ table.new_write_builder() so they share one commit_user)"
+ .to_string(),
+ ));
+ }
+ inner_messages.push(msg.inner.clone());
+ }
+ let rt = runtime();
+ py.detach(|| rt.block_on(async {
self.inner.commit(inner_messages).await }))
+ .map_err(to_py_err)
+ }
+}
+
+/// An opaque commit message produced by `prepare_commit`, consumed by
`commit`.
+/// PR1 supports same-process transfer only (no pickle/serialization).
+///
+/// Carries the originating table's location and builder `commit_user` so a
+/// committer can reject messages prepared for a different table or by a
+/// different `WriteBuilder`.
+#[pyclass(name = "CommitMessage", module = "pypaimon_rust.datafusion")]
+pub struct PyCommitMessage {
+ pub(crate) inner: CommitMessage,
+ pub(crate) table_location: String,
+ pub(crate) commit_user: String,
+}
diff --git a/bindings/python/tests/test_write.py
b/bindings/python/tests/test_write.py
new file mode 100644
index 0000000..66d7d10
--- /dev/null
+++ b/bindings/python/tests/test_write.py
@@ -0,0 +1,179 @@
+# 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 tempfile
+
+import pyarrow as pa
+import pytest
+
+from pypaimon_rust.datafusion import PaimonCatalog, SQLContext
+
+# The table created by _make_empty_table is (id INT, name STRING). Paimon INT
maps
+# to Arrow int32, so batches must use int32 for id — pyarrow infers Python
ints as
+# int64, which write_arrow now (correctly, matching pypaimon) rejects as a type
+# mismatch. Build batches against this explicit schema to match the table.
+_TABLE_SCHEMA = pa.schema([("id", pa.int32()), ("name", pa.string())])
+
+
+def _batch(ids, names):
+ return pa.record_batch([ids, names], schema=_TABLE_SCHEMA)
+
+
+def _make_empty_table(warehouse):
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+ ctx.sql("CREATE SCHEMA paimon.wdb")
+ ctx.sql("CREATE TABLE paimon.wdb.t (id INT, name STRING)")
+ return ctx
+
+
+def _get_table(warehouse):
+ return PaimonCatalog({"warehouse": warehouse}).get_table("wdb.t")
+
+
+def test_write_commit_read_roundtrip():
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = _make_empty_table(warehouse)
+ table = _get_table(warehouse)
+ batch = _batch([1, 2, 3], ["a", "b", "c"])
+ wb = table.new_write_builder()
+ write = wb.new_write()
+ write.write_arrow(batch)
+ messages = write.prepare_commit()
+ assert len(messages) >= 1 # cover API shape in the
first test
+ wb.new_commit().commit(messages) # same wb → shared commit_user
+ result = pa.Table.from_batches(
+ ctx.sql("SELECT id, name FROM paimon.wdb.t")
+ ).sort_by("id").to_pydict()
+ assert result == {"id": [1, 2, 3], "name": ["a", "b", "c"]}
+
+
+def test_write_multiple_batches():
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = _make_empty_table(warehouse)
+ table = _get_table(warehouse)
+ wb = table.new_write_builder()
+ write = wb.new_write()
+ write.write_arrow(_batch([1], ["a"]))
+ write.write_arrow(_batch([2], ["b"]))
+ messages = write.prepare_commit()
+ wb.new_commit().commit(messages)
+ result = pa.Table.from_batches(
+ ctx.sql("SELECT id, name FROM paimon.wdb.t")
+ ).sort_by("id").to_pydict()
+ assert result == {"id": [1, 2], "name": ["a", "b"]}
+
+
+def test_prepare_commit_returns_messages():
+ with tempfile.TemporaryDirectory() as warehouse:
+ _make_empty_table(warehouse)
+ table = _get_table(warehouse)
+ write = table.new_write_builder().new_write()
+ write.write_arrow(_batch([1], ["a"]))
+ messages = write.prepare_commit()
+ assert len(messages) >= 1
+ assert all(type(m).__name__ == "CommitMessage" for m in messages)
+
+
+def test_commit_empty_messages_noop():
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = _make_empty_table(warehouse)
+ table = _get_table(warehouse)
+ wb = table.new_write_builder()
+ messages = wb.new_write().prepare_commit() # no write
+ assert messages == []
+ wb.new_commit().commit(messages) # no-op success
+ batches = ctx.sql("SELECT COUNT(*) AS cnt FROM paimon.wdb.t")
+ assert batches[0].column(0).to_pylist() == [0]
+
+
+def test_write_arrow_type_mismatch_raises():
+ with tempfile.TemporaryDirectory() as warehouse:
+ _make_empty_table(warehouse) # table (id INT, name STRING)
+ table = _get_table(warehouse)
+ write = table.new_write_builder().new_write()
+ bad = pa.record_batch([["x", "y"], ["a", "b"]], names=["id", "name"])
# id as STRING
+ with pytest.raises(ValueError):
+ write.write_arrow(bad)
+
+
+def test_write_arrow_binary_family_mismatch_raises():
+ # A BINARY column requires Arrow `binary`; a near-equivalent `large_binary`
+ # must be rejected at validation (it would otherwise fail deeper, since the
+ # write path downcasts binary fields to arrow_array::BinaryArray only).
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+ ctx.sql("CREATE SCHEMA paimon.wdb")
+ ctx.sql("CREATE TABLE paimon.wdb.bt (id INT, data BINARY)")
+ table = PaimonCatalog({"warehouse": warehouse}).get_table("wdb.bt")
+ write = table.new_write_builder().new_write()
+ schema = pa.schema([("id", pa.int32()), ("data", pa.large_binary())])
+ bad = pa.record_batch([[1], [b"x"]], schema=schema)
+ with pytest.raises(ValueError):
+ write.write_arrow(bad)
+
+
+def test_commit_non_message_raises_typeerror():
+ with tempfile.TemporaryDirectory() as warehouse:
+ _make_empty_table(warehouse)
+ table = _get_table(warehouse)
+ with pytest.raises(TypeError):
+ table.new_write_builder().new_commit().commit([object()])
+ # A non-iterable argument also raises TypeError (not a raw PyO3 error).
+ with pytest.raises(TypeError):
+ table.new_write_builder().new_commit().commit(42)
+
+
+def test_commit_cross_table_messages_raises():
+ # Messages prepared for one table must not be committed by another table's
+ # committer (would persist a snapshot referencing data files written
+ # elsewhere). The wrapper stamps each message with its source table
location
+ # and the committer rejects mismatches.
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+ ctx.sql("CREATE SCHEMA paimon.wdb")
+ ctx.sql("CREATE TABLE paimon.wdb.t1 (id INT, name STRING)")
+ ctx.sql("CREATE TABLE paimon.wdb.t2 (id INT, name STRING)")
+ catalog = PaimonCatalog({"warehouse": warehouse})
+ t1 = catalog.get_table("wdb.t1")
+ t2 = catalog.get_table("wdb.t2")
+ batch = pa.record_batch(
+ [pa.array([1], pa.int32()), pa.array(["a"], pa.string())],
+ names=["id", "name"],
+ )
+ w1 = t1.new_write_builder().new_write()
+ w1.write_arrow(batch)
+ messages = w1.prepare_commit()
+ with pytest.raises(ValueError):
+ t2.new_write_builder().new_commit().commit(messages)
+
+
+def test_commit_different_builder_same_table_raises():
+ # Even for the same table, a committer from a different WriteBuilder must
+ # reject the messages: each builder mints its own commit_user, and writers
+ # and committers must share one (snapshot duplicate detection / postpone
+ # bucket file naming depend on it).
+ with tempfile.TemporaryDirectory() as warehouse:
+ _make_empty_table(warehouse)
+ table = _get_table(warehouse)
+ write = table.new_write_builder().new_write()
+ write.write_arrow(_batch([1], ["a"]))
+ messages = write.prepare_commit()
+ with pytest.raises(ValueError):
+ table.new_write_builder().new_commit().commit(messages)