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 2420ac6b9d [python] Enable native write and REST commit in Native CI 
(#10155)
2420ac6b9d is described below

commit 2420ac6b9db7a9d42b89b500df88ea573c8e0753
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Sep 24 18:06:54 2026 +0800

    [python] Enable native write and REST commit in Native CI (#10155)
---
 .github/workflows/ci-python.yml                    |  21 ++-
 paimon-python/conftest.py                          | 109 +++++++++++++-
 .../pypaimon/read/reader/format_pyarrow_reader.py  |  14 +-
 .../pypaimon/read/scanner/split_generator.py       |  14 ++
 paimon-python/pypaimon/tests/binary_row_test.py    |  22 ++-
 .../tests/data_evolution_row_rolling_test.py       |   2 +
 paimon-python/pypaimon/tests/native_commit_test.py | 161 ++++++++++++--------
 .../pypaimon/tests/native_plan_integration_test.py |   1 +
 paimon-python/pypaimon/tests/native_write_test.py  | 165 ++++++++++++++++++++-
 .../pypaimon/tests/parquet_metadata_cache_test.py  |  19 +++
 .../pypaimon/tests/ray_range_join_test.py          |   2 +
 .../rest/rest_catalog_commit_snapshot_test.py      |   2 +
 .../pypaimon/tests/rest/rest_simple_test.py        |   5 +
 .../pypaimon/tests/schema_evolution_read_test.py   |   3 +
 .../tests/write/changelog_producer_test.py         |   5 +-
 .../pypaimon/tests/write/table_write_test.py       |   5 +
 .../pypaimon/utils/file_store_path_factory.py      |  16 ++
 paimon-python/pypaimon/write/native_commit.py      | 126 ++++++++++++----
 paimon-python/pypaimon/write/native_write.py       |  63 ++++++--
 19 files changed, 624 insertions(+), 131 deletions(-)

diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml
index ea2a988695..892a155b5c 100644
--- a/.github/workflows/ci-python.yml
+++ b/.github/workflows/ci-python.yml
@@ -35,7 +35,7 @@ jobs:
   test:
     name: Tests / Python ${{ matrix.python-version }}
     env:
-      # Real native planning is covered by the Rust main job below.
+      # Native planning, reading, writing and REST commits run in Native CI.
       PYTEST_ADDOPTS: "-m 'not native_plan'"
     timeout-minutes: 90
     runs-on: ubuntu-latest
@@ -177,7 +177,7 @@ jobs:
           ./paimon-python/dev/lint-python.sh -e pytest_torch
 
   rust-plan:
-    name: Rust Plan
+    name: Native CI
     timeout-minutes: 90
     runs-on: ubuntu-latest
     container: "python:3.11-slim"
@@ -196,10 +196,10 @@ jobs:
           curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- 
-y --default-toolchain stable --profile minimal
           echo "$HOME/.cargo/bin" >> $GITHUB_PATH
 
-      - name: Build paimon-rust main
+      - name: Build paimon-rust with REST native commit support
         run: |
           python -m pip install --upgrade pip
-          python -m pip install --upgrade 
"git+https://github.com/apache/paimon-rust.git@main#subdirectory=bindings/python";
+          python -m pip install --upgrade 
"git+https://github.com/apache/paimon-rust.git@aa60a6ac7cbe9299aca19effd9ba8301c1bc55da#subdirectory=bindings/python";
 
       - name: Install Python test dependencies
         run: |
@@ -213,30 +213,27 @@ jobs:
             vortex-data==0.70.0 'paimon-mosaic>=0.1.0'
           python -m pip check
 
-      - name: Verify upstream native planning APIs
+      - name: Verify native wheel provenance
         run: |
           python - <<'PY'
           import importlib.metadata as metadata
           import json
 
-          from pypaimon_rust.datafusion import PaimonCatalog, ReadBuilder, 
Split
+          import pypaimon_rust.datafusion
 
           distribution = metadata.distribution('pypaimon-rust')
           source = json.loads(distribution.read_text('direct_url.json'))
           print('pypaimon-rust', distribution.version,
                 'commit', source['vcs_info']['commit_id'])
-          assert hasattr(PaimonCatalog, 'get_table'), 'Missing 
PaimonCatalog.get_table'
-          assert hasattr(Split, 'serialize'), 'Missing Split.serialize'
-          assert hasattr(Split, 'is_streaming'), 'Missing stream-aware 
Split.is_streaming'
-          assert hasattr(ReadBuilder, 'new_incremental_scan'), 'Missing 
ReadBuilder.new_incremental_scan'
-          assert hasattr(ReadBuilder, 'with_row_ranges'), 'Missing 
ReadBuilder.with_row_ranges'
           PY
 
-      - name: Run Python tests with Rust planning and reading
+      - name: Run Python tests with native planning, reading, writing and 
committing
         shell: bash
         env:
           PYPAIMON_TEST_NATIVE_PLAN: '1'
           PYPAIMON_TEST_NATIVE_READ: '1'
+          PYPAIMON_TEST_NATIVE_WRITE: '1'
+          PYPAIMON_TEST_NATIVE_COMMIT: '1'
         run: |
           bash paimon-python/dev/lint-python.sh -i pytest
 
diff --git a/paimon-python/conftest.py b/paimon-python/conftest.py
index b5090ed771..272428f9e9 100644
--- a/paimon-python/conftest.py
+++ b/paimon-python/conftest.py
@@ -21,10 +21,16 @@ import pytest
 
 _NATIVE_PLAN_ENV = "PYPAIMON_TEST_NATIVE_PLAN"
 _NATIVE_READ_ENV = "PYPAIMON_TEST_NATIVE_READ"
+_NATIVE_WRITE_ENV = "PYPAIMON_TEST_NATIVE_WRITE"
+_NATIVE_COMMIT_ENV = "PYPAIMON_TEST_NATIVE_COMMIT"
 _native_plan_count = 0
 _native_read_count = 0
+_native_write_count = 0
+_native_commit_count = 0
 _force_native_for_test = False
 _force_native_read_for_test = False
+_force_native_write_for_test = False
+_force_native_commit_for_test = False
 
 
 def pytest_addoption(parser):
@@ -34,6 +40,31 @@ def pytest_addoption(parser):
     )
 
 
[email protected]
+def native_rest_catalog(tmp_path):
+    """A local REST catalog for native writer and committer integration 
tests."""
+    import uuid
+
+    from pypaimon import CatalogFactory
+    from pypaimon.api.api_response import ConfigResponse
+    from pypaimon.api.auth import BearTokenAuthProvider
+    from pypaimon.tests.rest.rest_server import RESTCatalogServer
+
+    token = str(uuid.uuid4())
+    server = RESTCatalogServer(
+        data_path=str(tmp_path), auth_provider=BearTokenAuthProvider(token),
+        config=ConfigResponse(defaults={'prefix': 'native-test'}), 
warehouse='warehouse')
+    server.start()
+    try:
+        catalog = CatalogFactory.create({
+            'metastore': 'rest', 'uri': server.get_url(), 'warehouse': 
'warehouse',
+            'token.provider': 'bear', 'token': token, 'data-token.enabled': 
'false'})
+        catalog.create_database('default', True)
+        yield catalog
+    finally:
+        server.shutdown()
+
+
 def _native_plan_enabled():
     return os.environ.get(_NATIVE_PLAN_ENV) == "1"
 
@@ -42,6 +73,14 @@ def _native_read_enabled():
     return os.environ.get(_NATIVE_READ_ENV) == "1"
 
 
+def _native_write_enabled():
+    return os.environ.get(_NATIVE_WRITE_ENV) == "1"
+
+
+def _native_commit_enabled():
+    return os.environ.get(_NATIVE_COMMIT_ENV) == "1"
+
+
 def pytest_configure(config):
     config.addinivalue_line(
         "markers", "python_plan: keep Python planner assertions on the Python 
lane")
@@ -49,6 +88,10 @@ def pytest_configure(config):
         "markers", "python_read: keep Python reader assertions on the Python 
lane")
     config.addinivalue_line(
         "markers", "native_plan: exercise the real Rust planner in the Rust 
main CI job")
+    config.addinivalue_line(
+        "markers", "python_write: keep Python writer assertions on the Python 
lane")
+    config.addinivalue_line(
+        "markers", "python_commit: keep Python committer assertions on the 
Python lane")
     if _native_plan_enabled():
         from pypaimon.read.table_scan import TableScan
 
@@ -77,6 +120,35 @@ def pytest_configure(config):
 
         TableRead._try_native_batches = tracked_read
 
+    if _native_write_enabled():
+        from pypaimon.write.native_write import NativeTableWrite
+
+        original_write = NativeTableWrite.write_arrow_batch
+
+        def tracked_write(self, data):
+            global _native_write_count
+            native = self._native_writer is not None
+            result = original_write(self, data)
+            if native and data.num_rows and _force_native_write_for_test:
+                _native_write_count += 1
+            return result
+
+        NativeTableWrite.write_arrow_batch = tracked_write
+
+    if _native_commit_enabled():
+        from pypaimon.write.table_commit import TableCommit
+
+        original_prepare = TableCommit._prepare_native_commit
+
+        def tracked_prepare(self, messages):
+            global _native_commit_count
+            prepared = original_prepare(self, messages)
+            if prepared is not None and _force_native_commit_for_test:
+                _native_commit_count += 1
+            return prepared
+
+        TableCommit._prepare_native_commit = tracked_prepare
+
 
 def pytest_collection_modifyitems(items):
     if _native_plan_enabled():
@@ -88,17 +160,22 @@ def pytest_collection_modifyitems(items):
 
 
 @pytest.fixture(autouse=True)
-def enable_native_plan_and_read(request, monkeypatch):
+def enable_native_backends(request, monkeypatch):
     global _force_native_for_test, _force_native_read_for_test
+    global _force_native_write_for_test, _force_native_commit_for_test
     python_plan = request.node.get_closest_marker("python_plan") is not None
     python_read = request.node.get_closest_marker("python_read") is not None
+    python_write = request.node.get_closest_marker("python_write") is not None
+    python_commit = request.node.get_closest_marker("python_commit") is not 
None
     native_plan_test = request.path.name in (
         "native_plan_test.py", "native_plan_integration_test.py",
         "native_plan_capabilities_test.py")
     force_plan = _native_plan_enabled() and not python_plan and not 
native_plan_test
     force_read = (_native_read_enabled() and not python_plan and not 
python_read
                   and not native_plan_test)
-    if not (force_plan or force_read):
+    force_write = _native_write_enabled() and not python_write
+    force_commit = _native_commit_enabled() and not python_commit
+    if not (force_plan or force_read or force_write or force_commit):
         yield
         return
 
@@ -118,19 +195,39 @@ def enable_native_plan_and_read(request, monkeypatch):
             return original_read(self, True if default is None else default)
 
         monkeypatch.setattr(CoreOptions, "native_read_enabled", read_enabled)
+    if force_write:
+        original_write = CoreOptions.native_write_enabled
+
+        def write_enabled(self, default=None):
+            return original_write(self, True if default is None else default)
+
+        monkeypatch.setattr(CoreOptions, "native_write_enabled", write_enabled)
+    if force_commit:
+        original_commit = CoreOptions.native_commit_enabled
+
+        def commit_enabled(self, default=None):
+            return original_commit(self, True if default is None else default)
+
+        monkeypatch.setattr(CoreOptions, "native_commit_enabled", 
commit_enabled)
     _force_native_for_test = force_plan
     _force_native_read_for_test = force_read
+    _force_native_write_for_test = force_write
+    _force_native_commit_for_test = force_commit
     try:
         yield
     finally:
         _force_native_for_test = False
         _force_native_read_for_test = False
+        _force_native_write_for_test = False
+        _force_native_commit_for_test = False
 
 
 def pytest_sessionfinish(session, exitstatus):
     if exitstatus == 0:
         if ((_native_plan_enabled() and _native_plan_count == 0)
-                or (_native_read_enabled() and _native_read_count == 0)):
+                or (_native_read_enabled() and _native_read_count == 0)
+                or (_native_write_enabled() and _native_write_count == 0)
+                or (_native_commit_enabled() and _native_commit_count == 0)):
             session.exitstatus = pytest.ExitCode.TESTS_FAILED
 
 
@@ -141,3 +238,9 @@ def pytest_terminal_summary(terminalreporter):
     if _native_read_enabled():
         terminalreporter.write_line(
             "native reads exercised: %d" % _native_read_count)
+    if _native_write_enabled():
+        terminalreporter.write_line(
+            "native writes exercised: %d" % _native_write_count)
+    if _native_commit_enabled():
+        terminalreporter.write_line(
+            "native commits exercised: %d" % _native_commit_count)
diff --git a/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py 
b/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
index 572f90a54b..ecb7db7e74 100644
--- a/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
+++ b/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
@@ -294,9 +294,17 @@ def _file_format_dataset(file_io: FileIO, file_format: 
str, file_path: str,
             fragment_options = {}
             if known_size is not None and not _pyarrow_lt_7():
                 fragment_options["file_size"] = known_size
-            fragment = parquet_format.make_fragment(
-                file_path_for_pyarrow, filesystem=filesystem,
-                **fragment_options)
+            try:
+                fragment = parquet_format.make_fragment(
+                    file_path_for_pyarrow, filesystem=filesystem,
+                    **fragment_options)
+            except TypeError as error:
+                # PyArrow 7-12 expose make_fragment but do not accept the
+                # file_size hint. The handler already received the size.
+                if not fragment_options or 'file_size' not in str(error):
+                    raise
+                fragment = parquet_format.make_fragment(
+                    file_path_for_pyarrow, filesystem=filesystem)
             # Reuse this fragment's footer for schema discovery and scanning.
             return ds.FileSystemDataset(
                 [fragment], fragment.physical_schema, parquet_format, 
filesystem)
diff --git a/paimon-python/pypaimon/read/scanner/split_generator.py 
b/paimon-python/pypaimon/read/scanner/split_generator.py
index 100fbec2e9..ac0e15625f 100644
--- a/paimon-python/pypaimon/read/scanner/split_generator.py
+++ b/paimon-python/pypaimon/read/scanner/split_generator.py
@@ -26,6 +26,7 @@ from pypaimon.read.split import Split
 from pypaimon.read.split import DataSplit
 from pypaimon.table.row.generic_row import GenericRow
 from pypaimon.table.source.deletion_file import DeletionFile
+from pypaimon.utils.file_store_path_factory import canonical_data_file_path
 
 
 class AbstractSplitGenerator(ABC):
@@ -94,6 +95,13 @@ class AbstractSplitGenerator(ABC):
         Build splits from packed files.
         """
         splits = []
+        if not packed_files or not file_entries:
+            return splits
+        partition = tuple(file_entries[0].partition.values)
+        path_factory = self.table.path_factory()
+        escaped_partition = (path_factory.bucket_path(
+            partition, file_entries[0].bucket, canonical_partition=True)
+            != path_factory.bucket_path(partition, file_entries[0].bucket))
         for file_group in packed_files:
             if use_optimized_path:
                 raw_convertible = True
@@ -109,6 +117,12 @@ class AbstractSplitGenerator(ABC):
                     file_entries[0].bucket,
                     self.default_part_value
                 )
+                if escaped_partition and not data_file.external_path:
+                    canonical_path = canonical_data_file_path(
+                        self.table, partition, file_entries[0].bucket,
+                        data_file.file_name)
+                    if self.table.file_io.exists(canonical_path):
+                        data_file.file_path = canonical_path
 
             if file_group:
                 # Get deletion files for this split
diff --git a/paimon-python/pypaimon/tests/binary_row_test.py 
b/paimon-python/pypaimon/tests/binary_row_test.py
index 4fbe5940e2..5e8420e9eb 100644
--- a/paimon-python/pypaimon/tests/binary_row_test.py
+++ b/paimon-python/pypaimon/tests/binary_row_test.py
@@ -269,11 +269,16 @@ class BinaryRowTest(unittest.TestCase):
         manifest_files = 
file_scanner.manifest_list_manager.read_all(latest_snapshot)
         manifest_entries = 
file_scanner.manifest_file_manager.read(manifest_files[0].file_name)
         self._transform_manifest_entries(manifest_entries, [])
-        for i, entry in enumerate(manifest_entries):
+        for entry in manifest_entries:
+            # Manifest entry order is not part of the file format. Derive the
+            # synthetic statistics from this file's partition value.
+            partition_id = entry.partition.values[0]
             entry.file.value_stats_cols = ['f2', 'f6', 'f8']
             entry.file.value_stats = SimpleStats(
-                GenericRow([10 * (i + 1), 100 * (i + 1), 5 - i], 
[table.fields[2], table.fields[6], table.fields[8]]),
-                GenericRow([10 * (i + 1), 100 * (i + 1), 5 - i], 
[table.fields[2], table.fields[6], table.fields[8]]),
+                GenericRow([10 * partition_id, 100 * partition_id, 6 - 
partition_id],
+                           [table.fields[2], table.fields[6], 
table.fields[8]]),
+                GenericRow([10 * partition_id, 100 * partition_id, 6 - 
partition_id],
+                           [table.fields[2], table.fields[6], 
table.fields[8]]),
                 [0, 0, 0],
             )
         file_scanner.manifest_file_manager.write(manifest_files[0].file_name, 
manifest_entries)
@@ -301,18 +306,21 @@ class BinaryRowTest(unittest.TestCase):
                          'f8': [0, -3, -4],
                          'f9': ['w5', 'w8', 'w9']
                          }
-        self.assertEqual(expected_data, actual.to_pydict())
+        self.assertEqual(expected_data, actual.sort_by('f0').to_pydict())
 
         file_scanner = FileScanner(table, lambda: ([], None))
         latest_snapshot = file_scanner.snapshot_manager.get_latest_snapshot()
         manifest_files = 
file_scanner.manifest_list_manager.read_all(latest_snapshot)
         manifest_entries = 
file_scanner.manifest_file_manager.read(manifest_files[0].file_name)
         self._transform_manifest_entries(manifest_entries, [])
-        for i, entry in enumerate(manifest_entries):
+        for entry in manifest_entries:
+            partition_id = entry.partition.values[0]
             entry.file.value_stats_cols = ['f2', 'f6', 'f8']
             entry.file.value_stats = SimpleStats(
-                GenericRow([0, 100 * (i + 1), 5 - i], [table.fields[2], 
table.fields[6], table.fields[8]]),
-                GenericRow([0, 100 * (i + 1), 5 - i], [table.fields[2], 
table.fields[6], table.fields[8]]),
+                GenericRow([0, 100 * partition_id, 6 - partition_id],
+                           [table.fields[2], table.fields[6], 
table.fields[8]]),
+                GenericRow([0, 100 * partition_id, 6 - partition_id],
+                           [table.fields[2], table.fields[6], 
table.fields[8]]),
                 [0, 0, 0],
             )
         file_scanner.manifest_file_manager.write(manifest_files[0].file_name, 
manifest_entries)
diff --git a/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py 
b/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
index bc797398e3..f2a5405621 100644
--- a/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
+++ b/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
@@ -23,6 +23,7 @@ import uuid
 from unittest.mock import Mock
 
 import pyarrow as pa
+import pytest
 
 from pypaimon import CatalogFactory, Schema
 from pypaimon.common.uri_reader import FileUriReader
@@ -193,6 +194,7 @@ class DataEvolutionRowRollingTest(unittest.TestCase):
         self.assertEqual([1, 1, 1, 1], [f.row_count for f in files])
         self.assertEqual(list(range(4)), self._read_ids(table))
 
+    @pytest.mark.python_write
     def test_non_de_table_still_fails_fast(self):
         table = self._create({'target-file-row-num': '3'})
         wb = table.new_batch_write_builder()
diff --git a/paimon-python/pypaimon/tests/native_commit_test.py 
b/paimon-python/pypaimon/tests/native_commit_test.py
index 8dcf130fd5..c996973b14 100644
--- a/paimon-python/pypaimon/tests/native_commit_test.py
+++ b/paimon-python/pypaimon/tests/native_commit_test.py
@@ -33,6 +33,9 @@ from pypaimon.write.native_commit import (
 from pypaimon.write.table_write import StreamTableWrite
 
 
+pytestmark = pytest.mark.python_write
+
+
 def requires_native(test):
     # Native commit needs the from-source paimon-rust runtime built in the Rust
     # Plan CI job; the PyPI pypaimon-rust wheel used by the standard test job
@@ -42,12 +45,13 @@ def requires_native(test):
         not native_commit_available(), reason='pypaimon-rust runtime 
required')(test)
 
 
-def _table(tmp_path, mode='append', backend='filesystem'):
+def _table(tmp_path, mode='append', backend='filesystem', catalog=None):
     options = {'warehouse': str(tmp_path / 'warehouse')}
     if backend == 'jdbc':
         options.update({'metastore': 'jdbc', 'uri': 'jdbc:sqlite:' + 
str(tmp_path / 'catalog.db')})
-    catalog = CatalogFactory.create(options)
-    catalog.create_database('default', True)
+    if catalog is None:
+        catalog = CatalogFactory.create(options)
+        catalog.create_database('default', True)
     options = {'file.format': 'parquet', 'commit.native.enabled': 'true'}
     if mode == 'pk':
         options['bucket'] = '1'
@@ -95,11 +99,25 @@ def _seed(table):
         commit.close()
 
 
[email protected]_commit
 def test_native_commit_is_opt_in():
     assert not CoreOptions(Options({})).native_commit_enabled()
     assert CoreOptions(Options({'commit.native.enabled': 
'true'})).native_commit_enabled()
 
 
[email protected]('backend', ['filesystem', 'path', 'jdbc'])
+def test_non_rest_catalog_keeps_python_commit(tmp_path, backend):
+    table = _table(tmp_path, backend=backend)
+    assert create_native_commit(table, 'job') is None
+    builder = table.new_batch_write_builder()
+    commit = builder.new_commit()
+    try:
+        commit.commit(_prepare(builder, [{'id': 1, 'pt': 'a'}]))
+        assert _rows(table) == [{'id': 1, 'pt': 'a'}]
+    finally:
+        commit.close()
+
+
 def test_overwrite_builder_api_is_batch_only(tmp_path):
     table = _table(tmp_path)
     batch = table.new_batch_write_builder()
@@ -113,10 +131,9 @@ def test_overwrite_builder_api_is_batch_only(tmp_path):
 
 
 @requires_native
[email protected]('backend', ['filesystem', 'path', 'jdbc'])
[email protected]('mode', ['append', 'pk', 'de'])
-def test_native_batch_roundtrip_preserves_identity(tmp_path, backend, mode):
-    table = _table(tmp_path, mode, backend)
[email protected]('mode', ['append', 'pk'])
+def test_native_batch_roundtrip_preserves_identity(tmp_path, 
native_rest_catalog, mode):
+    table = _table(tmp_path, mode, catalog=native_rest_catalog)
     builder = table.new_batch_write_builder()
     rows = [{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': None}]
     messages = _prepare(builder, rows)
@@ -135,8 +152,15 @@ def 
test_native_batch_roundtrip_preserves_identity(tmp_path, backend, mode):
 
 
 @requires_native
-def test_native_stream_reuses_commit_user_and_identifiers(tmp_path):
-    table = _table(tmp_path)
+def test_replaced_rest_table_rejects_stale_commit(tmp_path, 
native_rest_catalog):
+    table = _table(tmp_path, catalog=native_rest_catalog)
+    table.catalog_environment.uuid = 'stale-table-id'
+    assert create_native_commit(table, 'job') is None
+
+
+@requires_native
+def test_native_stream_reuses_commit_user_and_identifiers(tmp_path, 
native_rest_catalog):
+    table = _table(tmp_path, catalog=native_rest_catalog)
     builder = table.new_stream_write_builder()
     commit = builder.new_commit()
     try:
@@ -155,12 +179,12 @@ def 
test_native_stream_reuses_commit_user_and_identifiers(tmp_path):
 @pytest.mark.parametrize('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({
+def test_native_overwrite_replaces_only_target_rows(
+        tmp_path, native_rest_catalog, mode, dynamic, spec):
+    table = _table(tmp_path, mode, catalog=native_rest_catalog).copy({
         'dynamic-partition-overwrite': str(dynamic).lower(), 
'commit.user-prefix': 'python'})
     _seed(table)
     builder = table.new_batch_write_builder().overwrite(spec)
@@ -193,8 +217,10 @@ def 
test_native_overwrite_replaces_only_target_rows(tmp_path, mode, dynamic, spe
     ('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()})
+def test_native_empty_overwrite_semantics(
+        tmp_path, native_rest_catalog, mode, dynamic, spec, remaining):
+    table = _table(tmp_path, mode, catalog=native_rest_catalog).copy({
+        'dynamic-partition-overwrite': str(dynamic).lower()})
     _seed(table)
     builder = table.new_batch_write_builder().overwrite(spec)
     commit = builder.new_commit()
@@ -215,8 +241,8 @@ def test_native_empty_overwrite_semantics(tmp_path, mode, 
dynamic, spec, remaini
 
 @requires_native
 @pytest.mark.parametrize('value', ['off', '0', ' false '])
-def test_native_overwrite_normalizes_python_boolean_option(tmp_path, value):
-    table = _table(tmp_path).copy({
+def test_native_overwrite_normalizes_python_boolean_option(tmp_path, 
native_rest_catalog, value):
+    table = _table(tmp_path, catalog=native_rest_catalog).copy({
         'dynamic-partition-overwrite': value, 'snapshot.ignore-empty-commit': 
value})
     _seed(table)
     commit = table.new_batch_write_builder().overwrite({'pt': 
'a'}).new_commit()
@@ -230,13 +256,14 @@ def 
test_native_overwrite_normalizes_python_boolean_option(tmp_path, value):
 
 @pytest.mark.parametrize('native', [False, pytest.param(True, 
marks=pytest.mark.native_plan)])
 @pytest.mark.parametrize('case', ['unpartitioned', 'static-empty', 
'static-missing', 'dynamic-empty'])
-def test_empty_overwrite_records_java_snapshot(tmp_path, native, case):
+def test_empty_overwrite_records_java_snapshot(tmp_path, native_rest_catalog, 
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(),
-    })
+    table = _table(tmp_path, 'unpartitioned' if case == 'unpartitioned' else 
'append',
+                   catalog=native_rest_catalog).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(
@@ -264,8 +291,10 @@ def test_empty_overwrite_records_java_snapshot(tmp_path, 
native, case):
 @requires_native
 @pytest.mark.parametrize('mode', ['batch', 'stream'])
 @pytest.mark.parametrize('ignore', [True, False])
-def test_native_empty_commit_preserves_python_option(tmp_path, mode, ignore):
-    table = _table(tmp_path).copy({'snapshot.ignore-empty-commit': 
str(ignore).lower()})
+def test_native_empty_commit_preserves_python_option(
+        tmp_path, native_rest_catalog, mode, ignore):
+    table = _table(tmp_path, catalog=native_rest_catalog).copy({
+        'snapshot.ignore-empty-commit': str(ignore).lower()})
     commit = getattr(table, 'new_' + mode + '_write_builder')().new_commit()
     try:
         with _must_not_fallback(commit):
@@ -285,8 +314,8 @@ def 
test_native_empty_commit_preserves_python_option(tmp_path, mode, ignore):
 
 @requires_native
 @pytest.mark.parametrize('overwrite', [False, True])
-def test_native_abort_removes_uncommitted_files(tmp_path, overwrite):
-    table = _table(tmp_path)
+def test_native_abort_removes_uncommitted_files(tmp_path, native_rest_catalog, 
overwrite):
+    table = _table(tmp_path, catalog=native_rest_catalog)
     builder = table.new_batch_write_builder()
     if overwrite:
         builder.overwrite()
@@ -303,6 +332,27 @@ def test_native_abort_removes_uncommitted_files(tmp_path, 
overwrite):
         commit.close()
 
 
+@requires_native
+def test_python_file_in_legacy_partition_uses_python_abort(
+        tmp_path, native_rest_catalog):
+    table = _table(tmp_path, catalog=native_rest_catalog)
+    builder = table.new_batch_write_builder()
+    messages = _prepare(builder, [{'id': 1, 'pt': 'a/b'}])
+    file = messages[0].new_files[0]
+    assert table.file_io.exists(file.file_path)
+    assert not native_messages_supported(table, messages)
+
+    commit = builder.new_commit()
+    try:
+        with patch.object(commit.file_store_commit, 'abort',
+                          wraps=commit.file_store_commit.abort) as 
python_abort:
+            commit.abort(messages)
+        python_abort.assert_called_once_with(messages)
+        assert not table.file_io.exists(file.file_path)
+    finally:
+        commit.close()
+
+
 @pytest.mark.parametrize('failure', ['missing', 'construction', 'conversion'])
 def test_preflight_failure_uses_python(tmp_path, failure):
     table = _table(tmp_path)
@@ -354,8 +404,9 @@ def 
test_native_mutation_failure_never_falls_back_or_aborts(tmp_path, method, ov
 
 @requires_native
 @pytest.mark.parametrize('overwrite', [False, True])
-def 
test_publication_response_loss_does_not_duplicate_or_delete_files(tmp_path, 
overwrite):
-    table = _table(tmp_path)
+def test_publication_response_loss_does_not_duplicate_or_delete_files(
+        tmp_path, native_rest_catalog, overwrite):
+    table = _table(tmp_path, catalog=native_rest_catalog)
     builder = table.new_batch_write_builder()
     if overwrite:
         builder.overwrite()
@@ -399,10 +450,11 @@ def 
test_snapshot_properties_select_python_before_native(tmp_path, properties, o
 
 
 @pytest.mark.parametrize('warmup', [False, pytest.param(True, 
marks=pytest.mark.native_plan)])
-def test_callbacks_added_after_construction_select_python(tmp_path, warmup):
+def test_callbacks_added_after_construction_select_python(
+        tmp_path, native_rest_catalog, warmup):
     if warmup and not native_commit_available():
         pytest.skip('native warmup requires the commit bindings')
-    table = _table(tmp_path)
+    table = _table(tmp_path, catalog=native_rest_catalog)
     builder = table.new_stream_write_builder()
     commit = builder.new_commit()
     if warmup:
@@ -478,12 +530,11 @@ def 
test_disabled_option_never_initializes_native(tmp_path):
     commit.close()
 
 
[email protected]('kind', ['version-managed', 'custom-env', 'branch', 
'custom-io'])
-def test_incompatible_publication_environment_is_not_reconstructed(tmp_path, 
kind):
-    table = _table(tmp_path)
-    if kind == 'version-managed':
-        table.catalog_environment.supports_version_management = True
-    elif kind == 'custom-env':
[email protected]('kind', ['custom-env', 'branch', 'custom-io'])
+def test_incompatible_publication_environment_is_not_reconstructed(
+        tmp_path, native_rest_catalog, kind):
+    table = _table(tmp_path, catalog=native_rest_catalog)
+    if kind == 'custom-env':
         class CustomEnvironment(CatalogEnvironment):
             pass
         table.catalog_environment = CustomEnvironment()
@@ -492,43 +543,37 @@ def 
test_incompatible_publication_environment_is_not_reconstructed(tmp_path, kin
     else:
         table.file_io = Mock()
     with patch('pypaimon.write.native_commit.native_commit_available', 
return_value=True), \
-            
patch('pypaimon.write.native_commit._resolved_schema_file_io_options') as 
resolve:
+            patch('pypaimon.write.native_commit._native_rest_table') as 
resolve:
         assert create_native_commit(table, 'job') is None
         resolve.assert_not_called()
 
 
[email protected]('missing_type,missing_method', [
-    ('Table', 'from_resolved_schema'),
-    ('CommitMessage', 'deserialize'),
-    ('StreamWriteBuilder', 'with_commit_user'),
-    ('BatchWriteBuilder', '_with_commit_user'),
-    ('BatchWriteBuilder', 'with_overwrite'),
-])
-def test_incomplete_runtime_falls_back_without_reconstructing_table(
-        tmp_path, missing_type, missing_method):
-    table = _table(tmp_path)
-    with patch('pypaimon.write.native_commit.native_method_available',
-               side_effect=lambda cls, method: (cls, method) != (missing_type, 
missing_method)), \
-            
patch('pypaimon.write.native_commit._resolved_schema_file_io_options') as 
resolve:
-        assert create_native_commit(table, 'job') is None
-        resolve.assert_not_called()
-
-
-def test_missing_runtime_falls_back_without_reconstructing_table(tmp_path):
-    table = _table(tmp_path)
+def test_missing_runtime_falls_back_without_reconstructing_table(
+        tmp_path, native_rest_catalog):
+    table = _table(tmp_path, catalog=native_rest_catalog)
     with patch('pypaimon.write.native_commit.native_commit_available', 
return_value=False), \
-            
patch('pypaimon.write.native_commit._resolved_schema_file_io_options') as 
resolve:
+            patch('pypaimon.write.native_commit._native_rest_table') as 
resolve:
         assert create_native_commit(table, 'job') is None
         resolve.assert_not_called()
 
 
-def 
test_partial_row_id_and_compact_messages_preserve_python_recovery(tmp_path):
-    table = _table(tmp_path, 'de')
+def test_partial_row_id_and_compact_messages_preserve_python_recovery(
+        tmp_path, native_rest_catalog):
+    table = _table(tmp_path, 'de', catalog=native_rest_catalog)
+    assert create_native_commit(table, 'job') is None
+    assert not native_messages_supported(table, [CommitMessage((), 0, [])])
     assert not native_messages_supported(table, [CommitMessage((), 0, [], 
check_from_snapshot=7)])
     assert not native_messages_supported(table, [CommitMessage((), 0, 
[Mock(first_row_id=1)])])
     assert not native_messages_supported(table, [CommitMessage((), 0, [], 
compact_after=[Mock()])])
 
 
+def test_custom_manifest_target_uses_python_rolling(tmp_path, 
native_rest_catalog):
+    table = _table(tmp_path, catalog=native_rest_catalog).copy({
+        'manifest.target-file-size': '16 kb'})
+    assert create_native_commit(table, 'job') is None
+    assert not native_messages_supported(table, [CommitMessage((), 0, [])])
+
+
 def test_close_releases_python_resources_even_if_native_close_fails(tmp_path):
     commit = _table(tmp_path).new_batch_write_builder().new_commit()
     commit._native_commit = Mock()
diff --git a/paimon-python/pypaimon/tests/native_plan_integration_test.py 
b/paimon-python/pypaimon/tests/native_plan_integration_test.py
index aa22c9a7ba..3f6cf52fe1 100644
--- a/paimon-python/pypaimon/tests/native_plan_integration_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_integration_test.py
@@ -1499,6 +1499,7 @@ class NativePlanIntegrationTest(unittest.TestCase):
         self.assertEqual(native.split_count, len(normal.splits()))
         self.assertEqual(native.split_count, 1)
 
+    @pytest.mark.python_write
     def test_partitioned_table_matches_normal_plan(self):
         # Native decoding restores PyPaimon's legacy unescaped partition path.
         schema = pa.schema([('k', pa.int64()), ('p', pa.string())])
diff --git a/paimon-python/pypaimon/tests/native_write_test.py 
b/paimon-python/pypaimon/tests/native_write_test.py
index 7224dbe09a..1b0f4972f3 100644
--- a/paimon-python/pypaimon/tests/native_write_test.py
+++ b/paimon-python/pypaimon/tests/native_write_test.py
@@ -24,20 +24,20 @@ import pytest
 from pypaimon import CatalogFactory, Schema
 from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.common.options.options import Options
-from pypaimon.write.native_write import NativeTableWrite, 
native_write_available
+from pypaimon.write.native_write import NativeTableWrite
 
 
-requires_native = pytest.mark.skipif(
-    not native_write_available(), reason='pypaimon-rust writer required')
+requires_native = pytest.mark.native_plan
 
 
-def _table(tmp_path, primary_key=False, commit_native=True):
+def _table(tmp_path, primary_key=False, commit_native=True, 
table_options=None):
     catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
     catalog.create_database('default', True)
     options = {'file.format': 'parquet', 'write.native.enabled': 'true',
                'commit.native.enabled': str(commit_native).lower()}
     if primary_key:
         options['bucket'] = '1'
+    options.update(table_options or {})
     catalog.create_table('default.t', Schema.from_pyarrow_schema(
         pa.schema([('id', pa.int64()), ('pt', pa.string())]),
         options=options, primary_keys=['id'] if primary_key else [],
@@ -57,6 +57,7 @@ def _rows(table):
         builder.new_scan().plan().splits()).to_pylist(), key=lambda row: 
row['id'])
 
 
[email protected]_write
 def test_native_write_is_opt_in():
     assert not CoreOptions(Options({})).native_write_enabled()
     assert CoreOptions(Options({'write.native.enabled': 
'true'})).native_write_enabled()
@@ -77,6 +78,8 @@ def test_batch_native_write_commits_through_both_committers(
         messages = writer.prepare_commit()
         assert messages and sum(file.row_count for msg in messages
                                 for file in msg.new_files) == 2
+        assert all(file.file_path and table.file_io.exists(file.file_path)
+                   for msg in messages for file in msg.new_files)
         commit = builder.new_commit()
         try:
             commit.commit(messages)
@@ -88,6 +91,82 @@ def test_batch_native_write_commits_through_both_committers(
     assert table.snapshot_manager().get_latest_snapshot().commit_user == 
builder.commit_user
 
 
+@requires_native
+def test_escaped_partition_file_path_and_abort(tmp_path, native_rest_catalog):
+    catalog = native_rest_catalog
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        pa.schema([('id', pa.int64()), ('pt', pa.string())]),
+        options={'file.format': 'parquet', 'write.native.enabled': 'true',
+                 'commit.native.enabled': 'true'}, partition_keys=['pt']), 
False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    assert isinstance(writer, NativeTableWrite)
+    try:
+        writer.write_arrow_batch(_batch([1], ['a/b']))
+        messages = writer.prepare_commit()
+        file = messages[0].new_files[0]
+        assert 'pt=a%2Fb/bucket-0' in file.file_path
+        assert table.file_io.exists(file.file_path)
+
+        commit = builder.new_commit()
+        try:
+            with patch.object(commit.file_store_commit, 'abort',
+                              side_effect=AssertionError('Python fallback')):
+                commit.abort(messages)
+            assert not table.file_io.exists(file.file_path)
+        finally:
+            commit.close()
+    finally:
+        writer.close()
+
+
+@requires_native
[email protected]_plan
[email protected]_read
+def test_escaped_partition_native_write_is_readable_by_python(tmp_path):
+    table = _table(tmp_path)
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        writer.write_arrow_batch(_batch([1], ['a/b']))
+        messages = writer.prepare_commit()
+        builder.new_commit().commit(messages)
+    finally:
+        writer.close()
+    assert _rows(table) == [{'id': 1, 'pt': 'a/b'}]
+
+
+@requires_native
+def test_rest_native_write_and_commit(tmp_path, native_rest_catalog):
+    catalog = native_rest_catalog
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        pa.schema([('id', pa.int64()), ('pt', pa.string())]),
+        options={'file.format': 'parquet', 'write.native.enabled': 'true',
+                 'commit.native.enabled': 'true'}, partition_keys=['pt']), 
False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    assert isinstance(writer, NativeTableWrite)
+    commit = builder.new_commit()
+    try:
+        writer.write_arrow_batch(_batch([1, 2], ['a', 'b']))
+        with patch.object(commit.file_store_commit, 'commit',
+                          side_effect=AssertionError('Python commit 
fallback')):
+            commit.commit(writer.prepare_commit())
+        assert _rows(table) == [{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}]
+        shard_rows = []
+        for shard in range(3):
+            read_builder = table.new_read_builder()
+            splits = read_builder.new_scan().with_shard(shard, 
3).plan().splits()
+            
shard_rows.extend(read_builder.new_read().to_arrow(splits).to_pylist())
+        assert sorted(shard_rows, key=lambda row: row['id']) == _rows(table)
+        assert table.snapshot_manager().get_latest_snapshot().commit_user == 
builder.commit_user
+    finally:
+        writer.close()
+        commit.close()
+
+
 @requires_native
 def test_stream_native_write_reuses_writer_across_checkpoints(tmp_path):
     table = _table(tmp_path)
@@ -162,3 +241,81 @@ def 
test_unavailable_native_writer_falls_back_before_table_reconstruction(tmp_pa
         writer = table.new_batch_write_builder().new_write()
     assert not isinstance(writer, NativeTableWrite)
     writer.close()
+
+
+@requires_native
[email protected]('primary_key', [False, True])
+def test_custom_prefix_uses_native_writer(tmp_path, primary_key):
+    table = _table(tmp_path, primary_key=primary_key).copy({
+        'data-file.prefix': 'custom-'})
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    assert isinstance(writer, NativeTableWrite)
+    try:
+        writer.write_arrow_batch(_batch([1], ['a']))
+        messages = writer.prepare_commit()
+        assert messages
+        assert all(file.file_name.startswith('custom-')
+                   for message in messages for file in message.new_files)
+        builder.new_commit().commit(messages)
+    finally:
+        writer.close()
+    assert _rows(table) == [{'id': 1, 'pt': 'a'}]
+
+
+def test_external_data_paths_fall_back_before_native_write(tmp_path):
+    table = _table(tmp_path).copy({
+        'data-file.external-paths': 'file://' + str(tmp_path / 'external')})
+    with patch('pypaimon.write.native_write.native_write_available', 
return_value=True), \
+            patch('pypaimon.write.native_write.create_native_write_table',
+                  side_effect=AssertionError('must not reconstruct')):
+        writer = table.new_batch_write_builder().new_write()
+    assert not isinstance(writer, NativeTableWrite)
+    writer.close()
+
+
[email protected]('options', [
+    {'bucket': '-1'},
+    {'merge-engine': 'first-row'},
+    {'merge-engine': 'partial-update'},
+    {'merge-engine': 'aggregation'},
+    {'target-file-row-num': '5'},
+    {'changelog-file.format': 'orc'},
+    {'metadata.stats-mode': 'full'},
+])
+def test_unsupported_primary_key_write_falls_back_before_native_reconstruction(
+        tmp_path, options):
+    table = (_table(tmp_path, primary_key=True, table_options=options)
+             if 'bucket' in options else
+             _table(tmp_path, primary_key=True).copy(options))
+    with patch('pypaimon.write.native_write.native_write_available', 
return_value=True), \
+            patch('pypaimon.write.native_write.create_native_write_table',
+                  side_effect=AssertionError('must not reconstruct')):
+        writer = table.new_batch_write_builder().new_write()
+    assert not isinstance(writer, NativeTableWrite)
+    writer.close()
+
+
+@requires_native
+def test_native_write_validates_input_schema_before_writing(tmp_path):
+    table = _table(tmp_path)
+    writer = table.new_batch_write_builder().new_write()
+    assert isinstance(writer, NativeTableWrite)
+    wrong = pa.record_batch([pa.array([1], pa.int32()), pa.array(['a'])],
+                            names=['id', 'pt'])
+    with pytest.raises(ValueError, match="Input schema isn't consistent"):
+        writer.write_arrow_batch(wrong)
+    assert not writer._written
+    writer.abort()
+
+
[email protected]('engine', ['partial-update', 'aggregation'])
+def test_deletion_vectors_with_merge_engine_fall_back(tmp_path, engine):
+    table = _table(tmp_path).copy({
+        'deletion-vectors.enabled': 'true', 'merge-engine': engine})
+    with patch('pypaimon.write.native_write.native_write_available', 
return_value=True), \
+            patch('pypaimon.write.native_write.create_native_write_table',
+                  side_effect=AssertionError('must not reconstruct')):
+        writer = table.new_batch_write_builder().new_write()
+    assert not isinstance(writer, NativeTableWrite)
+    writer.close()
diff --git a/paimon-python/pypaimon/tests/parquet_metadata_cache_test.py 
b/paimon-python/pypaimon/tests/parquet_metadata_cache_test.py
index 711e73191e..3b5db10c24 100644
--- a/paimon-python/pypaimon/tests/parquet_metadata_cache_test.py
+++ b/paimon-python/pypaimon/tests/parquet_metadata_cache_test.py
@@ -266,6 +266,25 @@ class FileFormatMetadataCacheTest(unittest.TestCase):
         self.assertIn(
             ("register_file_size", self.paths[0], file_size), handler.calls)
 
+    def test_old_pyarrow_retries_fragment_without_file_size(self):
+        parquet_format = unittest.mock.Mock()
+        fragment = unittest.mock.Mock(physical_schema=pa.schema([]))
+        parquet_format.make_fragment.side_effect = [
+            TypeError("make_fragment() got an unexpected keyword argument 
'file_size'"),
+            fragment,
+        ]
+        with patch.object(reader_module, "_pyarrow_lt_7", return_value=False), 
\
+                patch.object(reader_module.ds, "ParquetFileFormat", 
return_value=parquet_format), \
+                patch.object(reader_module.ds, "FileSystemDataset",
+                             return_value=unittest.mock.sentinel.dataset):
+            dataset = reader_module._file_format_dataset(
+                self.file_io, "parquet", self.paths[0], 0, 123)
+        self.assertIs(unittest.mock.sentinel.dataset, dataset)
+        self.assertEqual([
+            unittest.mock.call(self.paths[0], 
filesystem=self.file_io.filesystem, file_size=123),
+            unittest.mock.call(self.paths[0], 
filesystem=self.file_io.filesystem),
+        ], parquet_format.make_fragment.call_args_list)
+
     def test_fragment_metadata_is_reused_without_io(self):
         handler = _CountingFileSystemHandler()
         self.file_io.filesystem = pafs.PyFileSystem(handler)
diff --git a/paimon-python/pypaimon/tests/ray_range_join_test.py 
b/paimon-python/pypaimon/tests/ray_range_join_test.py
index 3696bf851f..9a7acfa212 100644
--- a/paimon-python/pypaimon/tests/ray_range_join_test.py
+++ b/paimon-python/pypaimon/tests/ray_range_join_test.py
@@ -418,6 +418,8 @@ class RayRangeJoinTest(unittest.TestCase):
                 name, self.catalog_options, None, "renamed")
         self.assertEqual([(lo, hi) for _, lo, hi in ranged], [(3, 9)])
 
+    @pytest.mark.python_plan
+    @pytest.mark.python_write
     def test_footer_failure_degrades_to_unknown(self):
         schema = pa.schema([("k", pa.int64())])
         self._table("default.rj_footer_failure", schema, [
diff --git 
a/paimon-python/pypaimon/tests/rest/rest_catalog_commit_snapshot_test.py 
b/paimon-python/pypaimon/tests/rest/rest_catalog_commit_snapshot_test.py
index 4b7ac5d8b0..be3b57339a 100644
--- a/paimon-python/pypaimon/tests/rest/rest_catalog_commit_snapshot_test.py
+++ b/paimon-python/pypaimon/tests/rest/rest_catalog_commit_snapshot_test.py
@@ -24,6 +24,7 @@ from dataclasses import replace
 from unittest.mock import Mock, patch
 
 import pyarrow as pa
+import pytest
 
 from pypaimon import Schema
 from pypaimon.api.api_response import CommitTableResponse
@@ -398,6 +399,7 @@ class TestRESTCommit(RESTBaseTest):
         self.assertEqual(
             sorted(actual.column('id').to_pylist()), [1, 2, 3, 4, 5, 6])
 
+    @pytest.mark.python_commit
     def test_commit_succeeded_on_server_but_client_fails(self):
         pa_schema = pa.schema([('id', pa.int32()), ('name', pa.string())])
         opts = {
diff --git a/paimon-python/pypaimon/tests/rest/rest_simple_test.py 
b/paimon-python/pypaimon/tests/rest/rest_simple_test.py
index 77183eef6d..1217661c29 100644
--- a/paimon-python/pypaimon/tests/rest/rest_simple_test.py
+++ b/paimon-python/pypaimon/tests/rest/rest_simple_test.py
@@ -18,6 +18,7 @@
 import sys
 
 import pyarrow as pa
+import pytest
 
 from pypaimon import Schema
 from pypaimon.catalog.catalog_exception import DatabaseAlreadyExistException, 
TableAlreadyExistException, \
@@ -58,6 +59,8 @@ class RESTSimpleTest(RESTBaseTest):
         }
         self.expected = pa.Table.from_pydict(self.data, schema=self.pa_schema)
 
+    @pytest.mark.python_write
+    @pytest.mark.python_commit
     def test_with_shard_ao_unaware_bucket(self):
         schema = Schema.from_pyarrow_schema(self.pa_schema, 
partition_keys=['dt'])
         
self.rest_catalog.drop_table('default.test_with_shard_ao_unaware_bucket', True)
@@ -169,6 +172,8 @@ class RESTSimpleTest(RESTBaseTest):
         }, schema=self.pa_schema)
         self.assertEqual(actual, expected)
 
+    @pytest.mark.python_write
+    @pytest.mark.python_commit
     def test_with_shard_ao_fixed_bucket(self):
         schema = Schema.from_pyarrow_schema(self.pa_schema, 
partition_keys=['dt'],
                                             options={'bucket': '5', 
'bucket-key': 'item_id'})
diff --git a/paimon-python/pypaimon/tests/schema_evolution_read_test.py 
b/paimon-python/pypaimon/tests/schema_evolution_read_test.py
index 60705d5cad..ea02304657 100644
--- a/paimon-python/pypaimon/tests/schema_evolution_read_test.py
+++ b/paimon-python/pypaimon/tests/schema_evolution_read_test.py
@@ -61,6 +61,7 @@ class SchemaEvolutionReadTest(unittest.TestCase):
         shutil.rmtree(cls.tempdir, ignore_errors=True)
 
     @pytest.mark.python_plan
+    @pytest.mark.python_write
     def test_schema_evolution(self):
         # schema 0
         pa_schema = pa.schema([
@@ -132,6 +133,7 @@ class SchemaEvolutionReadTest(unittest.TestCase):
         self.assertEqual(expected, actual)
 
     @pytest.mark.python_plan
+    @pytest.mark.python_write
     def test_schema_evolution_type(self):
         # schema 0
         pa_schema = pa.schema([
@@ -428,6 +430,7 @@ class SchemaEvolutionReadTest(unittest.TestCase):
         self.assertEqual(1, len(entries))  # verify scan filter success for 
schema evolution
 
     @pytest.mark.python_plan
+    @pytest.mark.python_write
     def test_schema_evolution_with_read_filter(self):
         # schema 0
         pa_schema = pa.schema([
diff --git a/paimon-python/pypaimon/tests/write/changelog_producer_test.py 
b/paimon-python/pypaimon/tests/write/changelog_producer_test.py
index 88faa792ac..0ac4610ee8 100644
--- a/paimon-python/pypaimon/tests/write/changelog_producer_test.py
+++ b/paimon-python/pypaimon/tests/write/changelog_producer_test.py
@@ -17,14 +17,14 @@
 
################################################################################
 
 import glob
+import json
 import os
 import shutil
 import tempfile
 import unittest
 
 import pyarrow as pa
-
-import json
+import pytest
 
 from pypaimon import CatalogFactory, Schema
 from pypaimon.manifest.manifest_list_manager import ManifestListManager
@@ -337,6 +337,7 @@ class ChangelogProducerTest(unittest.TestCase):
         table_write.close()
         table_commit.close()
 
+    @pytest.mark.python_write
     def test_failed_changelog_write_leaves_nothing_to_commit(self):
         """A data file and its changelog are committed together or not at all.
 
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py 
b/paimon-python/pypaimon/tests/write/table_write_test.py
index 8e1bbffaf2..ee0ee42fd9 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -26,6 +26,7 @@ from unittest.mock import Mock, patch
 
 from pypaimon import CatalogFactory, Schema
 import pyarrow as pa
+import pytest
 from parameterized import parameterized
 
 from pypaimon.build_info import full_version as build_full_version
@@ -471,6 +472,7 @@ class TableWriteTest(unittest.TestCase):
         with patch.object(pa.TableGroupBy, 'aggregate', raise_missing_kernel):
             self.assertFalse(rk._probe_arrow_group_by())
 
+    @pytest.mark.python_commit
     def test_write_snapshot(self):
         schema = Schema.from_pyarrow_schema(self.pa_schema, 
partition_keys=['dt'])
         self.catalog.create_table('default.test_write_snapshot', schema, False)
@@ -591,6 +593,7 @@ class TableWriteTest(unittest.TestCase):
         self.assertEqual(
             expected.sort_by(sort_keys), self._read_sorted(table, sort_keys))
 
+    @pytest.mark.python_write
     def test_multi_prepare_commit_ao(self):
         schema = Schema.from_pyarrow_schema(self.pa_schema, 
partition_keys=['dt'])
         self.catalog.create_table('default.test_append_only_parquet', schema, 
False)
@@ -715,6 +718,7 @@ class TableWriteTest(unittest.TestCase):
         actual = table_read.to_arrow(splits).sort_by('user_id')
         self.assertEqual(expected, actual)
 
+    @pytest.mark.python_write
     def test_multi_prepare_commit_pk(self):
         schema = Schema.from_pyarrow_schema(self.pa_schema, 
partition_keys=['dt'], primary_keys=['user_id', 'dt'],
                                             options={'bucket': '2'})
@@ -1970,6 +1974,7 @@ class TableWriteTest(unittest.TestCase):
         actual = self._read_sorted(table, 'id')
         self.assertEqual(expected, actual)
 
+    @pytest.mark.python_write
     def test_validate_schema_allows_binary_family_for_write_cols(self):
         pa_schema = pa.schema([
             ('id', pa.int32()),
diff --git a/paimon-python/pypaimon/utils/file_store_path_factory.py 
b/paimon-python/pypaimon/utils/file_store_path_factory.py
index d712f7e815..35594c2963 100644
--- a/paimon-python/pypaimon/utils/file_store_path_factory.py
+++ b/paimon-python/pypaimon/utils/file_store_path_factory.py
@@ -42,6 +42,22 @@ def _escape_partition_component(value: str) -> str:
                    for char in value)
 
 
+def canonical_data_file_path(table, partition, bucket, file_name):
+    """Locate a file in Java's escaped partition directory."""
+    bucket_path = table.path_factory().bucket_path(
+        tuple(partition), bucket, canonical_partition=True)
+    path = f"{bucket_path.rstrip('/')}/{file_name}"
+    root = table.table_path.rstrip('/')
+    if (root.startswith('file:') and path.startswith(root + '/')
+            and '%' in path[len(root) + 1:]):
+        from pypaimon.filesystem.local_file_io import LocalFileIO
+
+        # File I/O wrappers eventually decode %2F in file URIs. Rust stores
+        # the literal escaped component, so use its physical local path.
+        return str(LocalFileIO()._to_file(root) / path[len(root) + 1:])
+    return path
+
+
 def _floating_partition_string(value, single_precision: bool) -> str:
     # Use a shortest round-tripping form for the initial lookup. Older JVMs
     # can use different digits; the read fallback matches their stored values.
diff --git a/paimon-python/pypaimon/write/native_commit.py 
b/paimon-python/pypaimon/write/native_commit.py
index 7c54ccb851..1eb50e09bb 100644
--- a/paimon-python/pypaimon/write/native_commit.py
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -17,42 +17,67 @@
 
 """Optional native commits using the Java CommitMessage v14 bridge."""
 
+from importlib import import_module
+
 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)
+    _catalog_context_options, _catalog_metastore, _option_value_to_string,
+    _resolved_schema_file_io_options)
+from pypaimon.utils.file_store_path_factory import canonical_data_file_path
 from pypaimon.write.commit_message_serializer import serialize_commit_message
 
 
+_DEFAULT_MANIFEST_TARGET_SIZE = 8 * 1024 * 1024
+
+
 def native_commit_available() -> bool:
-    """Whether the Rust runtime provides the required commit APIs."""
-    return all(native_method_available(type_name, method) for type_name, 
method in (
-        ('Table', 'from_resolved_schema'),
-        ('CommitMessage', 'deserialize'),
-        ('StreamWriteBuilder', 'with_commit_user'),
-        ('BatchWriteBuilder', '_with_commit_user'),
-        ('BatchWriteBuilder', 'with_overwrite'),
-    ))
+    """Whether the optional Rust bindings are installed."""
+    try:
+        import_module('pypaimon_rust.datafusion')
+    except ImportError:
+        return False
+    return True
+
+
+def _native_publication_supported(table) -> bool:
+    # Data evolution needs sidecar ranges and row-id recovery. Custom manifest
+    # targets need Java's forced size checks between manifest entry groups.
+    return (not table.options.data_evolution_enabled()
+            and table.options.manifest_target_size() == 
_DEFAULT_MANIFEST_TARGET_SIZE)
 
 
 def native_messages_supported(table, messages) -> bool:
+    if not _native_publication_supported(table):
+        return False
+    path_factory = table.path_factory()
     for message in messages:
         if (message.compact_before or message.compact_after
                 or message.compact_changelog_files
                 or message.compact_index_adds or 
message.compact_index_deletes):
             return False
-        # Python can rewrite stale row-id files before retrying. The native
-        # committer does not yet implement that recovery path.
-        if table.options.data_evolution_enabled() and (
-                message.check_from_snapshot is not None
-                or any(file.first_row_id is not None
-                       for file in message.new_files + message.deleted_files)):
-            return False
+        partition = tuple(message.partition)
+        bucket_path = path_factory.bucket_path(
+            partition, message.bucket, canonical_partition=True)
+        for file in message.new_files + message.changelog_files:
+            if file.external_path:
+                continue
+            expected = canonical_data_file_path(
+                table, partition, message.bucket, file.file_name)
+            if file.file_path:
+                if str(file.file_path) != expected:
+                    return False
+            elif path_factory.bucket_path(partition, message.bucket) != 
bucket_path:
+                # The message does not say which of the two partition layouts
+                # contains the file. Use Python's path-aware commit and abort.
+                return False
     return True
 
 
 def create_native_commit(table, commit_user, overwrite_partition=None):
     """Return a native committer only when its publication protocol matches 
Python."""
-    if not native_commit_available():
+    if (not _native_publication_supported(table)
+            or not _rest_catalog_supported(table)
+            or not native_commit_available()):
         return None
     native_table = create_native_write_table(table)
     if native_table is None:
@@ -66,26 +91,63 @@ def create_native_commit(table, commit_user, 
overwrite_partition=None):
     return 
native_table.new_stream_write_builder().with_commit_user(commit_user).new_commit()
 
 
+def _rest_catalog_supported(table):
+    from pypaimon.catalog.catalog_environment import CatalogEnvironment
+    from pypaimon.catalog.rest.rest_token_file_io import RESTTokenFileIO
+    from pypaimon.filesystem.caching_file_io import CachingFileIO
+    from pypaimon.filesystem.local_file_io import LocalFileIO
+    from pypaimon.filesystem.oss_file_io import OssFileIO
+    from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
+    from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
+    from pypaimon.table.file_store_table import FileStoreTable
+
+    environment = table.catalog_environment
+    loader = getattr(environment, 'catalog_loader', None)
+    context = loader.context() if _catalog_metastore(loader) == 'rest' else 
None
+    file_io = table.file_io
+    if type(file_io) is CachingFileIO:
+        file_io = file_io._delegate
+    return (type(table) is FileStoreTable
+            and type(environment) is CatalogEnvironment
+            and type(file_io) in (LocalFileIO, PyArrowFileIO, OssFileIO,
+                                  ResolvingFileIO, RESTTokenFileIO)
+            and environment.supports_version_management
+            and environment.uuid is not None
+            and context is not None
+            and context.options is not None
+            and all(getattr(context, attr, None) is None for attr in (
+                'hadoop_conf', 'prefer_io_loader', 'fallback_io_loader')))
+
+
+def _native_rest_table(table, schema_json):
+    from pypaimon_rust.datafusion import PaimonCatalog as NativeCatalog
+
+    catalog_options = _catalog_context_options(table)
+    catalog_options['metastore'] = 'rest'
+    native_table = NativeCatalog(catalog_options).get_table((
+        table.identifier.get_database_name(), 
table.identifier.get_table_name()))
+    if (native_table.location() != table.table_path
+            or native_table.rest_table_uuid() != 
table.catalog_environment.uuid):
+        return None
+    return native_table.copy_with_resolved_schema(schema_json)
+
+
 def create_native_write_table(table):
-    """Reconstruct a resolved table only for the filesystem publication 
route."""
+    """Preserve the resolved schema and the catalog's publication route."""
     from pypaimon.catalog.catalog_environment import CatalogEnvironment
     from pypaimon.filesystem.local_file_io import LocalFileIO
     from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
     from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
+    from pypaimon.table.bucket_mode import BucketMode
     from pypaimon.table.file_store_table import FileStoreTable
 
-    # Native branch writes are not supported. Catalog-backed publication (REST
-    # or custom version management) must continue through Python's environment.
+    # Native branch and postpone writes are not supported.
     environment = table.catalog_environment
     if (type(table) is not FileStoreTable
             or type(environment) is not CatalogEnvironment
-            or environment.supports_version_management
             or table.current_branch() != 'main'
-            or table.options.query_auth_enabled
-            or type(table.file_io) not in (LocalFileIO, PyArrowFileIO, 
ResolvingFileIO)):
-        return None
-    file_io_options = _resolved_schema_file_io_options(table)
-    if file_io_options is None:
+            or table.bucket_mode() == BucketMode.POSTPONE_MODE
+            or table.options.query_auth_enabled):
         return None
 
     from pypaimon_rust.datafusion import Table as NativeTable
@@ -98,8 +160,18 @@ def create_native_write_table(table):
         table.options.dynamic_partition_overwrite())
     options['snapshot.ignore-empty-commit'] = _option_value_to_string(
         table.options.snapshot_ignore_empty_commit())
+    schema_json = JSON.to_json(table.table_schema.copy(new_options=options))
+    if environment.supports_version_management:
+        if not _rest_catalog_supported(table):
+            return None
+        return _native_rest_table(table, schema_json)
+    if type(table.file_io) not in (LocalFileIO, PyArrowFileIO, 
ResolvingFileIO):
+        return None
+    file_io_options = _resolved_schema_file_io_options(table)
+    if file_io_options is None:
+        return None
     return NativeTable.from_resolved_schema(
-        table.table_path, 
JSON.to_json(table.table_schema.copy(new_options=options)),
+        table.table_path, schema_json,
         database=table.identifier.get_database_name(),
         table=table.identifier.get_table_name(),
         options=file_io_options)
diff --git a/paimon-python/pypaimon/write/native_write.py 
b/paimon-python/pypaimon/write/native_write.py
index fe2952ed0c..f96905f3e7 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -16,36 +16,54 @@
 
 """Optional Rust data writer behind PyPaimon's batch and stream builders."""
 
+from importlib import import_module
+
 import pyarrow as pa
 
-from pypaimon.read.native_plan import native_method_available
-from pypaimon.schema.arrow_schema import normalize_arrow_strings
+from pypaimon.common.options.core_options import CoreOptions, MergeEngine
+from pypaimon.schema.arrow_schema import arrow_schemas_compatible, 
normalize_arrow_strings
 from pypaimon.schema.data_types import PyarrowFieldParser, is_blob_file_field
+from pypaimon.table.bucket_mode import BucketMode
+from pypaimon.utils.file_store_path_factory import canonical_data_file_path
 from pypaimon.write.commit_message_serializer import deserialize_commit_message
 from pypaimon.write.native_commit import create_native_write_table
 from pypaimon.write.row_utils import row_to_named_values, 
row_values_to_arrow_table
 
 
 def native_write_available() -> bool:
-    """Check every binding entry point used by the writer bridge."""
-    return all(native_method_available(type_name, method) for type_name, 
method in (
-        ('Table', 'from_resolved_schema'),
-        ('BatchWriteBuilder', '_with_commit_user'),
-        ('BatchWriteBuilder', 'with_overwrite'),
-        ('BatchTableWrite', 'write_arrow'),
-        ('BatchTableWrite', 'prepare_commit'),
-        ('StreamWriteBuilder', 'with_commit_user'),
-        ('StreamTableWrite', 'write_arrow'),
-        ('StreamTableWrite', 'prepare_commit'),
-        ('CommitMessage', 'serialize'),
-    ))
+    """Whether the optional Rust bindings are installed."""
+    try:
+        import_module('pypaimon_rust.datafusion')
+    except ImportError:
+        return False
+    return True
 
 
 def create_native_write(table, commit_user, static_partition=None, 
stream=False):
     """Return a native writer if the table can use the filesystem write 
path."""
+    schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
+    partition_types = [schema.field(name).type for name in 
table.partition_keys]
     if (not native_write_available()
             or table.options.data_evolution_enabled()
+            or table.options.data_file_external_paths()
+            or table.bucket_mode() not in (BucketMode.HASH_FIXED,
+                                           BucketMode.BUCKET_UNAWARE)
+            or table.options.merge_engine() in (MergeEngine.FIRST_ROW,
+                                                MergeEngine.PARTIAL_UPDATE,
+                                                MergeEngine.AGGREGATE)
+            # Rust currently omits value stats for primary-key files.
+            or (table.is_primary_key_table and 
table.options.metadata_stats_enabled())
+            or table.options.target_file_row_num()
+            != CoreOptions.TARGET_FILE_ROW_NUM.default_value()
+            or table.options.changelog_file_format() not in (None, 'parquet')
             or table.options.file_format() != 'parquet'
+            # Rust validates nested Arrow child names strictly; PyPaimon 
accepts
+            # equivalent layouts such as list<item> and list<element>.
+            or any(pa.types.is_nested(field.type)
+                   or pa.types.is_fixed_size_binary(field.type) for field in 
schema)
+            # Rust cannot encode these partition keys yet.
+            or any(pa.types.is_binary(type_) or pa.types.is_large_binary(type_)
+                   or pa.types.is_floating(type_) for type_ in partition_types)
             or any(is_blob_file_field(field) for field in 
table.table_schema.fields)):
         return None
     native_table = create_native_write_table(table)
@@ -77,6 +95,7 @@ class NativeTableWrite:
         self._native_writer = native_writer
         self._python_writer = None
         self._written = False
+        self._schema = 
PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
 
     def _switch_to_python(self):
         if self._python_writer is not None:
@@ -110,6 +129,15 @@ class NativeTableWrite:
     def write_arrow_batch(self, data):
         if self._python_writer is not None:
             return self._python_writer.write_arrow_batch(data)
+        if not arrow_schemas_compatible(
+                data.schema, self._schema, check_top_level_nullability=False,
+                allow_binary_compatibility=True):
+            raise ValueError(
+                "Input schema isn't consistent with table schema and write 
cols. "
+                f"Input schema is: {data.schema} Table schema is: 
{self._schema} "
+                "Write cols is: None")
+        if any(pa.types.is_fixed_size_binary(field.type) for field in 
data.schema):
+            return self._switch_to_python().write_arrow_batch(data)
         data = normalize_arrow_strings(data)
         if data.num_rows:
             # A failed native write may already have produced files. Never
@@ -144,9 +172,14 @@ class NativeTableWrite:
             if commit_identifier is not None:
                 raise TypeError('BatchTableWrite.prepare_commit accepts no 
identifier')
             messages = self._native_writer.prepare_commit()
-        return [deserialize_commit_message(
+        decoded = [deserialize_commit_message(
             message.serialize(), self.table.partition_keys_fields,
             self.table.trimmed_primary_keys_fields) for message in messages]
+        for message in decoded:
+            for file in message.new_files + message.changelog_files:
+                file.file_path = file.external_path or 
canonical_data_file_path(
+                    self.table, message.partition, message.bucket, 
file.file_name)
+        return decoded
 
     def close(self):
         if self._python_writer is not None:

Reply via email to