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

yihua pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hudi-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new e5a2552c feat(python): add write APIs to the Python binding (#878)
e5a2552c is described below

commit e5a2552cabf154b76d989ff4050d58392d6c587b
Author: Y Ethan Guo <[email protected]>
AuthorDate: Fri Oct 30 00:16:39 2026 -0700

    feat(python): add write APIs to the Python binding (#878)
    
    The Python package exposed only the read APIs, so `pip install hudi` could 
not create or change a table. `HudiTable` gains `create` (wrapping 
`TableCreateBuilder`, forwarding only the arguments a caller passes so the Rust 
defaults stay the defaults), `append`, `upsert` (with optional `update_columns` 
on copy-on-write), `update`, `delete`, `delete_keys`, `overwrite` and 
`dynamic_partition_overwrite`, each returning a `HudiWriteResult`. Every write 
releases the GIL and maps errors to Py [...]
    
    Write input is any `pyarrow.Table`, `RecordBatch` or `RecordBatchReader`, a 
list of batches, or any object implementing the Arrow PyCapsule stream 
interface, which covers Polars and pandas 2.2+ without adding either as a 
dependency. View, large and dictionary types are normalized to the types the 
writer accepts (moving that into core is #864), nanosecond timestamps from 
pandas and Polars become microseconds (a value with sub-microsecond precision 
is rejected rather than truncated), an [...]
---
 README.md                        |   82 ++-
 python/hudi/__init__.py          |    2 +
 python/hudi/_internal.pyi        |  204 +++++++-
 python/pyproject.toml            |    2 +
 python/src/internal.rs           | 1046 +++++++++++++++++++++++++++++++++++++-
 python/src/lib.rs                |    3 +-
 python/tests/test_table_write.py |  774 ++++++++++++++++++++++++++++
 7 files changed, 2101 insertions(+), 12 deletions(-)

diff --git a/README.md b/README.md
index ce078cd1..35ad9df0 100644
--- a/README.md
+++ b/README.md
@@ -322,7 +322,7 @@ std::vector<std::string> log_file_paths{};
 ArrowArrayStream* stream_ptr = 
reader->read_file_slice_from_paths("relative/path.parquet", log_file_paths);
 ```
 
-## Writing Tables (Rust)
+## Writing Tables
 
 hudi-rs includes a native, single-node write path that is storage-compatible
 with the Apache Hudi Spark writer: tables written here are readable — and
@@ -333,12 +333,30 @@ metadata table (file listings, column/partition stats, 
record-level index) on
 every commit. See [docs/writer-design.md](docs/writer-design.md) for the full
 design.
 
-> [!NOTE]
-> Write APIs are currently Rust-only; Python and C++ bindings expose the read
-> APIs shown above.
+Python exposes the same write APIs on `HudiTable`, accepting PyArrow tables,
+record batches and readers, or any object implementing the Arrow PyCapsule
+stream interface (`__arrow_c_stream__`, e.g. polars and pandas >= 2.2
+DataFrames). Each write returns a `HudiWriteResult`. The C++ binding exposes
+the read APIs only.
 
 ### Create a Table
 
+#### Python
+
+```python
+from hudi import HudiTable
+
+table = HudiTable.create(
+    "/tmp/trips_table",
+    record_key="uuid",
+    partition_by="city",
+    ordering_fields="ts",     # event-time ordering
+    table_type="cow",         # or "mor"
+)
+```
+
+#### Rust
+
 ```rust
 use hudi::table::Table;
 use hudi::config::table::TableTypeValue;
@@ -361,6 +379,20 @@ an index lookup, splitting input into files bounded by
 path: unpartitioned append-only tables with `hoodie.populate.meta.fields=false`
 and no metadata table, one base file per call.
 
+#### Python
+
+```python
+import pyarrow as pa
+
+# `table` is the HudiTable created above; the input is plain Arrow data.
+batch = pa.record_batch(
+    {"uuid": ["a", "b"], "city": ["san_francisco"] * 2, "ts": [1, 1], "fare": 
[30.0, 45.0]}
+)
+table.append(batch)
+```
+
+#### Rust
+
 ```rust
 use arrow_array::RecordBatch;
 
@@ -376,6 +408,18 @@ update their file group in place (a rewritten base file on 
COW, a log file on
 MOR); new keys pack into existing small files first and then form new file
 groups.
 
+#### Python
+
+```python
+result = table.upsert(batch)
+print(f"instant={result.instant_time} updates={result.num_updates} 
inserts={result.num_inserts}")
+
+# Partial upsert (copy-on-write only): matched rows take only these columns 
from the input.
+table.upsert(batch, update_columns=["fare"])
+```
+
+#### Rust
+
 ```rust
 let result = table.upsert([batch]).await?;
 println!(
@@ -386,6 +430,24 @@ println!(
 
 ### Update and Delete
 
+A write whose filter or keys match nothing succeeds without creating a commit
+and reports zero rows.
+
+#### Python
+
+```python
+# SQL-style update: each value must fit its column's type exactly.
+table.update("city = 'san_francisco'", {"fare": 99.0})
+
+# Delete by predicate (single comparison, or IN for multiple keys).
+table.delete("uuid IN ('a')")
+
+# Delete by key, optionally with the partition path.
+table.delete_keys([("b", "city=san_francisco")])
+```
+
+#### Rust
+
 ```rust
 use hudi::table::HoodieKey;
 
@@ -405,6 +467,18 @@ table.delete_keys([HoodieKey {
 
 ### Overwrite
 
+#### Python
+
+```python
+# INSERT_OVERWRITE_TABLE: replace all file groups.
+table.overwrite(batch)
+
+# INSERT_OVERWRITE: replace only the partitions present in the input.
+table.dynamic_partition_overwrite(batch)
+```
+
+#### Rust
+
 ```rust
 // INSERT_OVERWRITE_TABLE: replace all file groups.
 table.overwrite([batch]).await?;
diff --git a/python/hudi/__init__.py b/python/hudi/__init__.py
index 09a9e2fe..81c10e94 100644
--- a/python/hudi/__init__.py
+++ b/python/hudi/__init__.py
@@ -27,6 +27,7 @@ from hudi._internal import (
     HudiRecordBatchStream,
     HudiTable,
     HudiTimeline,
+    HudiWriteResult,
 )
 from hudi._internal import __version__ as __version__
 from hudi.table.builder import HudiTableBuilder
@@ -45,4 +46,5 @@ __all__ = [
     "HudiTableBuilder",
     "HudiTableConfig",
     "HudiTimeline",
+    "HudiWriteResult",
 ]
diff --git a/python/hudi/_internal.pyi b/python/hudi/_internal.pyi
index 2ec130f8..c5d3d9df 100644
--- a/python/hudi/_internal.pyi
+++ b/python/hudi/_internal.pyi
@@ -15,12 +15,24 @@
 #  specific language governing permissions and limitations
 #  under the License.
 from dataclasses import dataclass
-from typing import ClassVar, Dict, List, Optional, Tuple
+from typing import Any, ClassVar, Dict, List, Optional, Protocol, Tuple, Union
 
 import pyarrow  # type: ignore[import-untyped]
 
 __version__: str
 
+class _ArrowStreamExportable(Protocol):
+    def __arrow_c_stream__(self, requested_schema: Any = None) -> Any: ...
+
+WriteData = Union[
+    "pyarrow.Table",
+    "pyarrow.RecordBatch",
+    "pyarrow.RecordBatchReader",
+    List["pyarrow.RecordBatch"],
+    _ArrowStreamExportable,
+]
+"""Tabular input accepted by the :class:`HudiTable` write methods."""
+
 def _config_keys() -> Dict[str, List[Tuple[str, str]]]: ...
 
 class HudiQueryType:
@@ -255,6 +267,10 @@ class HudiTable:
     """
     Represents a Hudi table and provides methods to interact with it.
 
+    A ``HudiTable`` must not be shared across threads while a write is running:
+    each write method takes exclusive access to the instance, so a concurrent
+    call on it raises ``RuntimeError``. Use one instance per thread.
+
     Attributes:
         base_uri (str): The base URI of the Hudi table.
         options (Optional[Dict[str, str]]): Additional options for table 
operations.
@@ -436,6 +452,192 @@ class HudiTable:
         is not yet supported and raises ``HudiCoreError``.
         """
         ...
+    @staticmethod
+    def create(
+        base_uri: str,
+        *,
+        record_key: Union[str, List[str]],
+        partition_by: Optional[Union[str, List[str]]] = None,
+        ordering_fields: Optional[Union[str, List[str]]] = None,
+        table_type: str = "cow",
+        table_name: Optional[str] = None,
+        table_version: Optional[int] = None,
+        record_index: Optional[bool] = None,
+        metadata_table: Optional[bool] = None,
+        column_stats: Optional[bool] = None,
+        partition_stats: Optional[bool] = None,
+        hive_style_partitioning: Optional[bool] = None,
+        hudi_options: Optional[Dict[str, str]] = None,
+        storage_options: Optional[Dict[str, str]] = None,
+    ) -> "HudiTable":
+        """
+        Create a new Hudi table at ``base_uri`` and open it for reads and 
writes.
+
+        Arguments left as ``None`` keep the native defaults: table version 9,
+        metadata table on with the record index, column stats and partition
+        stats, and hive-style partition paths. Turning ``metadata_table`` off 
also
+        turns off the indexes that live in it.
+
+        Parameters:
+            base_uri (str): Location of the new table.
+            record_key (Union[str, List[str]]): Record key field(s).
+            partition_by (Optional[Union[str, List[str]]]): Partition 
field(s); unpartitioned if omitted.
+            ordering_fields (Optional[Union[str, List[str]]]): Field(s) that 
pick the winning
+                record when a key is written more than once; commit order if 
omitted.
+            table_type (str): ``"cow"``/``"COPY_ON_WRITE"`` or 
``"mor"``/``"MERGE_ON_READ"``,
+                case-insensitive.
+            table_name (Optional[str]): Defaults to the last path segment of 
``base_uri``.
+            table_version (Optional[int]): Table version to write, 8 or 9.
+            record_index (Optional[bool]): Record-level index in the metadata 
table.
+            metadata_table (Optional[bool]): Metadata table.
+            column_stats (Optional[bool]): Column stats index in the metadata 
table.
+            partition_stats (Optional[bool]): Partition stats index in the 
metadata table.
+            hive_style_partitioning (Optional[bool]): Use ``field=value`` 
partition paths.
+            hudi_options (Optional[Dict[str, str]]): Extra ``hoodie.*`` table 
configs, written into
+                ``hoodie.properties``. Read options belong on the reader, not 
here.
+            storage_options (Optional[Dict[str, str]]): Storage options such 
as cloud credentials.
+
+        Returns:
+            HudiTable: The new table.
+
+        Raises:
+            ValueError: If ``table_type`` is not recognized.
+            HudiCoreError: If a table already exists at ``base_uri`` or the
+                options are inconsistent.
+        """
+        ...
+    def append(self, data: WriteData) -> "HudiWriteResult":
+        """
+        Insert ``data`` as one commit without looking up existing keys.
+
+        Parameters:
+            data (WriteData): A pyarrow ``Table``, ``RecordBatch``, 
``RecordBatchReader``,
+                a list of ``RecordBatch``, or any object implementing 
``__arrow_c_stream__``
+                (for example a polars or pandas >= 2.2 DataFrame).
+
+        Returns:
+            HudiWriteResult: ``num_writes`` and ``num_inserts`` are the rows 
written.
+        """
+        ...
+    def upsert(
+        self,
+        data: WriteData,
+        update_columns: Optional[List[str]] = None,
+    ) -> "HudiWriteResult":
+        """
+        Update rows whose record key exists and insert the rest.
+
+        Parameters:
+            data (WriteData): Rows to upsert; see :meth:`append` for accepted 
types.
+            update_columns (Optional[List[str]]): When set, matched rows only 
take these
+                columns from ``data`` and keep their other values. 
Copy-on-write tables only.
+
+        Returns:
+            HudiWriteResult: The commit and its update/insert counts.
+        """
+        ...
+    def update(
+        self,
+        predicate: str,
+        updates: Union[
+            Dict[str, Any],
+            "pyarrow.RecordBatch",
+            "pyarrow.Table",
+            _ArrowStreamExportable,
+        ],
+    ) -> "HudiWriteResult":
+        """
+        Set columns on the rows matching ``predicate``.
+
+        Parameters:
+            predicate (str): A single binary predicate such as ``"city = 
'nyc'"``.
+            updates (Union[Dict[str, Any], pyarrow.RecordBatch, 
pyarrow.Table]): Column values to
+                set, either as a dict or as a one-row batch carrying only 
those columns.
+                Dict values must fit their column's type exactly, except that 
a float32
+                column rounds to the nearest float32; a bool fits only a bool 
column. Date
+                columns take ``datetime.date`` and timestamp columns take
+                ``datetime.datetime``; a tz-aware datetime needs a timestamp 
column with a
+                time zone. Struct fields missing from a dict value are set to 
null.
+
+        Returns:
+            HudiWriteResult: No commit is created when nothing matches.
+
+        Raises:
+            ValueError: For dict input, if a key is not a str column of the 
table schema
+                or a value does not fit its column; for batch input, if it 
yields no record
+                batches (such as an empty ``pyarrow.Table``).
+            HudiCoreError: For batch input that does not match the table 
schema or does not
+                hold exactly one row.
+        """
+        ...
+    def delete(self, predicate: str) -> "HudiWriteResult":
+        """
+        Delete the rows matching ``predicate``.
+
+        Parameters:
+            predicate (str): A single binary predicate such as ``"id IN ('t1', 
't2')"``.
+                Predicates on the record key use the index; others scan the 
table.
+
+        Returns:
+            HudiWriteResult: No commit is created when nothing matches.
+        """
+        ...
+    def delete_keys(self, keys: List[Union[str, Tuple[str, str]]]) -> 
"HudiWriteResult":
+        """
+        Delete records by record key.
+
+        Parameters:
+            keys (List[Union[str, Tuple[str, str]]]): Record keys, or
+                ``(record_key, partition_path)`` tuples. ``partition_path`` is 
the
+                table-relative path as stored, e.g. ``city=nyc`` with 
hive-style
+                partitioning. A bare key is looked up in every partition.
+
+        Returns:
+            HudiWriteResult: No commit is created when no key exists.
+        """
+        ...
+    def overwrite(self, data: WriteData) -> "HudiWriteResult":
+        """
+        Replace all rows in the table with ``data``.
+
+        Parameters:
+            data (WriteData): Replacement rows; see :meth:`append` for 
accepted types.
+
+        Returns:
+            HudiWriteResult: The replace commit.
+        """
+        ...
+    def dynamic_partition_overwrite(self, data: WriteData) -> 
"HudiWriteResult":
+        """
+        Replace only the partitions present in ``data`` in a partitioned table.
+
+        Parameters:
+            data (WriteData): Replacement rows; see :meth:`append` for 
accepted types.
+
+        Returns:
+            HudiWriteResult: The replace commit.
+        """
+        ...
+
+@dataclass(init=False)
+class HudiWriteResult:
+    """
+    Outcome of a write on :class:`HudiTable`.
+
+    Attributes:
+        instant_time (Optional[str]): Instant time of the completed commit, or 
``None``
+            when no commit was made.
+        num_writes (int): Rows written to data files.
+        num_updates (int): Input rows that replaced existing records.
+        num_inserts (int): Input rows that were new records.
+        num_deletes (int): Records deleted.
+    """
+
+    instant_time: Optional[str]
+    num_writes: int
+    num_updates: int
+    num_inserts: int
+    num_deletes: int
 
 @dataclass(init=False)
 class HudiTimeline:
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 16c5d1b3..4c25fec2 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -48,6 +48,8 @@ devel = [
     "ruff==0.17.1",
     "mypy==2.4.0",
     "pre-commit",
+    "pandas>=2.2",
+    "polars>=1.0",
 ]
 lint = [
     "ruff==0.17.1",
diff --git a/python/src/internal.rs b/python/src/internal.rs
index 98639e12..8c3adbf7 100644
--- a/python/src/internal.rs
+++ b/python/src/internal.rs
@@ -17,13 +17,21 @@
  * under the License.
  */
 
-use arrow::pyarrow::ToPyArrow;
-use arrow::record_batch::RecordBatch;
+use arrow::array::{
+    Array, ArrayData, ArrayRef, AsArray, GenericListArray, OffsetSizeTrait, 
make_array,
+};
+use arrow::compute::{cast, concat_batches};
+use arrow::datatypes::TimestampNanosecondType;
+use arrow::datatypes::{DataType, FieldRef, Schema, TimeUnit};
+use arrow::ffi_stream::ArrowArrayStreamReader;
+use arrow::pyarrow::{FromPyArrow, ToPyArrow};
+use arrow::record_batch::{RecordBatch, RecordBatchOptions};
 use futures::StreamExt;
 use futures::stream::BoxStream;
 use std::collections::HashMap;
 use std::convert::From;
 use std::path::PathBuf;
+use std::str::FromStr;
 use std::sync::{Arc, OnceLock};
 use tokio::runtime::Runtime;
 use tokio::sync::Mutex;
@@ -33,7 +41,7 @@ use datafusion::error::DataFusionError;
 
 use hudi::config::plan::HudiPlanConfig;
 use hudi::config::read::HudiReadConfig;
-use hudi::config::table::HudiTableConfig;
+use hudi::config::table::{HudiTableConfig, TableTypeValue};
 use hudi::error::CoreError;
 use hudi::error::Result as HudiResult;
 use hudi::file_group::FileGroup;
@@ -41,12 +49,15 @@ use hudi::file_group::file_slice::FileSlice;
 use hudi::file_group::reader::FileGroupReader;
 use hudi::storage::error::StorageError;
 use hudi::table::builder::TableBuilder;
-use hudi::table::{QueryType, ReadOptions, Table};
+use hudi::table::{AppendResult, QueryType, ReadOptions, Table};
 use hudi::timeline::Timeline;
 use hudi::timeline::instant::Instant;
+use hudi::{HoodieKey, UpsertOptions, WriteResult};
 use pyo3::exceptions::PyException;
+use pyo3::exceptions::{PyTypeError, PyValueError};
 use pyo3::prelude::*;
-use pyo3::{PyErr, PyResult, Python, create_exception, pyclass, pyfunction, 
pymethods};
+use pyo3::types::{PyBool, PyDict, PyList, PyString, PyTuple};
+use pyo3::{PyErr, PyResult, Python, create_exception, intern, pyclass, 
pyfunction, pymethods};
 use std::error::Error;
 
 type RecordBatchBoxStream = BoxStream<'static, HudiResult<RecordBatch>>;
@@ -849,6 +860,707 @@ impl HudiTable {
         })?;
         Ok(HudiRecordBatchStream::from_stream(stream))
     }
+
+    #[staticmethod]
+    #[pyo3(signature = (
+        base_uri,
+        *,
+        record_key,
+        partition_by=None,
+        ordering_fields=None,
+        table_type="cow",
+        table_name=None,
+        table_version=None,
+        record_index=None,
+        metadata_table=None,
+        column_stats=None,
+        partition_stats=None,
+        hive_style_partitioning=None,
+        hudi_options=None,
+        storage_options=None,
+    ))]
+    #[allow(clippy::too_many_arguments)]
+    fn create(
+        py: Python,
+        base_uri: &str,
+        record_key: OneOrMany,
+        partition_by: Option<OneOrMany>,
+        ordering_fields: Option<OneOrMany>,
+        table_type: &str,
+        table_name: Option<String>,
+        table_version: Option<isize>,
+        record_index: Option<bool>,
+        metadata_table: Option<bool>,
+        column_stats: Option<bool>,
+        partition_stats: Option<bool>,
+        hive_style_partitioning: Option<bool>,
+        hudi_options: Option<HashMap<String, String>>,
+        storage_options: Option<HashMap<String, String>>,
+    ) -> PyResult<Self> {
+        let table_type = TableTypeValue::from_str(table_type).map_err(|_| {
+            PyValueError::new_err(format!(
+                "invalid table_type '{table_type}': expected 'cow' 
(COPY_ON_WRITE) or 'mor' (MERGE_ON_READ)"
+            ))
+        })?;
+        let table_name = table_name.unwrap_or_else(|| 
last_path_segment(base_uri));
+        let mut builder = Table::create(base_uri)
+            .with_table_name(table_name)
+            .with_table_type(table_type)
+            .with_record_key_fields(record_key.into_vec());
+        if let Some(fields) = partition_by {
+            builder = builder.with_partition_fields(fields.into_vec());
+        }
+        if let Some(fields) = ordering_fields {
+            builder = builder.with_ordering_fields(fields.into_vec());
+        }
+        // Order matters: the version and metadata setters adjust the index
+        // flags that follow them, so explicit index choices must come last.
+        if let Some(version) = table_version {
+            builder = builder.with_table_version(version);
+        }
+        if let Some(enabled) = metadata_table {
+            builder = builder.with_metadata(enabled);
+        }
+        if let Some(enabled) = record_index {
+            builder = builder.with_record_index(enabled);
+        }
+        if let Some(enabled) = column_stats {
+            builder = builder.with_column_stats(enabled);
+        }
+        if let Some(enabled) = partition_stats {
+            builder = builder.with_partition_stats(enabled);
+        }
+        if let Some(enabled) = hive_style_partitioning {
+            builder = builder.with_hive_style_partitioning(enabled);
+        }
+        for (k, v) in hudi_options.unwrap_or_default() {
+            builder = builder.with_option(k, v);
+        }
+        for (k, v) in storage_options.unwrap_or_default() {
+            builder = builder.with_storage_option(k, v);
+        }
+        let inner = py.detach(|| 
rt().block_on(builder.create()).map_err(PythonError::from))?;
+        Ok(HudiTable { inner })
+    }
+
+    fn append(&mut self, data: &Bound<'_, PyAny>, py: Python) -> 
PyResult<HudiWriteResult> {
+        let batches = to_batches(data)?;
+        let result = py.detach(|| {
+            rt().block_on(self.inner.append(batches))
+                .map_err(PythonError::from)
+        })?;
+        Ok(HudiWriteResult::from(result))
+    }
+
+    #[pyo3(signature = (data, update_columns=None))]
+    fn upsert(
+        &mut self,
+        data: &Bound<'_, PyAny>,
+        update_columns: Option<Vec<String>>,
+        py: Python,
+    ) -> PyResult<HudiWriteResult> {
+        let batches = to_batches(data)?;
+        let options = UpsertOptions { update_columns };
+        let result = py.detach(|| {
+            rt().block_on(self.inner.upsert_with(batches, options))
+                .map_err(PythonError::from)
+        })?;
+        Ok(HudiWriteResult::from(result))
+    }
+
+    fn update(
+        &mut self,
+        predicate: &str,
+        updates: &Bound<'_, PyAny>,
+        py: Python,
+    ) -> PyResult<HudiWriteResult> {
+        let updates = if let Ok(dict) = updates.cast::<PyDict>() {
+            let schema = py.detach(|| {
+                rt().block_on(self.inner.get_schema())
+                    .map_err(PythonError::from)
+            })?;
+            one_row_batch_from_dict(py, &schema, dict)?
+        } else {
+            let batches = to_batches(updates)?;
+            let Some(first) = batches.first() else {
+                return Err(PyValueError::new_err(
+                    "`updates` must contain exactly one row, got no batches",
+                ));
+            };
+            concat_batches(&first.schema(), &batches).map_err(|e| {
+                PyValueError::new_err(format!("failed to combine `updates` 
batches: {e}"))
+            })?
+        };
+        let result = py.detach(|| {
+            rt().block_on(self.inner.update(predicate, updates))
+                .map_err(PythonError::from)
+        })?;
+        Ok(HudiWriteResult::from(result))
+    }
+
+    fn delete(&mut self, predicate: &str, py: Python) -> 
PyResult<HudiWriteResult> {
+        let result = py.detach(|| {
+            rt().block_on(self.inner.delete(predicate))
+                .map_err(PythonError::from)
+        })?;
+        Ok(HudiWriteResult::from(result))
+    }
+
+    fn delete_keys(&mut self, keys: &Bound<'_, PyAny>, py: Python) -> 
PyResult<HudiWriteResult> {
+        let keys = to_hoodie_keys(keys)?;
+        let result = py.detach(|| {
+            rt().block_on(self.inner.delete_keys(keys))
+                .map_err(PythonError::from)
+        })?;
+        Ok(HudiWriteResult::from(result))
+    }
+
+    fn overwrite(&mut self, data: &Bound<'_, PyAny>, py: Python) -> 
PyResult<HudiWriteResult> {
+        let batches = to_batches(data)?;
+        let result = py.detach(|| {
+            rt().block_on(self.inner.overwrite(batches))
+                .map_err(PythonError::from)
+        })?;
+        Ok(HudiWriteResult::from(result))
+    }
+
+    fn dynamic_partition_overwrite(
+        &mut self,
+        data: &Bound<'_, PyAny>,
+        py: Python,
+    ) -> PyResult<HudiWriteResult> {
+        let batches = to_batches(data)?;
+        let result = py.detach(|| {
+            rt().block_on(self.inner.dynamic_partition_overwrite(batches))
+                .map_err(PythonError::from)
+        })?;
+        Ok(HudiWriteResult::from(result))
+    }
+}
+
+/// Result of a table write, shared by every write verb.
+#[cfg(not(tarpaulin_include))]
+#[pyclass(frozen, get_all)]
+pub struct HudiWriteResult {
+    instant_time: Option<String>,
+    num_writes: usize,
+    num_updates: usize,
+    num_inserts: usize,
+    num_deletes: usize,
+}
+
+#[cfg(not(tarpaulin_include))]
+#[pymethods]
+impl HudiWriteResult {
+    fn __repr__(&self) -> String {
+        let instant_time = self
+            .instant_time
+            .as_ref()
+            .map_or_else(|| "None".to_string(), |t| format!("'{t}'"));
+        format!(
+            "HudiWriteResult(instant_time={instant_time}, num_writes={}, 
num_updates={}, num_inserts={}, num_deletes={})",
+            self.num_writes, self.num_updates, self.num_inserts, 
self.num_deletes,
+        )
+    }
+}
+
+impl From<WriteResult> for HudiWriteResult {
+    fn from(r: WriteResult) -> Self {
+        Self {
+            instant_time: committed(r.instant),
+            num_writes: r.num_writes,
+            num_updates: r.num_updates,
+            num_inserts: r.num_inserts,
+            num_deletes: r.num_deletes,
+        }
+    }
+}
+
+impl From<AppendResult> for HudiWriteResult {
+    fn from(r: AppendResult) -> Self {
+        Self {
+            instant_time: committed(r.instant),
+            num_writes: r.num_rows,
+            num_updates: 0,
+            num_inserts: r.num_rows,
+            num_deletes: 0,
+        }
+    }
+}
+
+/// The core reports a write that made no commit with an empty instant.
+fn committed(instant: String) -> Option<String> {
+    (!instant.is_empty()).then_some(instant)
+}
+
+/// A Python argument that is either one string or a list of strings.
+#[cfg(not(tarpaulin_include))]
+#[derive(FromPyObject)]
+enum OneOrMany {
+    One(String),
+    Many(Vec<String>),
+}
+
+#[cfg(not(tarpaulin_include))]
+impl OneOrMany {
+    fn into_vec(self) -> Vec<String> {
+        match self {
+            OneOrMany::One(s) => vec![s],
+            OneOrMany::Many(v) => v,
+        }
+    }
+}
+
+fn last_path_segment(base_uri: &str) -> String {
+    base_uri
+        .trim_end_matches('/')
+        .rsplit('/')
+        .next()
+        .unwrap_or_default()
+        .to_string()
+}
+
+/// Convert any supported Python tabular object into record batches.
+///
+/// Objects implementing the Arrow PyCapsule interface (`__arrow_c_stream__`,
+/// `__arrow_c_array__`) are imported zero-copy, which covers pyarrow, polars
+/// and pandas >= 2.2. The pyarrow class checks serve pyarrow releases that
+/// predate the PyCapsule interface.
+#[cfg(not(tarpaulin_include))]
+fn to_batches(data: &Bound<'_, PyAny>) -> PyResult<Vec<RecordBatch>> {
+    collect_batches(data)?
+        .into_iter()
+        .map(|batch| normalize_batch(batch).map_err(PyValueError::new_err))
+        .collect()
+}
+
+/// Rewrite a batch into types the writer accepts: drop the columns pandas
+/// adds for an unnamed index, and cast view and large string, binary and
+/// list types (Polars exports `Utf8View`, pandas exports `LargeUtf8`) and
+/// dictionaries to their plain forms at any nesting depth.
+///
+/// Errors are plain strings so the function runs without an interpreter.
+fn normalize_batch(batch: RecordBatch) -> Result<RecordBatch, String> {
+    let schema = batch.schema();
+    let from_pandas = schema.metadata().contains_key("pandas");
+    let mut changed = false;
+    let mut fields = Vec::with_capacity(batch.num_columns());
+    let mut columns = Vec::with_capacity(batch.num_columns());
+    for (field, column) in schema.fields().iter().zip(batch.columns()) {
+        if from_pandas && is_unnamed_pandas_index(field.name()) {
+            changed = true;
+            continue;
+        }
+        match writable_field(field) {
+            Some(target) => {
+                // The cast to microseconds truncates silently.
+                if has_sub_microsecond(column.as_ref()) {
+                    return Err(format!(
+                        "column '{}' has timestamps with sub-microsecond 
precision, \
+                         which Hudi tables cannot store",
+                        field.name()
+                    ));
+                }
+                let casted = cast(column.as_ref(), 
target.data_type()).map_err(|e| {
+                    format!(
+                        "failed to cast column '{}' from {} to {}: {e}",
+                        field.name(),
+                        field.data_type(),
+                        target.data_type()
+                    )
+                })?;
+                fields.push(target);
+                columns.push(casted);
+                changed = true;
+            }
+            None => {
+                fields.push(field.clone());
+                columns.push(column.clone());
+            }
+        }
+    }
+    if !changed {
+        return Ok(batch);
+    }
+    let options = 
RecordBatchOptions::new().with_row_count(Some(batch.num_rows()));
+    RecordBatch::try_new_with_options(
+        Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone())),
+        columns,
+        &options,
+    )
+    .map_err(|e| format!("failed to rebuild record batch: {e}"))
+}
+
+/// Whether a nanosecond timestamp anywhere in `array` is not a whole 
microsecond.
+///
+/// Only values the array references are checked: a sliced list keeps its
+/// whole child buffer and a dictionary may hold unused values.
+fn has_sub_microsecond(array: &dyn Array) -> bool {
+    match array.data_type() {
+        DataType::Timestamp(TimeUnit::Nanosecond, _) => array
+            .as_primitive::<TimestampNanosecondType>()
+            .iter()
+            .flatten()
+            .any(|v| v % 1000 != 0),
+        DataType::List(_) => {
+            
has_sub_microsecond(referenced_values(array.as_list::<i32>()).as_ref())
+        }
+        DataType::LargeList(_) => {
+            
has_sub_microsecond(referenced_values(array.as_list::<i64>()).as_ref())
+        }
+        DataType::Struct(_) => array
+            .as_struct()
+            .columns()
+            .iter()
+            .any(|c| has_sub_microsecond(c.as_ref())),
+        DataType::Dictionary(_, value) => {
+            cast(array, value).is_ok_and(|plain| 
has_sub_microsecond(plain.as_ref()))
+        }
+        _ => false,
+    }
+}
+
+fn referenced_values<O: OffsetSizeTrait>(list: &GenericListArray<O>) -> 
ArrayRef {
+    let offsets = list.value_offsets();
+    let start = offsets[0].as_usize();
+    let end = offsets[offsets.len() - 1].as_usize();
+    list.values().slice(start, end - start)
+}
+
+/// pandas exports an unnamed index as `__index_level_N__`; a named index
+/// keeps its name and may be the record key.
+fn is_unnamed_pandas_index(name: &str) -> bool {
+    name.strip_prefix("__index_level_")
+        .and_then(|rest| rest.strip_suffix("__"))
+        .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| 
b.is_ascii_digit()))
+}
+
+/// The writable form of `field`, or `None` when it is already writable.
+/// Names, nullability and metadata are kept, including list item names.
+fn writable_field(field: &FieldRef) -> Option<FieldRef> {
+    writable_type(field.data_type()).map(|dt| 
Arc::new(field.as_ref().clone().with_data_type(dt)))
+}
+
+fn writable_type(dt: &DataType) -> Option<DataType> {
+    match dt {
+        DataType::Utf8View | DataType::LargeUtf8 => Some(DataType::Utf8),
+        DataType::BinaryView | DataType::LargeBinary => Some(DataType::Binary),
+        DataType::Timestamp(TimeUnit::Nanosecond, tz) => {
+            Some(DataType::Timestamp(TimeUnit::Microsecond, tz.clone()))
+        }
+        DataType::Dictionary(_, value) => {
+            Some(writable_type(value).unwrap_or_else(|| 
value.as_ref().clone()))
+        }
+        DataType::List(item) => writable_field(item).map(DataType::List),
+        DataType::LargeList(item) => Some(DataType::List(
+            writable_field(item).unwrap_or_else(|| item.clone()),
+        )),
+        DataType::Struct(children) => {
+            let mapped: Vec<Option<FieldRef>> = 
children.iter().map(writable_field).collect();
+            if mapped.iter().all(Option::is_none) {
+                return None;
+            }
+            Some(DataType::Struct(
+                children
+                    .iter()
+                    .zip(mapped)
+                    .map(|(child, m)| m.unwrap_or_else(|| child.clone()))
+                    .collect(),
+            ))
+        }
+        _ => None,
+    }
+}
+
+#[cfg(not(tarpaulin_include))]
+fn collect_batches(data: &Bound<'_, PyAny>) -> PyResult<Vec<RecordBatch>> {
+    let py = data.py();
+    if let Ok(list) = data.cast::<PyList>() {
+        return list
+            .iter()
+            .enumerate()
+            .map(|(i, item)| {
+                RecordBatch::from_pyarrow_bound(&item).map_err(|e| {
+                    PyTypeError::new_err(format!(
+                        "data[{i}] is not a pyarrow.RecordBatch or an Arrow 
array-capsule object: {e}"
+                    ))
+                })
+            })
+            .collect();
+    }
+    if data.hasattr(intern!(py, "__arrow_c_stream__"))? {
+        return collect_stream(data);
+    }
+    if data.hasattr(intern!(py, "__arrow_c_array__"))? {
+        return Ok(vec![RecordBatch::from_pyarrow_bound(data)?]);
+    }
+    let pa = py.import(intern!(py, "pyarrow"))?;
+    if data.is_instance(&pa.getattr(intern!(py, "RecordBatchReader"))?)? {
+        return collect_stream(data);
+    }
+    if data.is_instance(&pa.getattr(intern!(py, "RecordBatch"))?)? {
+        return Ok(vec![RecordBatch::from_pyarrow_bound(data)?]);
+    }
+    if data.is_instance(&pa.getattr(intern!(py, "Table"))?)? {
+        return collect_batches(&data.call_method0(intern!(py, "to_batches"))?);
+    }
+    Err(PyTypeError::new_err(format!(
+        "unsupported data type '{}': expected a pyarrow Table, RecordBatch, 
RecordBatchReader, \
+         a list of RecordBatch, or an object implementing __arrow_c_stream__",
+        data.get_type().name()?
+    )))
+}
+
+#[cfg(not(tarpaulin_include))]
+fn collect_stream(data: &Bound<'_, PyAny>) -> PyResult<Vec<RecordBatch>> {
+    ArrowArrayStreamReader::from_pyarrow_bound(data)?
+        .collect::<Result<Vec<_>, _>>()
+        .map_err(|e| PyValueError::new_err(format!("failed to read Arrow 
stream: {e}")))
+}
+
+/// Build the one-row `SET` batch for `update`, typing each value by the
+/// table schema so that e.g. a Python int lands in a float64 column.
+#[cfg(not(tarpaulin_include))]
+fn one_row_batch_from_dict(
+    py: Python,
+    schema: &Schema,
+    updates: &Bound<'_, PyDict>,
+) -> PyResult<RecordBatch> {
+    if updates.is_empty() {
+        return Err(PyValueError::new_err(
+            "`updates` must name at least one column",
+        ));
+    }
+    let pa = py.import(intern!(py, "pyarrow"))?;
+    let mut fields = Vec::with_capacity(updates.len());
+    let mut columns = Vec::with_capacity(updates.len());
+    for (key, value) in updates.iter() {
+        let name: String = key.extract().map_err(|_| {
+            PyValueError::new_err(format!(
+                "update column names must be str, got {}",
+                key.repr().map(|r| r.to_string()).unwrap_or_default()
+            ))
+        })?;
+        let field = schema.field_with_name(&name).map_err(|_| {
+            PyValueError::new_err(format!("update column '{name}' is not in 
the table schema"))
+        })?;
+        let value = unwrap_scalars(&value, &pa.getattr(intern!(py, 
"Scalar"))?)?;
+        let kwargs = PyDict::new(py);
+        kwargs.set_item(intern!(py, "type"), 
field.data_type().to_pyarrow(py)?)?;
+        let array = pa
+            .call_method(
+                intern!(py, "array"),
+                (PyList::new(py, [&value])?,),
+                Some(&kwargs),
+            )
+            .map_err(|e| {
+                PyValueError::new_err(format!(
+                    "cannot convert update value for column '{name}' to {}: 
{e}",
+                    field.data_type()
+                ))
+            })?;
+        let stored = array
+            .get_item(0)
+            .and_then(|v| v.call_method0(intern!(py, "as_py")))
+            .map_err(|e| {
+                PyValueError::new_err(format!(
+                    "cannot read back update value for column '{name}' as {}: 
{e}",
+                    field.data_type()
+                ))
+            })?;
+        if let Some(reason) = misfit(field.data_type(), &value, &stored)? {
+            return Err(PyValueError::new_err(format!(
+                "update value {} for column '{name}' does not fit {} exactly: 
{reason} \
+                 (stored as {})",
+                value.repr()?,
+                field.data_type(),
+                stored.repr()?
+            )));
+        }
+        columns.push(make_array(ArrayData::from_pyarrow_bound(&array)?));
+        fields.push(field.clone());
+    }
+    RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
+        .map_err(|e| PyValueError::new_err(format!("invalid `updates`: {e}")))
+}
+
+/// `value` with every `pyarrow.Scalar`, at any depth of dicts, lists and
+/// tuples, replaced by its `as_py()`.
+#[cfg(not(tarpaulin_include))]
+fn unwrap_scalars<'py>(
+    value: &Bound<'py, PyAny>,
+    scalar: &Bound<'py, PyAny>,
+) -> PyResult<Bound<'py, PyAny>> {
+    let py = value.py();
+    if value.is_instance(scalar)? {
+        return value.call_method0(intern!(py, "as_py"));
+    }
+    if let Ok(dict) = value.cast::<PyDict>() {
+        let out = PyDict::new(py);
+        for (k, v) in dict.iter() {
+            out.set_item(k, unwrap_scalars(&v, scalar)?)?;
+        }
+        return Ok(out.into_any());
+    }
+    let is_tuple = value.is_instance_of::<PyTuple>();
+    if is_tuple || value.is_instance_of::<PyList>() {
+        let items = value
+            .try_iter()?
+            .map(|v| unwrap_scalars(&v?, scalar))
+            .collect::<PyResult<Vec<_>>>()?;
+        return Ok(if is_tuple {
+            PyTuple::new(py, items)?.into_any()
+        } else {
+            PyList::new(py, items)?.into_any()
+        });
+    }
+    Ok(value.clone())
+}
+
+/// Why `value` does not survive the typed `pa.array` constructor for `dt`,
+/// given `stored`, the `as_py()` of what it produced; `None` when it fits.
+///
+/// The constructor coerces silently: 1.5 into int64 stores 1, a numpy bool
+/// into int64 stores 1, a datetime into date32 drops the time, a number into
+/// a timestamp is read as epoch units, a coarser timestamp unit drops
+/// sub-unit digits, and unknown struct keys are dropped. Python equality
+/// accepts exact conversions (3 == 3.0);
+/// timestamps are not compared whole because a naive input never equals its
+/// UTC-aware stored form.
+#[cfg(not(tarpaulin_include))]
+fn misfit(
+    dt: &DataType,
+    value: &Bound<'_, PyAny>,
+    stored: &Bound<'_, PyAny>,
+) -> PyResult<Option<String>> {
+    if value.is_none() || stored.is_none() {
+        return Ok(None);
+    }
+    let py = value.py();
+    match dt {
+        DataType::Struct(children) => {
+            let by_name = value.cast::<PyDict>().ok();
+            if let Some(d) = &by_name {
+                for key in d.keys() {
+                    let known = key
+                        .extract::<String>()
+                        .is_ok_and(|k| children.iter().any(|c| c.name() == 
&k));
+                    if !known {
+                        return Ok(Some(format!("unknown struct field {}", 
key.repr()?)));
+                    }
+                }
+            }
+            let mut positional = match by_name {
+                Some(_) => None,
+                None => Some(value.try_iter()?),
+            };
+            for child in children {
+                let v = match (&by_name, positional.as_mut()) {
+                    (Some(d), _) => d.get_item(child.name())?,
+                    (None, Some(it)) => it.next().transpose()?,
+                    (None, None) => None,
+                };
+                if let Some(v) = v {
+                    let s = stored.get_item(child.name())?;
+                    if let Some(reason) = misfit(child.data_type(), &v, &s)? {
+                        return Ok(Some(reason));
+                    }
+                }
+            }
+            Ok(None)
+        }
+        DataType::List(item) | DataType::LargeList(item) => {
+            for (v, s) in value.try_iter()?.zip(stored.try_iter()?) {
+                if let Some(reason) = misfit(item.data_type(), &v?, &s?)? {
+                    return Ok(Some(reason));
+                }
+            }
+            Ok(None)
+        }
+        dt if dt.is_numeric() && is_bool(value)? => Ok(Some("a bool is not a 
number".into())),
+        DataType::Float16 | DataType::Float32 => {
+            let (v, s): (f64, f64) = (value.extract()?, stored.extract()?);
+            Ok((v.is_finite() && !s.is_finite())
+                .then(|| "the value overflows the float type".into()))
+        }
+        dt if dt.is_numeric() => {
+            let both_nan = stored.ne(stored)? && value.ne(value)?;
+            Ok((!(stored.eq(value)? || both_nan)).then(|| "the value would 
change".into()))
+        }
+        DataType::Date32 | DataType::Date64 => {
+            let datetime = py.import(intern!(py, "datetime"))?;
+            let is_date = value.is_instance(&datetime.getattr(intern!(py, 
"date"))?)?;
+            let is_datetime = value.is_instance(&datetime.getattr(intern!(py, 
"datetime"))?)?;
+            Ok((!is_date || is_datetime).then(|| "expected a 
datetime.date".into()))
+        }
+        DataType::Timestamp(unit, tz) => {
+            let datetime = py
+                .import(intern!(py, "datetime"))?
+                .getattr(intern!(py, "datetime"))?;
+            if !value.is_instance(&datetime)? {
+                return Ok(Some("expected a datetime.datetime".into()));
+            }
+            if tz.is_none() && !value.getattr(intern!(py, 
"tzinfo"))?.is_none() {
+                return Ok(Some(
+                    "a tz-aware datetime is ambiguous for a timestamp without 
a time zone".into(),
+                ));
+            }
+            let nanos = intern!(py, "nanosecond");
+            let micros = intern!(py, "microsecond");
+            let truncated = (*unit != TimeUnit::Nanosecond
+                && value.hasattr(nanos)?
+                && !value.getattr(nanos)?.eq(0)?)
+                || !stored.getattr(micros)?.eq(value.getattr(micros)?)?;
+            Ok(truncated.then(|| "the timestamp unit would truncate the 
value".into()))
+        }
+        _ => Ok(None),
+    }
+}
+
+#[cfg(not(tarpaulin_include))]
+fn is_bool(value: &Bound<'_, PyAny>) -> PyResult<bool> {
+    if value.is_instance_of::<PyBool>() {
+        return Ok(true);
+    }
+    let ty = value.get_type();
+    let module: String = ty.getattr(intern!(value.py(), 
"__module__"))?.extract()?;
+    let name = ty.name()?;
+    Ok(module == "numpy" && (name == "bool" || name == "bool_"))
+}
+
+/// Accept record keys as `str` (empty partition path) or `(record_key, 
partition_path)`.
+#[cfg(not(tarpaulin_include))]
+fn to_hoodie_keys(keys: &Bound<'_, PyAny>) -> PyResult<Vec<HoodieKey>> {
+    if keys.is_instance_of::<PyString>() {
+        return Err(PyTypeError::new_err(
+            "keys must be a list of record keys, not a single str",
+        ));
+    }
+    keys.try_iter()?
+        .map(|item| {
+            let item = item?;
+            if let Ok(record_key) = item.extract::<String>() {
+                return Ok(HoodieKey {
+                    record_key,
+                    partition_path: String::new(),
+                });
+            }
+            let (record_key, partition_path) =
+                item.extract::<(String, String)>().map_err(|_| {
+                    PyTypeError::new_err(format!(
+                        "each key must be a str or a (record_key, 
partition_path) tuple, got {}",
+                        item.get_type()
+                            .name()
+                            .map(|n| n.to_string())
+                            .unwrap_or_default()
+                    ))
+                })?;
+            Ok(HoodieKey {
+                record_key,
+                partition_path,
+            })
+        })
+        .collect()
 }
 
 #[cfg(not(tarpaulin_include))]
@@ -1030,7 +1742,329 @@ pub fn rt() -> &'static Runtime {
 
 #[cfg(test)]
 mod tests {
-    use super::pascal_to_screaming_snake;
+    use super::{
+        HudiWriteResult, has_sub_microsecond, is_unnamed_pandas_index, 
last_path_segment,
+        normalize_batch, pascal_to_screaming_snake, writable_field, 
writable_type,
+    };
+    use arrow::array::{
+        Array, ArrayRef, DictionaryArray, Int8Array, Int64Array, 
LargeListArray, LargeStringArray,
+        ListArray, StringArray, StructArray, TimestampMicrosecondArray, 
TimestampNanosecondArray,
+    };
+    use arrow::buffer::OffsetBuffer;
+    use arrow::datatypes::{DataType, Field, Fields, Schema, TimeUnit};
+    use arrow::record_batch::RecordBatch;
+    use hudi::WriteResult;
+    use hudi::table::AppendResult;
+    use std::collections::HashMap;
+    use std::sync::Arc;
+
+    #[test]
+    fn writable_field_rewrites_nested_types_and_keeps_names_and_metadata() {
+        let metadata = HashMap::from([("k".to_string(), "v".to_string())]);
+        let item =
+            Arc::new(Field::new("item", DataType::Utf8View, 
true).with_metadata(metadata.clone()));
+        let field = Arc::new(
+            Field::new(
+                "f",
+                DataType::Struct(Fields::from(vec![
+                    Field::new("tags", DataType::LargeList(item), true),
+                    Field::new(
+                        "kind",
+                        DataType::Dictionary(
+                            Box::new(DataType::Int8),
+                            Box::new(DataType::LargeUtf8),
+                        ),
+                        true,
+                    ),
+                    Field::new("n", DataType::Int64, true),
+                ])),
+                false,
+            )
+            .with_metadata(metadata.clone()),
+        );
+
+        let written = writable_field(&field).unwrap();
+        assert_eq!(written.name(), "f");
+        assert!(!written.is_nullable());
+        assert_eq!(written.metadata(), &metadata);
+        let DataType::Struct(children) = written.data_type() else {
+            panic!("expected struct, got {}", written.data_type());
+        };
+        let DataType::List(item) = children[0].data_type() else {
+            panic!("expected list, got {}", children[0].data_type());
+        };
+        assert_eq!(item.name(), "item");
+        assert_eq!(item.data_type(), &DataType::Utf8);
+        assert_eq!(item.metadata(), &metadata);
+        assert_eq!(children[1].data_type(), &DataType::Utf8);
+        assert_eq!(children[2].data_type(), &DataType::Int64);
+
+        let plain = Arc::new(Field::new(
+            "n",
+            DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))),
+            true,
+        ));
+        assert!(writable_field(&plain).is_none());
+    }
+
+    #[test]
+    fn unnamed_pandas_index_detection() {
+        assert!(is_unnamed_pandas_index("__index_level_0__"));
+        assert!(is_unnamed_pandas_index("__index_level_12__"));
+        assert!(!is_unnamed_pandas_index("__index_level___"));
+        assert!(!is_unnamed_pandas_index("__index_level_x__"));
+        assert!(!is_unnamed_pandas_index("id"));
+    }
+
+    #[test]
+    fn writable_type_maps_leaf_types() {
+        let utc: Option<Arc<str>> = Some("UTC".into());
+        for (from, to) in [
+            (DataType::LargeBinary, Some(DataType::Binary)),
+            (DataType::BinaryView, Some(DataType::Binary)),
+            (
+                DataType::Timestamp(TimeUnit::Nanosecond, utc.clone()),
+                Some(DataType::Timestamp(TimeUnit::Microsecond, utc.clone())),
+            ),
+            (
+                DataType::Dictionary(Box::new(DataType::Int8), 
Box::new(DataType::Int64)),
+                Some(DataType::Int64),
+            ),
+            (DataType::Timestamp(TimeUnit::Microsecond, utc), None),
+            (DataType::Binary, None),
+            (DataType::Int64, None),
+            (
+                DataType::Struct(Fields::from(vec![Field::new("n", 
DataType::Int64, true)])),
+                None,
+            ),
+        ] {
+            assert_eq!(writable_type(&from), to, "for {from}");
+        }
+
+        // A large list narrows to a list even when its item is already 
writable.
+        let item = Arc::new(Field::new("element", DataType::Int64, false));
+        assert_eq!(
+            writable_type(&DataType::LargeList(item.clone())),
+            Some(DataType::List(item))
+        );
+    }
+
+    fn nanos(values: Vec<Option<i64>>) -> TimestampNanosecondArray {
+        TimestampNanosecondArray::from(values)
+    }
+
+    fn list_of(values: TimestampNanosecondArray) -> ArrayRef {
+        let field = Arc::new(Field::new("item", values.data_type().clone(), 
true));
+        let offsets = OffsetBuffer::from_lengths([values.len()]);
+        Arc::new(ListArray::new(field, offsets, Arc::new(values), None))
+    }
+
+    fn large_list_of(values: TimestampNanosecondArray) -> ArrayRef {
+        let field = Arc::new(Field::new("item", values.data_type().clone(), 
true));
+        let offsets = OffsetBuffer::from_lengths([values.len()]);
+        Arc::new(LargeListArray::new(field, offsets, Arc::new(values), None))
+    }
+
+    fn struct_of(values: TimestampNanosecondArray) -> ArrayRef {
+        let field = Arc::new(Field::new("ts", values.data_type().clone(), 
true));
+        Arc::new(StructArray::from(vec![(
+            field,
+            Arc::new(values) as ArrayRef,
+        )]))
+    }
+
+    fn dictionary_of(values: TimestampNanosecondArray) -> ArrayRef {
+        let keys = Int8Array::from_iter_values(0..values.len() as i8);
+        Arc::new(DictionaryArray::new(keys, Arc::new(values)))
+    }
+
+    #[test]
+    fn has_sub_microsecond_checks_values_at_any_depth() {
+        let whole = || nanos(vec![Some(2_000), None]);
+        let partial = || nanos(vec![Some(2_000), Some(2_001)]);
+        assert!(!has_sub_microsecond(&whole()));
+        assert!(has_sub_microsecond(&partial()));
+        for wrap in [list_of, large_list_of, struct_of, dictionary_of] {
+            assert!(!has_sub_microsecond(wrap(whole()).as_ref()));
+            assert!(has_sub_microsecond(wrap(partial()).as_ref()));
+        }
+        // Values outside a slice and unused dictionary values are ignored.
+        let lists = ListArray::new(
+            Arc::new(Field::new(
+                "item",
+                DataType::Timestamp(TimeUnit::Nanosecond, None),
+                true,
+            )),
+            OffsetBuffer::from_lengths([1, 1]),
+            Arc::new(nanos(vec![Some(1), Some(2_000)])),
+            None,
+        );
+        assert!(!has_sub_microsecond(&lists.slice(1, 1)));
+        assert!(has_sub_microsecond(&lists.slice(0, 1)));
+        let unused = DictionaryArray::new(
+            Int8Array::from(vec![1]),
+            Arc::new(nanos(vec![Some(1), Some(2_000)])),
+        );
+        assert!(!has_sub_microsecond(&unused));
+        // Only nanosecond timestamps can hold sub-microsecond digits.
+        assert!(!has_sub_microsecond(&TimestampMicrosecondArray::from(
+            vec![1]
+        )));
+        assert!(!has_sub_microsecond(&Int64Array::from(vec![1])));
+    }
+
+    fn batch(columns: Vec<(&str, ArrayRef)>, pandas: bool) -> RecordBatch {
+        let fields: Vec<Field> = columns
+            .iter()
+            .map(|(name, c)| Field::new(*name, c.data_type().clone(), true))
+            .collect();
+        let metadata = if pandas {
+            HashMap::from([("pandas".to_string(), "{}".to_string())])
+        } else {
+            HashMap::new()
+        };
+        let schema = Arc::new(Schema::new_with_metadata(fields, metadata));
+        RecordBatch::try_new(schema, columns.into_iter().map(|(_, c)| 
c).collect()).unwrap()
+    }
+
+    #[test]
+    fn normalize_batch_returns_a_writable_batch_unchanged() {
+        let input = batch(
+            vec![
+                ("id", Arc::new(StringArray::from(vec!["a"]))),
+                ("__index_level_0__", Arc::new(Int64Array::from(vec![0]))),
+            ],
+            false,
+        );
+        let output = normalize_batch(input.clone()).unwrap();
+        assert!(Arc::ptr_eq(&input.schema(), &output.schema()));
+    }
+
+    #[test]
+    fn normalize_batch_drops_pandas_index_and_casts_columns() {
+        let input = batch(
+            vec![
+                ("__index_level_0__", Arc::new(Int64Array::from(vec![5]))),
+                ("id", Arc::new(LargeStringArray::from(vec!["a"]))),
+                ("ts", list_of(nanos(vec![Some(3_000), None]))),
+            ],
+            true,
+        );
+        let output = normalize_batch(input.clone()).unwrap();
+        assert_eq!(output.schema().metadata(), input.schema().metadata());
+        let names: Vec<_> = output
+            .schema()
+            .fields()
+            .iter()
+            .map(|f| f.name().clone())
+            .collect();
+        assert_eq!(names, ["id", "ts"]);
+        assert_eq!(output.column(0).data_type(), &DataType::Utf8);
+        let DataType::List(item) = output.column(1).data_type() else {
+            panic!("expected list, got {}", output.column(1).data_type());
+        };
+        assert_eq!(
+            item.data_type(),
+            &DataType::Timestamp(TimeUnit::Microsecond, None)
+        );
+        let values = output
+            .column(1)
+            .as_any()
+            .downcast_ref::<ListArray>()
+            .unwrap();
+        let values = values
+            .values()
+            .as_any()
+            .downcast_ref::<TimestampMicrosecondArray>()
+            .unwrap();
+        assert_eq!(values.iter().collect::<Vec<_>>(), [Some(3), None]);
+    }
+
+    #[test]
+    fn normalize_batch_keeps_row_count_when_only_the_index_was_present() {
+        let input = batch(
+            vec![(
+                "__index_level_0__",
+                Arc::new(Int64Array::from(vec![0, 1, 2])),
+            )],
+            true,
+        );
+        let output = normalize_batch(input).unwrap();
+        assert_eq!((output.num_columns(), output.num_rows()), (0, 3));
+    }
+
+    #[test]
+    fn normalize_batch_rejects_sub_microsecond_timestamps() {
+        for column in [
+            Arc::new(nanos(vec![Some(1)])) as ArrayRef,
+            struct_of(nanos(vec![Some(1_000_001)])),
+        ] {
+            let err = normalize_batch(batch(vec![("ts", column)], 
false)).unwrap_err();
+            assert!(
+                err.contains("column 'ts' has timestamps with 
sub-microsecond"),
+                "{err}"
+            );
+        }
+    }
+
+    #[test]
+    fn write_results_convert_to_python_counts() {
+        let rewrite = HudiWriteResult::from(WriteResult {
+            instant: "20240101000000000".to_string(),
+            completion_time: None,
+            num_writes: 4,
+            num_updates: 1,
+            num_inserts: 2,
+            num_deletes: 3,
+        });
+        assert_eq!(rewrite.instant_time.as_deref(), Some("20240101000000000"));
+        assert_eq!(
+            (
+                rewrite.num_writes,
+                rewrite.num_updates,
+                rewrite.num_inserts,
+                rewrite.num_deletes
+            ),
+            (4, 1, 2, 3)
+        );
+
+        let append = HudiWriteResult::from(AppendResult {
+            instant: "20240101000000001".to_string(),
+            completion_time: None,
+            commit_relative_path: String::new(),
+            base_file_path: String::new(),
+            num_rows: 5,
+        });
+        assert_eq!(append.instant_time.as_deref(), Some("20240101000000001"));
+        assert_eq!(
+            (
+                append.num_writes,
+                append.num_updates,
+                append.num_inserts,
+                append.num_deletes
+            ),
+            (5, 0, 5, 0)
+        );
+
+        // A write that matched nothing reports no instant.
+        let noop = HudiWriteResult::from(WriteResult {
+            instant: String::new(),
+            completion_time: None,
+            num_writes: 0,
+            num_updates: 0,
+            num_inserts: 0,
+            num_deletes: 0,
+        });
+        assert_eq!(noop.instant_time, None);
+    }
+
+    #[test]
+    fn last_path_segment_names_the_table_directory() {
+        assert_eq!(last_path_segment("/data/trips"), "trips");
+        assert_eq!(last_path_segment("file:///data/trips//"), "trips");
+        assert_eq!(last_path_segment("s3://bucket/warehouse/trips"), "trips");
+        assert_eq!(last_path_segment("trips"), "trips");
+    }
 
     #[test]
     fn pascal_to_screaming_snake_basic() {
diff --git a/python/src/lib.rs b/python/src/lib.rs
index 6308ff37..aa796387 100644
--- a/python/src/lib.rs
+++ b/python/src/lib.rs
@@ -33,7 +33,7 @@ fn _internal(_py: Python, m: &Bound<'_, PyModule>) -> 
PyResult<()> {
 
     use internal::{
         HudiFileGroupReader, HudiFileSlice, HudiInstant, HudiQueryType, 
HudiReadOptions,
-        HudiRecordBatchStream, HudiTable, HudiTimeline,
+        HudiRecordBatchStream, HudiTable, HudiTimeline, HudiWriteResult,
     };
     m.add_class::<HudiFileGroupReader>()?;
     m.add_class::<HudiFileSlice>()?;
@@ -43,6 +43,7 @@ fn _internal(_py: Python, m: &Bound<'_, PyModule>) -> 
PyResult<()> {
     m.add_class::<HudiRecordBatchStream>()?;
     m.add_class::<HudiTable>()?;
     m.add_class::<HudiTimeline>()?;
+    m.add_class::<HudiWriteResult>()?;
 
     m.add_function(wrap_pyfunction!(internal::_config_keys, m)?)?;
 
diff --git a/python/tests/test_table_write.py b/python/tests/test_table_write.py
new file mode 100644
index 00000000..64da9426
--- /dev/null
+++ b/python/tests/test_table_write.py
@@ -0,0 +1,774 @@
+#  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.
+
+"""Tests for the HudiTable write API."""
+
+import datetime
+import math
+from decimal import Decimal
+
+import pyarrow as pa
+import pytest
+
+from hudi import HudiTable, HudiWriteResult
+
+SCHEMA = pa.schema(
+    [
+        ("id", pa.string()),
+        ("city", pa.string()),
+        ("ts", pa.int64()),
+        ("fare", pa.float64()),
+    ]
+)
+
+
+def trips(*rows):
+    ids, cities, ts, fares = zip(*rows)
+    return pa.table({"id": ids, "city": cities, "ts": ts, "fare": fares}, 
schema=SCHEMA)
+
+
+def snapshot(table):
+    """Map record key to (city, ts, fare) for the current snapshot."""
+    read = pa.Table.from_batches(table.read())
+    return {
+        r["id"]: (r["city"], r["ts"], r["fare"])
+        for r in read.select(["id", "city", "ts", "fare"]).to_pylist()
+    }
+
+
+def new_table(tmp_path, table_type="cow"):
+    table = HudiTable.create(
+        str(tmp_path / "trips"),
+        record_key="id",
+        partition_by="city",
+        ordering_fields="ts",
+        table_type=table_type,
+    )
+    table.append(
+        trips(("t1", "sf", 1, 30.0), ("t2", "sf", 1, 45.0), ("t3", "nyc", 1, 
20.0))
+    )
+    return table
+
+
+class StreamOnly:
+    """Exposes only the Arrow PyCapsule stream interface, like polars or 
pandas."""
+
+    def __init__(self, table):
+        self._table = table
+
+    def __arrow_c_stream__(self, requested_schema=None):
+        return self._table.__arrow_c_stream__(requested_schema)
+
+
+class ArrayOnly:
+    """Exposes only the Arrow PyCapsule array interface for one record 
batch."""
+
+    def __init__(self, batch):
+        self._batch = batch
+
+    def __arrow_c_array__(self, requested_schema=None):
+        return self._batch.__arrow_c_array__(requested_schema)
+
+
[email protected](
+    "table_type, expected_type",
+    [
+        ("cow", "COPY_ON_WRITE"),
+        ("Merge_On_Read", "MERGE_ON_READ"),
+    ],
+)
+def test_create_table_types(tmp_path, table_type, expected_type):
+    base = tmp_path / "trips"
+    table = HudiTable.create(str(base), record_key=["id"], 
table_type=table_type)
+    assert table.table_type == expected_type
+    assert table.is_mor == (expected_type == "MERGE_ON_READ")
+    assert table.table_name == "trips"
+    assert (base / ".hoodie" / "hoodie.properties").is_file()
+    assert (base / ".hoodie" / "metadata" / "record_index").is_dir()
+
+
+def test_create_with_explicit_options(tmp_path):
+    base = tmp_path / "plain"
+    table = HudiTable.create(
+        str(base) + "/",
+        record_key="id",
+        partition_by=["city"],
+        table_name="renamed",
+        table_version=8,
+        metadata_table=False,
+        hive_style_partitioning=False,
+        hudi_options={"hoodie.table.timeline.timezone": "UTC"},
+    )
+    assert table.table_name == "renamed"
+    assert table.hudi_options()["hoodie.table.version"] == "8"
+    assert not (base / ".hoodie" / "metadata").exists()
+    table.append(trips(("t1", "sf", 1, 30.0)))
+    assert (base / "sf").is_dir()
+
+
+def test_create_rejects_bad_table_type(tmp_path):
+    with pytest.raises(ValueError, match="invalid table_type 'parquet'"):
+        HudiTable.create(str(tmp_path / "t"), record_key="id", 
table_type="parquet")
+    assert not (tmp_path / "t").exists()
+
+
+def test_create_on_existing_table_raises(tmp_path):
+    HudiTable.create(str(tmp_path / "t"), record_key="id")
+    with pytest.raises(Exception, match="already exists"):
+        HudiTable.create(str(tmp_path / "t"), record_key="id")
+
+
[email protected](
+    "flags, error",
+    [
+        (
+            dict(metadata_table=False, record_index=True),
+            "record index requires the metadata",
+        ),
+        (
+            dict(metadata_table=False, column_stats=True),
+            "column_stats requires the metadata",
+        ),
+        # The explicit partition_stats choice is applied after column_stats,
+        # so it is reported rather than silently turned off.
+        (dict(column_stats=False, partition_stats=True), "partition_stats 
requires"),
+    ],
+)
+def test_create_rejects_inconsistent_index_options(tmp_path, flags, error):
+    with pytest.raises(Exception, match=error):
+        HudiTable.create(str(tmp_path / "t"), record_key="id", **flags)
+    assert not (tmp_path / "t").exists()
+
+
+def test_create_index_options(tmp_path):
+    base = tmp_path / "t"
+    table = HudiTable.create(
+        str(base),
+        record_key="id",
+        partition_by="city",
+        record_index=False,
+        column_stats=True,
+        partition_stats=False,
+    )
+    partitions = 
table.hudi_options()["hoodie.table.metadata.partitions"].split(",")
+    assert "column_stats" in partitions
+    assert "partition_stats" not in partitions
+    assert "record_index" not in partitions
+    # Without the record index, upserts tag records through the simple index.
+    table.append(trips(("t1", "sf", 1, 30.0)))
+    assert table.upsert(trips(("t1", "sf", 2, 31.0))).num_updates == 1
+    assert snapshot(table) == {"t1": ("sf", 2, 31.0)}
+
+
+def test_create_with_field_lists_and_storage_options(tmp_path):
+    table = HudiTable.create(
+        str(tmp_path / "t"),
+        record_key=["id"],
+        partition_by=["city", "ts"],
+        ordering_fields=["ts"],
+        storage_options={"allow_http": "true"},
+    )
+    options = table.hudi_options()
+    assert options["hoodie.table.recordkey.fields"] == "id"
+    assert options["hoodie.table.partition.fields"] == "city,ts"
+    assert options["hoodie.table.ordering.fields"] == "ts"
+    assert table.storage_options()["allow_http"] == "true"
+    table.append(trips(("t1", "sf", 1, 30.0)))
+    assert (tmp_path / "t" / "city=sf" / "ts=1").is_dir()
+
+
[email protected]("table_type", ["cow", "mor"])
+def test_append_then_read(tmp_path, table_type):
+    table = HudiTable.create(
+        str(tmp_path / "trips"),
+        record_key="id",
+        partition_by="city",
+        table_type=table_type,
+    )
+    res = table.append(trips(("t1", "sf", 1, 30.0), ("t2", "nyc", 1, 45.0)))
+    assert isinstance(res, HudiWriteResult)
+    assert res.instant_time
+    assert (res.num_writes, res.num_inserts, res.num_updates, res.num_deletes) 
== (
+        2,
+        2,
+        0,
+        0,
+    )
+    assert "num_inserts=2" in repr(res)
+    assert f"instant_time='{res.instant_time}'" in repr(res)
+    with pytest.raises(AttributeError):
+        res.num_inserts = 0
+    assert snapshot(table) == {"t1": ("sf", 1, 30.0), "t2": ("nyc", 1, 45.0)}
+    # A fresh handle sees the same committed data.
+    assert snapshot(HudiTable(str(tmp_path / "trips"))) == snapshot(table)
+
+
[email protected]("table_type", ["cow", "mor"])
+def test_upsert_updates_and_inserts(tmp_path, table_type):
+    table = new_table(tmp_path, table_type)
+    res = table.upsert(trips(("t2", "sf", 2, 50.0), ("t4", "nyc", 2, 15.0)))
+    assert (res.num_updates, res.num_inserts, res.num_deletes) == (1, 1, 0)
+    assert snapshot(table) == {
+        "t1": ("sf", 1, 30.0),
+        "t2": ("sf", 2, 50.0),
+        "t3": ("nyc", 1, 20.0),
+        "t4": ("nyc", 2, 15.0),
+    }
+
+
+def test_upsert_keeps_higher_ordering_value(tmp_path):
+    table = new_table(tmp_path)
+    table.upsert(trips(("t1", "sf", 0, 99.0)))
+    assert snapshot(table)["t1"] == ("sf", 1, 30.0)
+
+
+def test_upsert_update_columns(tmp_path):
+    table = new_table(tmp_path)
+    res = table.upsert(trips(("t1", "sf", 5, 31.0)), update_columns=["fare"])
+    assert res.num_updates == 1
+    assert snapshot(table)["t1"] == ("sf", 1, 31.0)
+
+
[email protected]("table_type", ["cow", "mor"])
+def test_update_with_dict(tmp_path, table_type):
+    table = new_table(tmp_path, table_type)
+    # An int value lands in the float64 column.
+    res = table.update(predicate="city = 'nyc'", updates={"fare": 99})
+    assert res.num_updates == 1
+    assert snapshot(table)["t3"] == ("nyc", 1, 99.0)
+    assert snapshot(table)["t1"] == ("sf", 1, 30.0)
+
+
[email protected]("table_type", ["cow", "mor"])
+def test_update_with_batch(tmp_path, table_type):
+    table = new_table(tmp_path, table_type)
+    set_batch = pa.record_batch(
+        {"fare": pa.array([7.5]), "ts": pa.array([3], pa.int64())}
+    )
+    table.update("id = 't1'", set_batch)
+    assert snapshot(table)["t1"] == ("sf", 3, 7.5)
+    table.update("id = 't2'", pa.table({"fare": [8.5]}))
+    assert snapshot(table)["t2"] == ("sf", 1, 8.5)
+    table.update("id = 't3'", [pa.record_batch({"fare": [9.5]})])
+    assert snapshot(table)["t3"] == ("nyc", 1, 9.5)
+
+
+def test_update_set_batches_must_hold_one_row(tmp_path):
+    table = new_table(tmp_path)
+    with pytest.raises(ValueError, match="got no batches"):
+        table.update("id = 't1'", [])
+    two = [pa.record_batch({"fare": [1.0]}), pa.record_batch({"fare": [2.0]})]
+    with pytest.raises(Exception, match="single-row"):
+        table.update("id = 't1'", two)
+    assert snapshot(table)["t1"] == ("sf", 1, 30.0)
+
+
+def test_update_dict_null_and_nan(tmp_path):
+    table = new_table(tmp_path)
+    table.update("id = 't1'", {"fare": None})
+    assert snapshot(table)["t1"] == ("sf", 1, None)
+    table.update("id = 't2'", {"fare": float("nan")})
+    assert math.isnan(snapshot(table)["t2"][2])
+
+
+def test_update_no_match_creates_no_commit(tmp_path):
+    table = new_table(tmp_path)
+    commits = len(table.get_timeline().get_completed_commits())
+    res = table.update("city = 'la'", {"fare": 1.0})
+    assert (res.instant_time, res.num_updates) == (None, 0)
+    assert "instant_time=None" in repr(res)
+    assert len(table.get_timeline().get_completed_commits()) == commits
+
+
+def test_update_rejects_bad_set(tmp_path):
+    table = new_table(tmp_path)
+    with pytest.raises(
+        ValueError, match="update column 'nope' is not in the table schema"
+    ):
+        table.update("city = 'nyc'", {"nope": 1})
+    with pytest.raises(
+        ValueError, match="cannot convert update value for column 'fare'"
+    ):
+        table.update("city = 'nyc'", {"fare": "not a number"})
+    with pytest.raises(ValueError, match="at least one column"):
+        table.update("city = 'nyc'", {})
+
+
+def test_update_dict_values_must_fit_column_type(tmp_path):
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    table.append(
+        pa.table(
+            {
+                "id": ["t1"],
+                "n": pa.array([1], pa.int64()),
+                "x": pa.array([1.0], pa.float64()),
+                "f": pa.array([1.0], pa.float32()),
+                "amount": pa.array([Decimal("1.00")], pa.decimal128(10, 2)),
+            }
+        )
+    )
+
+    def row():
+        return (
+            pa.Table.from_batches(table.read())
+            .select(["n", "x", "amount"])
+            .to_pylist()[0]
+        )
+
+    table.update("id = 't1'", {"x": 3, "amount": 2})
+    table.update("id = 't1'", {"n": 4.0})
+    # float32 rounding is accepted; only finite to inf is not.
+    table.update("id = 't1'", {"f": 0.1})
+    assert row() == {"n": 4, "x": 3.0, "amount": Decimal("2.00")}
+
+    for set_, column in [
+        ({"n": 1.5}, "n"),
+        ({"amount": 1.234}, "amount"),
+        ({"amount": Decimal("1.234")}, "amount"),
+        ({"n": "7"}, "n"),
+        ({"f": 1e300}, "f"),
+        ({"x": True}, "x"),
+    ]:
+        with pytest.raises(ValueError, match=f"column '{column}'"):
+            table.update("id = 't1'", set_)
+    assert row() == {"n": 4, "x": 3.0, "amount": Decimal("2.00")}
+
+
+def test_update_dict_nested_and_numpy_values_must_fit(tmp_path):
+    np = pytest.importorskip("numpy")
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    table.append(
+        pa.table(
+            {
+                "id": ["t1"],
+                "n": pa.array([1], pa.int64()),
+                "st": pa.array([{"k": 1}], pa.struct([("k", pa.int64())])),
+                "xs": pa.array([[1]], pa.list_(pa.int64())),
+            }
+        )
+    )
+    table.update("id = 't1'", {"st": {"k": 3}, "xs": [np.int64(4), 5.0]})
+
+    for set_, column in [
+        ({"st": {"k": 2.5}}, "st"),
+        ({"xs": [np.float64(7.9)]}, "xs"),
+        ({"n": np.True_}, "n"),
+        ({"xs": [np.True_]}, "xs"),
+    ]:
+        with pytest.raises(ValueError, match=f"column '{column}'"):
+            table.update("id = 't1'", set_)
+    row = pa.Table.from_batches(table.read()).select(["n", "st", 
"xs"]).to_pylist()
+    assert row == [{"n": 1, "st": {"k": 3}, "xs": [4, 5]}]
+
+
+def _np():
+    return pytest.importorskip("numpy")
+
+
+def _pd():
+    return pytest.importorskip("pandas")
+
+
+_AWARE = datetime.datetime(
+    2024, 1, 3, 2, tzinfo=datetime.timezone(datetime.timedelta(hours=2))
+)
+
+
[email protected](
+    "make_set, error",
+    [
+        # Accepted.
+        (lambda: {"d": datetime.date(2024, 2, 2)}, None),
+        (lambda: {"ms": datetime.datetime(2024, 1, 2, 0, 0, 0, 5000)}, None),
+        (lambda: {"utc": datetime.datetime(2024, 1, 2)}, None),
+        (lambda: {"utc": _AWARE}, None),
+        (lambda: {"n": pa.scalar(5)}, None),
+        (lambda: {"st": {"k": pa.scalar(3), "inner": {"j": pa.scalar(2)}}}, 
None),
+        (
+            lambda: {
+                "tl": [pa.scalar(datetime.datetime(2024, 1, 1), 
pa.timestamp("ms"))]
+            },
+            None,
+        ),
+        (lambda: {"st": {"k": 3}}, None),
+        # Temporal columns take only dates and datetimes, at any depth.
+        (lambda: {"d": datetime.datetime(2024, 3, 3, 12)}, "column 'd'"),
+        (lambda: {"d": 5}, "column 'd'"),
+        (lambda: {"utc": 1.7e9}, "column 'utc'"),
+        (lambda: {"ms": Decimal(5)}, "column 'ms'"),
+        (lambda: {"ms": _np().datetime64("2024-01-01")}, "column 'ms'"),
+        (
+            lambda: {"tl": _np().array(["2024-01-01"], dtype="datetime64[s]")},
+            "column 'tl'",
+        ),
+        # Sub-unit truncation, including pandas nanoseconds.
+        (lambda: {"ms": datetime.datetime(2024, 1, 2, 0, 0, 0, 123456)}, 
"column 'ms'"),
+        (
+            lambda: {"utc": _pd().Timestamp("2024-01-01 00:00:00.000000001")},
+            "column 'utc'",
+        ),
+        # A tz-aware datetime into a timestamp without a time zone.
+        (lambda: {"ms": _AWARE}, "column 'ms'"),
+        # Unknown struct keys, at any depth.
+        (lambda: {"st": {"k": 1, "x": 2}}, "column 'st'.*'x'"),
+        (lambda: {"st": {"inner": {"j": 1, "y": 2}}}, "column 'st'.*'y'"),
+        # pyarrow scalars are unwrapped before the check.
+        (lambda: {"n": pa.scalar(1.5)}, "column 'n'"),
+        (lambda: {"st": {"k": pa.scalar(2.5)}}, "column 'st'"),
+        (lambda: {"tl": [pa.scalar(5)]}, "column 'tl'"),
+        # A value pyarrow stores but cannot read back.
+        (lambda: {"ms": 10**15}, "read back .*column 'ms'"),
+        (lambda: {1: 2}, "must be str"),
+    ],
+)
+def test_update_dict_value_checks(tmp_path, make_set, error):
+    set_ = make_set()
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    inner = pa.struct([("j", pa.int64())])
+    table.append(
+        pa.table(
+            {
+                "id": ["t1"],
+                "n": pa.array([1], pa.int64()),
+                "d": pa.array([datetime.date(2024, 1, 1)], pa.date32()),
+                "ms": pa.array([datetime.datetime(2024, 1, 1)], 
pa.timestamp("ms")),
+                "utc": pa.array(
+                    [datetime.datetime(2024, 1, 1)], pa.timestamp("us", "UTC")
+                ),
+                "tl": pa.array(
+                    [[datetime.datetime(2024, 1, 1)]], 
pa.list_(pa.timestamp("ms"))
+                ),
+                "st": pa.array(
+                    [{"k": 1, "inner": {"j": 1}}],
+                    pa.struct([("k", pa.int64()), ("inner", inner)]),
+                ),
+            }
+        )
+    )
+    if error is None:
+        assert table.update("id = 't1'", set_).num_updates == 1
+        return
+    before = pa.Table.from_batches(table.read()).drop_columns(
+        ["_hoodie_commit_time", "_hoodie_commit_seqno"]
+    )
+    with pytest.raises(ValueError, match=error):
+        table.update("id = 't1'", set_)
+    after = pa.Table.from_batches(table.read()).drop_columns(
+        ["_hoodie_commit_time", "_hoodie_commit_seqno"]
+    )
+    assert after.equals(before)
+
+
[email protected]("table_type", ["cow", "mor"])
+def test_delete_by_key_predicate(tmp_path, table_type):
+    table = new_table(tmp_path, table_type)
+    res = table.delete("id IN ('t1', 't3')")
+    assert res.num_deletes == 2
+    assert set(snapshot(table)) == {"t2"}
+
+
+def test_delete_by_non_key_predicate(tmp_path):
+    table = new_table(tmp_path)
+    res = table.delete(predicate="fare > 40")
+    assert res.num_deletes == 1
+    assert set(snapshot(table)) == {"t1", "t3"}
+    res = table.delete("fare > 1000")
+    assert (res.instant_time, res.num_deletes) == (None, 0)
+
+
[email protected]("table_type", ["cow", "mor"])
+def test_delete_keys(tmp_path, table_type):
+    table = new_table(tmp_path, table_type)
+    res = table.delete_keys(["t1", ("t3", "city=nyc")])
+    assert res.num_deletes == 2
+    assert set(snapshot(table)) == {"t2"}
+    res = table.delete_keys(["missing"])
+    assert (res.instant_time, res.num_deletes) == (None, 0)
+    res = table.delete_keys(k for k in ("t2",))
+    assert res.num_deletes == 1
+    assert snapshot(table) == {}
+
+
+def test_delete_keys_rejects_bad_input(tmp_path):
+    table = new_table(tmp_path)
+    with pytest.raises(TypeError, match="not a single str"):
+        table.delete_keys("t1")
+    for bad in ([1], [("t1",)], [("t1", "city=sf", "x")]):
+        with pytest.raises(TypeError, match="each key must be a str"):
+            table.delete_keys(bad)
+    assert len(snapshot(table)) == 3
+
+
[email protected]("table_type", ["cow", "mor"])
+def test_overwrite(tmp_path, table_type):
+    table = new_table(tmp_path, table_type)
+    res = table.overwrite(trips(("t9", "sf", 9, 10.0)))
+    assert res.instant_time
+    assert snapshot(table) == {"t9": ("sf", 9, 10.0)}
+
+
[email protected]("table_type", ["cow", "mor"])
+def test_dynamic_partition_overwrite(tmp_path, table_type):
+    table = new_table(tmp_path, table_type)
+    res = table.dynamic_partition_overwrite(trips(("t8", "sf", 9, 11.0)))
+    assert res.instant_time
+    assert snapshot(table) == {"t8": ("sf", 9, 11.0), "t3": ("nyc", 1, 20.0)}
+
+
[email protected](
+    "make_data",
+    [
+        pytest.param(lambda t: t, id="table"),
+        pytest.param(lambda t: t.to_batches()[0], id="record_batch"),
+        pytest.param(lambda t: t.to_reader(), id="record_batch_reader"),
+        pytest.param(lambda t: t.to_batches(), id="list"),
+        pytest.param(StreamOnly, id="arrow_c_stream"),
+        pytest.param(
+            lambda t: ArrayOnly(t.combine_chunks().to_batches()[0]), 
id="arrow_c_array"
+        ),
+        pytest.param(
+            lambda t: [ArrayOnly(b) for b in t.to_batches()], 
id="list_of_arrow_c_array"
+        ),
+        pytest.param(lambda t: polars().from_arrow(t), id="polars"),
+        pytest.param(
+            lambda t: t.to_pandas(types_mapper=pandas().ArrowDtype), 
id="pandas_arrow"
+        ),
+        pytest.param(lambda t: t.to_pandas(), id="pandas_numpy"),
+    ],
+)
+def test_data_input_types(tmp_path, make_data):
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    data = trips(("t1", "sf", 1, 30.0), ("t2", "nyc", 1, 45.0))
+    table.append(make_data(data))
+    res = table.upsert(make_data(trips(("t2", "nyc", 2, 46.0))))
+    assert res.num_updates == 1
+    assert snapshot(table) == {"t1": ("sf", 1, 30.0), "t2": ("nyc", 2, 46.0)}
+
+
+def polars():
+    return pytest.importorskip("polars")
+
+
+def pandas():
+    return pytest.importorskip("pandas")
+
+
+def test_nested_view_large_and_dictionary_columns_are_cast(tmp_path):
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    addr = pa.field(
+        "addr",
+        pa.struct([("city", pa.large_string()), ("zip", pa.int64())]),
+        metadata={b"k": b"v"},
+    )
+    schema = pa.schema(
+        [
+            ("id", pa.string_view()),
+            addr,
+            ("kind", pa.dictionary(pa.int8(), pa.large_string())),
+        ]
+    )
+
+    def rows(*values):
+        ids, cities, kinds = zip(*values)
+        return pa.table(
+            {
+                "id": ids,
+                "addr": [{"city": c, "zip": 94000} for c in cities],
+                "kind": list(kinds),
+            },
+            schema=schema,
+        )
+
+    table.append(rows(("t1", "sf", "x"), ("t2", "nyc", "y")))
+    res = table.upsert(rows(("t2", "la", "x")))
+    assert res.num_updates == 1
+    read = pa.Table.from_batches(table.read()).sort_by("id")
+    assert [a["city"] for a in read.column("addr").to_pylist()] == ["sf", "la"]
+    assert read.column("kind").to_pylist() == ["x", "x"]
+
+
+def polars_frame(ids, tags, cities):
+    return polars().DataFrame(
+        {"id": ids, "tags": tags, "addr": [{"city": c} for c in cities]}
+    )
+
+
+def test_polars_nested_columns_append(tmp_path):
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    table.append(polars_frame(["t1", "t2"], [["a", "b"], ["c"]], ["sf", 
"nyc"]))
+    written = table.get_schema()
+    assert written.field("tags").type.value_type == pa.string()
+    assert written.field("addr").type.field("city").type == pa.string()
+    read = pa.Table.from_batches(table.read()).sort_by("id")
+    assert read.column("tags").to_pylist() == [["a", "b"], ["c"]]
+
+
+def test_polars_nested_columns_upsert(tmp_path):
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    table.append(polars_frame(["t1", "t2"], [["a", "b"], ["c"]], ["sf", 
"nyc"]))
+    res = table.upsert(polars_frame(["t2"], [["d"]], ["la"]))
+    assert res.num_updates == 1
+    read = pa.Table.from_batches(table.read()).sort_by("id")
+    assert read.column("tags").to_pylist() == [["a", "b"], ["d"]]
+    assert [a["city"] for a in read.column("addr").to_pylist()] == ["sf", "la"]
+
+
[email protected]("source", ["pandas", "polars"])
+def test_nanosecond_timestamps_are_checked(tmp_path, source):
+    # 2024-01-02 03:04:05 plus `nanos`, as a naive nanosecond timestamp.
+    def frame(nanos):
+        epoch_ns = 1_704_164_645 * 10**9 + nanos
+        if source == "pandas":
+            pd = pandas()
+            ts = pd.Series(pd.to_datetime([epoch_ns], unit="ns")).astype(
+                "datetime64[ns]"
+            )
+            return pd.DataFrame({"id": ["t1"], "ts": ts})
+        pl = polars()
+        ts = pl.Series([epoch_ns]).cast(pl.Datetime("ns"))
+        return pl.DataFrame({"id": ["t1"], "ts": ts})
+
+    assert pa.table(frame(1)).schema.field("ts").type == pa.timestamp("ns")
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    table.append(frame(123_456_000))
+    read = pa.Table.from_batches(table.read())
+    assert read.column("ts").to_pylist() == [
+        datetime.datetime(2024, 1, 2, 3, 4, 5, 123456)
+    ]
+    with pytest.raises(
+        ValueError, match="column 'ts' has timestamps with sub-microsecond"
+    ):
+        table.upsert(frame(1))
+
+
+def test_unreferenced_nanosecond_values_are_ignored(tmp_path):
+    ns = pa.timestamp("ns")
+    sliced = pa.table(
+        {"id": ["a", "b"], "ts": pa.array([[1], [1_000]], pa.list_(ns))}
+    ).slice(1)
+    unused = pa.DictionaryArray.from_arrays(
+        pa.array([1], pa.int8()), pa.array([1, 1_000], ns)
+    )
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    table.append(sliced.append_column("d", unused))
+    read = pa.Table.from_batches(table.read()).select(["id", "ts", 
"d"]).to_pylist()
+    one_us = datetime.datetime(1970, 1, 1, 0, 0, 0, 1)
+    assert read == [{"id": "b", "ts": [one_us], "d": one_us}]
+
+
+def test_pandas_category_and_index_columns(tmp_path):
+    pd = pandas()
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    frame = pd.DataFrame(
+        {
+            "id": ["t2", "t1", "t3"],
+            "kind": pd.Categorical(["x", "y", "x"]),
+            "fare": [2.0, 1.0, 3.0],
+        }
+    )
+    # A sorted frame keeps a non-default index, which pandas exports as an
+    # unnamed `__index_level_0__` column.
+    table.append(frame.sort_values("id"))
+    assert "__index_level_0__" not in table.get_schema().names
+    res = table.upsert(
+        pd.DataFrame({"id": ["t1"], "kind": pd.Categorical(["z"]), "fare": 
[9.0]})
+    )
+    assert res.num_updates == 1
+    read = pa.Table.from_batches(table.read()).sort_by("id")
+    assert read.column("kind").to_pylist() == ["z", "x", "x"]
+    assert read.column("fare").to_pylist() == [9.0, 2.0, 3.0]
+
+
+def test_index_like_column_without_pandas_metadata_is_kept(tmp_path):
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    table.append(pa.table({"id": ["t1"], "__index_level_0__": [7]}))
+    assert "__index_level_0__" in table.get_schema().names
+
+
+def test_pandas_named_index_is_kept(tmp_path):
+    pd = pandas()
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    frame = pd.DataFrame({"id": ["t1", "t2"], "fare": [1.0, 
2.0]}).set_index("id")
+    table.append(frame)
+    res = table.upsert(pd.DataFrame({"fare": [5.0]}, index=pd.Index(["t2"], 
name="id")))
+    assert res.num_updates == 1
+    read = pa.Table.from_batches(table.read()).sort_by("id")
+    assert read.column("fare").to_pylist() == [1.0, 5.0]
+
+
+def test_write_errors_leave_the_table_unchanged(tmp_path):
+    table = new_table(tmp_path)
+    timeline = tmp_path / "trips" / ".hoodie" / "timeline"
+    instants = sorted(p.name for p in timeline.iterdir())
+    no_key = pa.table({"city": ["sf"], "ts": [2], "fare": [1.0]})
+    with pytest.raises(Exception, match="schema does not match"):
+        table.upsert(no_key)
+    with pytest.raises(Exception, match="nope"):
+        table.upsert(trips(("t1", "sf", 2, 1.0)), update_columns=["nope"])
+    with pytest.raises(Exception, match="'no_such_column' not found"):
+        table.delete("no_such_column = 1")
+    assert sorted(p.name for p in timeline.iterdir()) == instants
+    assert snapshot(table)["t1"] == ("sf", 1, 30.0)
+
+
+def test_binary_large_list_and_dictionary_columns_are_cast(tmp_path):
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    schema = pa.schema(
+        [
+            ("id", pa.string()),
+            ("blob", pa.large_binary()),
+            ("view", pa.binary_view()),
+            ("xs", pa.large_list(pa.int64())),
+            ("code", pa.dictionary(pa.int8(), pa.int64())),
+        ]
+    )
+
+    def rows(id_, blob, xs, code):
+        return pa.table(
+            {"id": [id_], "blob": [blob], "view": [blob], "xs": [xs], "code": 
[code]},
+            schema=schema,
+        )
+
+    table.append(rows("t1", b"a", [1, 2], 7))
+    written = table.get_schema()
+    assert written.field("blob").type == pa.binary()
+    assert written.field("view").type == pa.binary()
+    assert pa.types.is_list(written.field("xs").type)
+    assert written.field("xs").type.value_type == pa.int64()
+    assert written.field("code").type == pa.int64()
+    assert table.upsert(rows("t1", b"b", [3], 8)).num_updates == 1
+    read = pa.Table.from_batches(table.read()).select(["blob", "view", "xs", 
"code"])
+    assert read.to_pylist() == [{"blob": b"b", "view": b"b", "xs": [3], 
"code": 8}]
+
+
+def test_update_dict_struct_value_by_position(tmp_path):
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    st = pa.struct([("k", pa.int64()), ("v", pa.string())])
+    table.append(pa.table({"id": ["t1"], "st": pa.array([{"k": 1, "v": "a"}], 
st)}))
+    table.update("id = 't1'", {"st": (2, "b")})
+    with pytest.raises(ValueError, match="column 'st'"):
+        table.update("id = 't1'", {"st": (2.5, "c")})
+    read = pa.Table.from_batches(table.read()).select(["st"]).to_pylist()
+    assert read == [{"st": {"k": 2, "v": "b"}}]
+
+
+def test_unsupported_data_type_raises(tmp_path):
+    table = HudiTable.create(str(tmp_path / "t"), record_key="id")
+    with pytest.raises(TypeError, match="unsupported data type 'dict'"):
+        table.append({"id": ["t1"]})
+    with pytest.raises(TypeError, match=r"data\[0\] is not a 
pyarrow.RecordBatch"):
+        table.append([{"id": "t1"}])

Reply via email to