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

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


The following commit(s) were added to refs/heads/master by this push:
     new 5fa4a337ed [python] Support native batch overwrite commits (#10097)
5fa4a337ed is described below

commit 5fa4a337edbe8e20cdfd0c6c2bce690041d48b46
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Sep 22 23:06:43 2026 +0800

    [python] Support native batch overwrite commits (#10097)
---
 paimon-python/README.md                            |  32 ++-
 .../pypaimon/common/options/core_options.py        |   2 +-
 paimon-python/pypaimon/tests/native_commit_test.py | 220 +++++++++++++++++++--
 paimon-python/pypaimon/tests/ray_sink_test.py      |  11 +-
 paimon-python/pypaimon/tests/table_commit_test.py  |  15 +-
 paimon-python/pypaimon/write/file_store_commit.py  |   5 +-
 paimon-python/pypaimon/write/native_commit.py      |  35 ++--
 paimon-python/pypaimon/write/table_commit.py       |  14 +-
 paimon-python/pypaimon/write/table_write.py        |   3 +
 paimon-python/pypaimon/write/write_builder.py      |  17 +-
 10 files changed, 279 insertions(+), 75 deletions(-)

diff --git a/paimon-python/README.md b/paimon-python/README.md
index 8e45ba7153..7da6734823 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -121,8 +121,8 @@ object-store requests.
 
 # Native commit
 
-PyPaimon can submit append commits through the optional `pypaimon-rust`
-runtime. Enable it independently of native planning and reading:
+PyPaimon can submit append and batch overwrite commits through the optional
+`pypaimon-rust` runtime. Enable it independently of native planning and 
reading:
 
 ```python
 native_table = table.copy({"commit.native.enabled": "true"})
@@ -142,16 +142,34 @@ and stream append commits retain the Python builder's 
commit user, identifier,
 empty-commit option, and batch one-shot lifecycle. Explicit abort also supports
 native cleanup of uncommitted files.
 
-This requires a runtime containing the commit bindings merged in
-[paimon-rust #912](https://github.com/apache/paimon-rust/pull/912). Older or 
missing
-runtimes automatically use Python. The current native route supports 
main-branch
+For batch overwrite, configure the Python builder as usual:
+
+```python
+builder = native_table.new_batch_write_builder().overwrite({"pt": 
"2026-09-22"})
+```
+
+Overwrite preserves the Python commit user and follows the table's
+`dynamic-partition-overwrite` option. Dynamic overwrite replaces only 
partitions
+present in the messages and does nothing for empty input. Static overwrite
+replaces partitions matching the spec, including for empty input; an empty spec
+matches the whole table. Empty overwrite of an unpartitioned table truncates 
it.
+Static and unpartitioned overwrite record an OVERWRITE snapshot even when no
+files match, following Java; this also applies when the operation uses Python.
+
+Overwrite is configured only through `BatchWriteBuilder`, following Java's
+batch/stream API split. `StreamWriteBuilder` does not expose overwrite.
+
+Native commits require a runtime containing the batch identity bridge in
+[paimon-rust #916](https://github.com/apache/paimon-rust/pull/916), built on
+[#915](https://github.com/apache/paimon-rust/pull/915). If the optional 
runtime is
+not installed, commits use Python. The current native route supports 
main-branch
 tables using filesystem/JDBC catalogs or `FileStoreTable.from_path()` with
-standard FileIO. Overwrite, truncate, REST/catalog-managed publication, custom
+standard FileIO. Truncate, REST/catalog-managed publication, custom
 FileIO/environments, commit callbacks, and snapshot properties use Python.
 Data-evolution updates that need Python's row-id conflict rewriting also retain
 the Python path. Compact increments remain unsupported by both committers.
 
-Fallback is limited to capability checks, table construction and message
+Fallback is limited to runtime availability, table construction and message
 conversion before a native mutation starts. A native commit error propagates;
 the adapter neither retries it through Python nor aborts files, since the
 snapshot may already have been published. The option is disabled by default.
diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index b329f62d19..ee7cb43d7c 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -681,7 +681,7 @@ class CoreOptions:
         ConfigOptions.key("commit.native.enabled")
         .boolean_type()
         .default_value(False)
-        .with_description("Commit append messages via pypaimon_rust. 
Unsupported "
+        .with_description("Commit append and batch overwrite messages via 
pypaimon_rust. Unsupported "
                           "operations use Python before any native commit is 
attempted.")
     )
 
diff --git a/paimon-python/pypaimon/tests/native_commit_test.py 
b/paimon-python/pypaimon/tests/native_commit_test.py
index fde7def37c..67c9656c39 100644
--- a/paimon-python/pypaimon/tests/native_commit_test.py
+++ b/paimon-python/pypaimon/tests/native_commit_test.py
@@ -30,10 +30,11 @@ from pypaimon.table.file_store_table import FileStoreTable
 from pypaimon.write.commit_message import CommitMessage
 from pypaimon.write.native_commit import (
     create_native_commit, native_commit_available, native_messages_supported)
+from pypaimon.write.table_write import StreamTableWrite
 
 
 requires_native = pytest.mark.skipif(
-    not native_commit_available(), reason='pypaimon-rust CommitMessage/commit 
API required')
+    not native_commit_available(), reason='pypaimon-rust runtime required')
 
 
 def _table(tmp_path, mode='append', backend='filesystem'):
@@ -50,7 +51,7 @@ def _table(tmp_path, mode='append', backend='filesystem'):
     catalog.create_table('default.t', Schema.from_pyarrow_schema(
         pa.schema([('id', pa.int64()), ('pt', pa.string())]),
         options=options, primary_keys=['id'] if mode == 'pk' else [],
-        partition_keys=[] if mode == 'pk' else ['pt']), False)
+        partition_keys=[] if mode in ('pk', 'unpartitioned') else ['pt']), 
False)
     table = catalog.get_table('default.t')
     if backend == 'path':
         table = FileStoreTable.from_path(table.table_path)
@@ -75,8 +76,18 @@ def _rows(table):
         builder.new_scan().plan().splits()).to_pylist(), key=lambda row: 
row['id'])
 
 
-def _must_not_fallback(commit):
-    return patch.object(commit.file_store_commit, 'commit', 
side_effect=AssertionError('Python fallback'))
+def _must_not_fallback(commit, method='commit'):
+    return patch.object(commit.file_store_commit, method, 
side_effect=AssertionError('Python fallback'))
+
+
+def _seed(table):
+    builder = table.copy({'commit.native.enabled': 
'false'}).new_batch_write_builder()
+    commit = builder.new_commit()
+    try:
+        commit.commit(_prepare(builder, [
+            {'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}, {'id': 3, 'pt': 
None}]))
+    finally:
+        commit.close()
 
 
 def test_native_commit_is_opt_in():
@@ -84,6 +95,18 @@ def test_native_commit_is_opt_in():
     assert CoreOptions(Options({'commit.native.enabled': 
'true'})).native_commit_enabled()
 
 
+def test_overwrite_builder_api_is_batch_only(tmp_path):
+    table = _table(tmp_path)
+    batch = table.new_batch_write_builder()
+    assert batch.overwrite({'pt': 'a'}) is batch
+    assert batch.static_partition == {'pt': 'a'}
+    stream = table.new_stream_write_builder()
+    with pytest.raises(AttributeError):
+        stream.overwrite({'pt': 'a'})
+    with pytest.raises(TypeError):
+        StreamTableWrite(table, stream.commit_user, {'pt': 'a'})
+
+
 @requires_native
 @pytest.mark.parametrize('backend', ['filesystem', 'path', 'jdbc'])
 @pytest.mark.parametrize('mode', ['append', 'pk', 'de'])
@@ -123,6 +146,116 @@ def 
test_native_stream_reuses_commit_user_and_identifiers(tmp_path):
         commit.close()
 
 
+@requires_native
[email protected]('mode,dynamic,spec', [
+    ('append', True, {'pt': 'ignored'}),
+    ('append', False, {'pt': 'a'}),
+    ('de', True, {}),
+    ('pk', True, {}),
+    ('unpartitioned', True, {}),
+])
+def test_native_overwrite_replaces_only_target_rows(tmp_path, mode, dynamic, 
spec):
+    table = _table(tmp_path, mode).copy({
+        'dynamic-partition-overwrite': str(dynamic).lower(), 
'commit.user-prefix': 'python'})
+    _seed(table)
+    builder = table.new_batch_write_builder().overwrite(spec)
+    commit = builder.new_commit()
+    try:
+        messages = _prepare(builder, [{'id': 4, 'pt': 'a'}])
+        with _must_not_fallback(commit, 'overwrite'):
+            commit.commit(messages)
+        expected = [{'id': 4, 'pt': 'a'}]
+        if table.partition_keys:
+            expected = [{'id': 2, 'pt': 'b'}, {'id': 3, 'pt': None}] + expected
+        assert _rows(table) == expected
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        assert snapshot.commit_user == builder.commit_user
+        assert snapshot.commit_identifier == BATCH_COMMIT_IDENTIFIER
+        assert snapshot.commit_kind == 'OVERWRITE'
+        with pytest.raises(RuntimeError, match='one-time'):
+            commit.commit(messages)
+    finally:
+        commit.close()
+
+
+@requires_native
[email protected]('mode,dynamic,spec,remaining', [
+    ('append', True, {}, [1, 2, 3]),
+    ('append', False, {'pt': 'a'}, [2, 3]),
+    ('append', False, {'pt': None}, [1, 2]),
+    ('append', False, {'pt': '__DEFAULT_PARTITION__'}, [1, 2]),
+    ('append', False, {}, []),
+    ('unpartitioned', True, {}, []),
+    ('pk', True, {}, []),
+])
+def test_native_empty_overwrite_semantics(tmp_path, mode, dynamic, spec, 
remaining):
+    table = _table(tmp_path, mode).copy({'dynamic-partition-overwrite': 
str(dynamic).lower()})
+    _seed(table)
+    builder = table.new_batch_write_builder().overwrite(spec)
+    commit = builder.new_commit()
+    try:
+        with _must_not_fallback(commit, 'overwrite'):
+            commit.commit([])
+        assert [row['id'] for row in _rows(table)] == remaining
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        if remaining == [1, 2, 3]:
+            assert snapshot.id == 1
+        else:
+            assert snapshot.id == 2
+            assert snapshot.commit_user == builder.commit_user
+            assert snapshot.commit_kind == 'OVERWRITE'
+    finally:
+        commit.close()
+
+
+@requires_native
[email protected]('value', ['off', '0', ' false '])
+def test_native_overwrite_normalizes_python_boolean_option(tmp_path, value):
+    table = _table(tmp_path).copy({
+        'dynamic-partition-overwrite': value, 'snapshot.ignore-empty-commit': 
value})
+    _seed(table)
+    commit = table.new_batch_write_builder().overwrite({'pt': 
'a'}).new_commit()
+    try:
+        with _must_not_fallback(commit, 'overwrite'):
+            commit.commit([])
+        assert [row['id'] for row in _rows(table)] == [2, 3]
+    finally:
+        commit.close()
+
+
[email protected]('native', [False, True])
[email protected]('case', ['unpartitioned', 'static-empty', 
'static-missing', 'dynamic-empty'])
+def test_empty_overwrite_records_java_snapshot(tmp_path, native, case):
+    if native and not native_commit_available():
+        pytest.skip('pypaimon-rust runtime required')
+    table = _table(tmp_path, 'unpartitioned' if case == 'unpartitioned' else 
'append').copy({
+        'commit.native.enabled': str(native).lower(),
+        'dynamic-partition-overwrite': str(case == 'dynamic-empty').lower(),
+    })
+    if case == 'static-missing':
+        _seed(table)
+    builder = table.new_batch_write_builder().overwrite(
+        {'pt': 'missing'} if case.startswith('static') else {})
+    commit = builder.new_commit()
+    try:
+        if native:
+            with _must_not_fallback(commit, 'overwrite'):
+                commit.commit([])
+        else:
+            commit.commit([])
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        if case == 'dynamic-empty':
+            assert snapshot is None
+        else:
+            assert snapshot.id == (2 if case == 'static-missing' else 1)
+            assert snapshot.commit_user == builder.commit_user
+            assert snapshot.commit_kind == 'OVERWRITE'
+            assert snapshot.total_record_count == (3 if case == 
'static-missing' else 0)
+        assert [row['id'] for row in _rows(table)] == ([1, 2, 3] if case == 
'static-missing' else [])
+    finally:
+        commit.close()
+
+
 @requires_native
 @pytest.mark.parametrize('mode', ['batch', 'stream'])
 @pytest.mark.parametrize('ignore', [True, False])
@@ -146,9 +279,12 @@ def 
test_native_empty_commit_preserves_python_option(tmp_path, mode, ignore):
 
 
 @requires_native
-def test_native_abort_removes_uncommitted_files(tmp_path):
[email protected]('overwrite', [False, True])
+def test_native_abort_removes_uncommitted_files(tmp_path, overwrite):
     table = _table(tmp_path)
     builder = table.new_batch_write_builder()
+    if overwrite:
+        builder.overwrite()
     messages = _prepare(builder, [{'id': 1, 'pt': None}])
     files = list(tmp_path.rglob('data-*.parquet'))
     assert files
@@ -183,9 +319,12 @@ def test_preflight_failure_uses_python(tmp_path, failure):
 
 
 @pytest.mark.parametrize('method', ['commit', 'abort'])
-def test_native_mutation_failure_never_falls_back_or_aborts(tmp_path, method):
[email protected]('overwrite', [False, True])
+def test_native_mutation_failure_never_falls_back_or_aborts(tmp_path, method, 
overwrite):
     table = _table(tmp_path)
     builder = table.new_batch_write_builder()
+    if overwrite:
+        builder.overwrite()
     messages = _prepare(builder, [{'id': 1, 'pt': 'a'}])
     commit = builder.new_commit()
     native = Mock()
@@ -193,10 +332,12 @@ def 
test_native_mutation_failure_never_falls_back_or_aborts(tmp_path, method):
     with patch('pypaimon.write.native_commit.create_native_commit', 
return_value=native), \
             patch('pypaimon.write.native_commit.to_native_commit_messages', 
return_value=['wire']), \
             patch.object(commit.file_store_commit, 'commit') as fallback, \
+            patch.object(commit.file_store_commit, 'overwrite') as 
fallback_overwrite, \
             patch.object(commit.file_store_commit, 'abort') as abort:
         with pytest.raises(OSError, match='outcome unknown'):
             getattr(commit, method)(messages)
         fallback.assert_not_called()
+        fallback_overwrite.assert_not_called()
         abort.assert_not_called()
         if method == 'commit':
             native.abort.assert_not_called()
@@ -207,22 +348,25 @@ def 
test_native_mutation_failure_never_falls_back_or_aborts(tmp_path, method):
 
 
 @requires_native
-def 
test_publication_response_loss_does_not_duplicate_or_delete_files(tmp_path):
[email protected]('overwrite', [False, True])
+def 
test_publication_response_loss_does_not_duplicate_or_delete_files(tmp_path, 
overwrite):
     table = _table(tmp_path)
     builder = table.new_batch_write_builder()
+    if overwrite:
+        builder.overwrite()
     messages = _prepare(builder, [{'id': 1, 'pt': None}])
-    native = create_native_commit(table, builder.commit_user)
+    native = create_native_commit(table, builder.commit_user, 
builder.static_partition)
     proxy = Mock(wraps=native)
 
-    def publish_then_fail(identifier, messages):
-        native.commit(identifier, messages)
+    def publish_then_fail(*args):
+        native.commit(*args)
         raise OSError('response lost')
 
     proxy.commit.side_effect = publish_then_fail
     commit = builder.new_commit()
     try:
         with patch('pypaimon.write.native_commit.create_native_commit', 
return_value=proxy), \
-                _must_not_fallback(commit), \
+                _must_not_fallback(commit, 'overwrite' if overwrite else 
'commit'), \
                 patch.object(commit.file_store_commit, 'abort', 
side_effect=AssertionError('abort')):
             with pytest.raises(OSError, match='response lost'):
                 commit.commit(messages)
@@ -234,9 +378,12 @@ def 
test_publication_response_loss_does_not_duplicate_or_delete_files(tmp_path):
 
 
 @pytest.mark.parametrize('properties', [{}, {'source': 'python'}])
-def test_snapshot_properties_select_python_before_native(tmp_path, properties):
[email protected]('overwrite', [False, True])
+def test_snapshot_properties_select_python_before_native(tmp_path, properties, 
overwrite):
     table = _table(tmp_path)
     builder = table.new_batch_write_builder()
+    if overwrite:
+        builder.overwrite()
     commit = builder.new_commit()
     with patch('pypaimon.write.native_commit.create_native_commit',
                side_effect=AssertionError('must not initialize native')) as 
create:
@@ -266,17 +413,53 @@ def 
test_callbacks_added_after_construction_select_python(tmp_path, warmup):
     callback.close.assert_called_once()
 
 
-def test_overwrite_and_truncate_use_python(tmp_path):
+def test_truncate_uses_python(tmp_path):
     table = _table(tmp_path)
+    _seed(table)
+    with patch('pypaimon.write.native_commit.create_native_commit') as create:
+        for method, args in [('truncate_partitions', ([{'pt': 'a'}],)), 
('truncate_table', ())]:
+            commit = table.new_batch_write_builder().new_commit()
+            try:
+                getattr(commit, method)(*args)
+            finally:
+                commit.close()
+        create.assert_not_called()
+    assert _rows(table) == []
+
+
+def test_overwrite_conversion_failure_preserves_partition_scope(tmp_path):
+    table = _table(tmp_path).copy({'dynamic-partition-overwrite': 'false'})
+    _seed(table)
     builder = table.new_batch_write_builder().overwrite({'pt': 'a'})
     commit = builder.new_commit()
+    native = Mock()
+    try:
+        messages = _prepare(builder, [{'id': 4, 'pt': 'a'}])
+        with patch('pypaimon.write.native_commit.create_native_commit', 
return_value=native), \
+                patch('pypaimon.write.native_commit.to_native_commit_messages',
+                      side_effect=ValueError('unsupported payload')):
+            commit.commit(messages)
+        native.commit.assert_not_called()
+        native.abort.assert_not_called()
+        assert _rows(table) == [{'id': 2, 'pt': 'b'}, {'id': 3, 'pt': None}, 
{'id': 4, 'pt': 'a'}]
+        assert table.snapshot_manager().get_latest_snapshot().commit_user == 
builder.commit_user
+    finally:
+        commit.close()
+
+
+def test_overwrite_callbacks_select_python(tmp_path):
+    table = _table(tmp_path)
+    builder = table.new_batch_write_builder().overwrite()
+    commit = builder.new_commit()
+    callback = Mock()
+    commit.add_commit_callback(callback)
     with patch('pypaimon.write.native_commit.create_native_commit') as create:
         commit.commit(_prepare(builder, [{'id': 1, 'pt': 'a'}]))
-        
table.new_batch_write_builder().new_commit().truncate_partitions([{'pt': 'a'}])
-        table.new_batch_write_builder().new_commit().truncate_table()
         create.assert_not_called()
-    assert _rows(table) == []
+    assert _rows(table) == [{'id': 1, 'pt': 'a'}]
+    callback.call.assert_called_once()
     commit.close()
+    callback.close.assert_called_once()
 
 
 def test_disabled_option_never_initializes_native(tmp_path):
@@ -309,10 +492,9 @@ def 
test_incompatible_publication_environment_is_not_reconstructed(tmp_path, kin
         resolve.assert_not_called()
 
 
-def test_missing_capability_falls_back_without_reconstructing_table(tmp_path):
+def test_missing_runtime_falls_back_without_reconstructing_table(tmp_path):
     table = _table(tmp_path)
-    with patch('pypaimon.write.native_commit.native_method_available',
-               side_effect=lambda cls, method: (cls, method) != 
('CommitMessage', 'deserialize')), \
+    with patch('pypaimon.write.native_commit.native_commit_available', 
return_value=False), \
             
patch('pypaimon.write.native_commit._resolved_schema_file_io_options') as 
resolve:
         assert create_native_commit(table, 'job') is None
         resolve.assert_not_called()
diff --git a/paimon-python/pypaimon/tests/ray_sink_test.py 
b/paimon-python/pypaimon/tests/ray_sink_test.py
index d5db6f3074..72c565828e 100644
--- a/paimon-python/pypaimon/tests/ray_sink_test.py
+++ b/paimon-python/pypaimon/tests/ray_sink_test.py
@@ -172,14 +172,11 @@ class RaySinkTest(unittest.TestCase):
         finally:
             batch_write.close()
 
-        stream_write = (
-            self.table
-            .new_stream_write_builder()
-            .overwrite({'dt': '2024-01-01'})
-            .new_write()
-        )
+        stream_builder = self.table.new_stream_write_builder()
+        self.assertFalse(hasattr(stream_builder, 'overwrite'))
+        stream_write = stream_builder.new_write()
         try:
-            self.assertEqual(stream_write.static_partition, {'dt': 
'2024-01-01'})
+            self.assertIsNone(stream_write.static_partition)
         finally:
             stream_write.close()
 
diff --git a/paimon-python/pypaimon/tests/table_commit_test.py 
b/paimon-python/pypaimon/tests/table_commit_test.py
index 5b71403b19..82cd49291c 100644
--- a/paimon-python/pypaimon/tests/table_commit_test.py
+++ b/paimon-python/pypaimon/tests/table_commit_test.py
@@ -166,18 +166,9 @@ class TestTableCommit(unittest.TestCase):
             snapshot_properties={"source": "capture"},
         )
 
-    # -- StreamTableCommit overwrite should also reach overwrite() with empty 
messages --
-
-    def test_stream_commit_overwrite_empty_messages(self):
-        commit, mock_fsc = self._create_commit(StreamTableCommit, 
overwrite_partition={'dt': '2024-01-15'})
-
-        commit.commit([], commit_identifier=42)
-
-        mock_fsc.overwrite.assert_called_once_with(
-            overwrite_partition={'dt': '2024-01-15'},
-            commit_messages=[],
-            commit_identifier=42,
-        )
+    def test_stream_commit_does_not_accept_overwrite_configuration(self):
+        with self.assertRaises(TypeError):
+            StreamTableCommit(Mock(), 'job', {'dt': '2024-01-15'})
 
     def test_stream_commit_forwards_snapshot_properties(self):
         commit, mock_fsc = self._create_commit(
diff --git a/paimon-python/pypaimon/write/file_store_commit.py 
b/paimon-python/pypaimon/write/file_store_commit.py
index afe7476620..5c0b6f7427 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -361,6 +361,8 @@ class FileStoreCommit:
                 index_deletes=index_deletes,
                 index_adds=index_adds,
                 snapshot_properties=snapshot_properties,
+                # Java records static/table overwrite even when no files match.
+                allow_empty_commit=True,
             )
 
     def _overwrite_hash_index_deletes(self, partition_filter, deletes):
@@ -476,8 +478,7 @@ class FileStoreCommit:
                 else commit_entries_plan(latest_snapshot)
             )
 
-            # Append can explicitly publish an empty snapshot for tagging.
-            # No-op overwrite/drop operations retain their existing behavior.
+            # Callers opt in when the operation records an empty snapshot.
             if (not allow_empty_commit and not commit_entries
                     and not index_deletes and not index_adds):
                 break
diff --git a/paimon-python/pypaimon/write/native_commit.py 
b/paimon-python/pypaimon/write/native_commit.py
index decba9dcdc..86fc008942 100644
--- a/paimon-python/pypaimon/write/native_commit.py
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -15,27 +15,18 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"""Optional native append commits using the Java CommitMessage v14 bridge."""
+"""Optional native commits using the Java CommitMessage v14 bridge."""
 
 from pypaimon.common.json_util import JSON
 from pypaimon.read.native_plan import (
-    _option_value_to_string, _resolved_schema_file_io_options,
-    native_method_available)
+    _option_value_to_string, _resolved_schema_file_io_options)
 from pypaimon.write.commit_message_serializer import serialize_commit_message
 
 
 def native_commit_available() -> bool:
-    """Probe capabilities instead of assuming every 0.4 development wheel has 
them."""
-    return all(native_method_available(type_name, method) for type_name, 
method in (
-        ('Table', 'from_resolved_schema'),
-        ('Table', 'new_stream_write_builder'),
-        ('StreamWriteBuilder', 'with_commit_user'),
-        ('StreamWriteBuilder', 'new_commit'),
-        ('StreamTableCommit', 'commit'),
-        ('StreamTableCommit', 'abort'),
-        ('StreamTableCommit', 'close'),
-        ('CommitMessage', 'deserialize'),
-    ))
+    """Whether the optional Rust runtime is installed."""
+    from importlib.util import find_spec
+    return find_spec('pypaimon_rust') is not None
 
 
 def native_messages_supported(table, messages) -> bool:
@@ -54,7 +45,7 @@ def native_messages_supported(table, messages) -> bool:
     return True
 
 
-def create_native_commit(table, commit_user):
+def create_native_commit(table, commit_user, overwrite_partition=None):
     """Return a native committer only when its publication protocol matches 
Python."""
     from pypaimon.catalog.catalog_environment import CatalogEnvironment
     from pypaimon.filesystem.local_file_io import LocalFileIO
@@ -83,14 +74,22 @@ def create_native_commit(table, commit_user):
     # copy() overrides. Do not inject scan options or reload catalog schemas.
     options = {str(key): _option_value_to_string(value)
                for key, value in table.table_schema.options.items() if value 
is not None}
+    # Python accepts boolean spellings such as "off"; pass the parsed value.
+    options['dynamic-partition-overwrite'] = _option_value_to_string(
+        table.options.dynamic_partition_overwrite())
+    options['snapshot.ignore-empty-commit'] = _option_value_to_string(
+        table.options.snapshot_ignore_empty_commit())
     native_table = NativeTable.from_resolved_schema(
         table.table_path, 
JSON.to_json(table.table_schema.copy(new_options=options)),
         database=table.identifier.get_database_name(),
         table=table.identifier.get_table_name(),
         options=file_io_options)
-    # Both Python modes already enforce their public lifecycle/empty-commit
-    # contract. The stream builder preserves their existing writer identity;
-    # a native batch builder would mint a different commit user.
+    if overwrite_partition is not None:
+        return (native_table.new_batch_write_builder()
+                ._with_commit_user(commit_user)
+                .with_overwrite(overwrite_partition).new_commit())
+    # Append commits use the stream committer with the Python writer's 
identity;
+    # Python enforces each mode's lifecycle and empty-commit rules.
     return 
native_table.new_stream_write_builder().with_commit_user(commit_user).new_commit()
 
 
diff --git a/paimon-python/pypaimon/write/table_commit.py 
b/paimon-python/pypaimon/write/table_commit.py
index c6ba7bc3fa..ef2595d93c 100644
--- a/paimon-python/pypaimon/write/table_commit.py
+++ b/paimon-python/pypaimon/write/table_commit.py
@@ -85,6 +85,13 @@ class TableCommit:
                 "Committing overwrite to table %s, %d non-empty messages",
                 self.table.identifier, len(non_empty_messages)
             )
+            if snapshot_properties is None:
+                prepared = self._prepare_native_commit(non_empty_messages)
+                if prepared is not None:
+                    native, messages = prepared
+                    # Keep publication failures outside the preparation 
fallback.
+                    native.commit(messages)
+                    return
             self.file_store_commit.overwrite(
                 overwrite_partition=self.overwrite_partition,
                 **commit_kwargs)
@@ -108,7 +115,6 @@ class TableCommit:
 
     def _prepare_native_commit(self, messages):
         if (not self.table.options.native_commit_enabled()
-                or self.overwrite_partition is not None
                 or self._commit_callbacks):
             return None
         try:
@@ -118,7 +124,8 @@ class TableCommit:
             if not native_messages_supported(self.table, messages):
                 return None
             if self._native_commit is None:
-                self._native_commit = create_native_commit(self.table, 
self.commit_user)
+                self._native_commit = create_native_commit(
+                    self.table, self.commit_user, self.overwrite_partition)
             if self._native_commit is None:
                 return None
             return self._native_commit, to_native_commit_messages(self.table, 
messages)
@@ -185,6 +192,9 @@ class StreamTableCommit(TableCommit):
     :meth:`StreamTableWrite.prepare_commit`.
     """
 
+    def __init__(self, table, commit_user: str):
+        super().__init__(table, commit_user, None)
+
     def commit(
             self,
             commit_messages: List[CommitMessage],
diff --git a/paimon-python/pypaimon/write/table_write.py 
b/paimon-python/pypaimon/write/table_write.py
index 37ee3fe4c8..87867b54b5 100644
--- a/paimon-python/pypaimon/write/table_write.py
+++ b/paimon-python/pypaimon/write/table_write.py
@@ -390,6 +390,9 @@ class BatchTableWrite(TableWrite):
 
 class StreamTableWrite(TableWrite):
 
+    def __init__(self, table, commit_user):
+        super().__init__(table, commit_user, None)
+
     def prepare_commit(self, commit_identifier) -> List[CommitMessage]:
         messages = self._prepare_commit(commit_identifier)
         self._release_prepared_indexes()
diff --git a/paimon-python/pypaimon/write/write_builder.py 
b/paimon-python/pypaimon/write/write_builder.py
index f7a0459305..1c64d616e2 100644
--- a/paimon-python/pypaimon/write/write_builder.py
+++ b/paimon-python/pypaimon/write/write_builder.py
@@ -33,11 +33,6 @@ class WriteBuilder(ABC):
 
         self.table: FileStoreTable = table
         self.commit_user = self._create_commit_user()
-        self.static_partition = None
-
-    def overwrite(self, static_partition: Optional[dict] = None):
-        self.static_partition = static_partition if static_partition is not 
None else {}
-        return self
 
     def new_write(self) -> TableWrite:
         """Returns a table write."""
@@ -58,6 +53,14 @@ class WriteBuilder(ABC):
 
 class BatchWriteBuilder(WriteBuilder):
 
+    def __init__(self, table):
+        super().__init__(table)
+        self.static_partition = None
+
+    def overwrite(self, static_partition: Optional[dict] = None):
+        self.static_partition = static_partition if static_partition is not 
None else {}
+        return self
+
     def new_write(self) -> BatchTableWrite:
         return BatchTableWrite(self.table, self.commit_user, 
self.static_partition)
 
@@ -72,11 +75,11 @@ class BatchWriteBuilder(WriteBuilder):
 class StreamWriteBuilder(WriteBuilder):
 
     def new_write(self) -> StreamTableWrite:
-        return StreamTableWrite(self.table, self.commit_user, 
self.static_partition)
+        return StreamTableWrite(self.table, self.commit_user)
 
     def new_update(self) -> StreamTableUpdate:
         return StreamTableUpdate(self.table, self.commit_user)
 
     def new_commit(self) -> StreamTableCommit:
-        commit = StreamTableCommit(self.table, self.commit_user, 
self.static_partition)
+        commit = StreamTableCommit(self.table, self.commit_user)
         return commit

Reply via email to