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 0bfd0fada1 [python] Use native IO for floating and binary partitions 
(#10461)
0bfd0fada1 is described below

commit 0bfd0fada1f61b3f2b637962f5606f6be6ebf708
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 9 16:32:09 2026 +0800

    [python] Use native IO for floating and binary partitions (#10461)
---
 paimon-python/pypaimon/index/dynamic_bucket.py     |   2 +-
 .../pypaimon/read/scanner/file_scanner.py          |   2 +-
 .../pypaimon/tests/deletion_vector_path_test.py    |  74 +++---
 .../pypaimon/tests/file_store_commit_test.py       |  35 +--
 .../pypaimon/tests/native_dynamic_write_test.py    |   2 +-
 .../pypaimon/tests/native_merge_into_test.py       |   9 +-
 .../pypaimon/tests/native_partition_types_test.py  | 269 +++++++++++++++++++++
 paimon-python/pypaimon/tests/native_update_test.py |   2 +-
 .../pypaimon/tests/py36/rest_ao_read_write_test.py |   4 +-
 paimon-python/pypaimon/tests/reader_base_test.py   |   4 +-
 paimon-python/pypaimon/tests/table_commit_test.py  |   2 +-
 .../pypaimon/utils/file_store_path_factory.py      |  97 +++-----
 paimon-python/pypaimon/write/file_store_commit.py  |  36 +--
 paimon-python/pypaimon/write/native_commit.py      |   7 +
 paimon-python/pypaimon/write/native_merge_into.py  |   6 +-
 paimon-python/pypaimon/write/native_update.py      |   5 +-
 paimon-python/pypaimon/write/native_write.py       |  14 +-
 paimon-python/pypaimon/write/table_delete.py       |   2 +-
 18 files changed, 407 insertions(+), 165 deletions(-)

diff --git a/paimon-python/pypaimon/index/dynamic_bucket.py 
b/paimon-python/pypaimon/index/dynamic_bucket.py
index 707b03e724..89de907ea0 100644
--- a/paimon-python/pypaimon/index/dynamic_bucket.py
+++ b/paimon-python/pypaimon/index/dynamic_bucket.py
@@ -84,7 +84,7 @@ def validate_bucket_id(bucket: int) -> None:
 def _iter_hashes(table, entry: IndexManifestEntry) -> Iterator[int]:
     meta = entry.index_file
     path = table.path_factory().bucket_index_path(
-        tuple(entry.partition.values), entry.bucket, meta, table.file_io
+        tuple(entry.partition.values), entry.bucket, meta
     )
     with table.file_io.new_input_stream(path) as stream:
         remainder = b""
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py 
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index 503cc78d0a..a7eb538520 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -991,7 +991,7 @@ class FileScanner:
             return deletion_files
 
         dv_file_path = self.table.path_factory().bucket_index_path(
-            tuple(index_entry.partition.values), index_entry.bucket, 
index_file, self.table.file_io)
+            tuple(index_entry.partition.values), index_entry.bucket, 
index_file)
 
         # Convert each DeletionVectorMeta to DeletionFile
         for data_file_name, dv_meta in index_file.dv_ranges.items():
diff --git a/paimon-python/pypaimon/tests/deletion_vector_path_test.py 
b/paimon-python/pypaimon/tests/deletion_vector_path_test.py
index d1f53116e9..6592ddb5fd 100644
--- a/paimon-python/pypaimon/tests/deletion_vector_path_test.py
+++ b/paimon-python/pypaimon/tests/deletion_vector_path_test.py
@@ -157,7 +157,7 @@ def test_bucket_dv_requires_java_directory(tmp_path, 
planner, wrong_directory):
     file = entry.index_file
     assert file.external_path is None
     partition = tuple(entry.partition.values)
-    path = factory.bucket_index_path(partition, entry.bucket, file, 
table.file_io)
+    path = factory.bucket_index_path(partition, entry.bucket, file)
     wrong_parent = (factory.index_path() if wrong_directory == 'table-index'
                     else factory.bucket_path(partition, entry.bucket))
     wrong_path = wrong_parent + '/' + file.file_name
@@ -189,14 +189,14 @@ def 
test_floating_bucket_dv_does_not_search_python_directory(tmp_path, file_uri,
         factory._root = 'file://' + table.table_path
     partition = tuple(entry.partition.values)
     file = replace(entry.index_file, external_path=None)
-    canonical = factory.bucket_index_path(partition, entry.bucket, file, 
table.file_io)
+    canonical = factory.bucket_index_path(partition, entry.bucket, file)
     python_path = factory.bucket_path(partition, entry.bucket) + '/' + 
file.file_name
     with table.file_io.new_input_stream(canonical) as stream:
         data = stream.read()
     table.file_io.delete_quietly(canonical)
     with table.file_io.new_output_stream(python_path) as stream:
         stream.write(data)
-    assert factory.bucket_index_path(partition, entry.bucket, file, 
table.file_io) == canonical
+    assert factory.bucket_index_path(partition, entry.bucket, file) == 
canonical
     with patch.object(table, 'path_factory', return_value=factory):
         with pytest.raises(FileNotFoundError, match=file.file_name):
             _read(table, 'python', 2, [1, 2, 3])
@@ -266,33 +266,33 @@ def 
test_bucket_dv_writes_java_typed_partition_directory(tmp_path, planner, part
     _delete(table, [0])
     entry = _entries(table, 2)[0]
     file = entry.index_file
-    assert (file.external_path is not None) == 
pa.types.is_floating(partition_type)
+    assert file.external_path is None
     path = Path(table.table_path) / ('p=' + canonical_name) / ('bucket-' + 
str(entry.bucket)) / file.file_name
     assert path.is_file()
-    if file.external_path is not None:
-        # Java IndexInDataFileDirPathFactory uses the explicit path even when
-        # this JDK would render the same float with different digits.
-        assert Path(file.external_path).samefile(path)
-    effective_planner = planner
-    if planner == 'native' and pa.types.is_floating(partition_type):
-        # Rust intentionally rejects floating partition formatting until it can
-        # reproduce Java Float/Double.toString, including boundary values.
-        with pytest.raises(NotImplementedError, match='type is not supported 
as partition key'):
-            native_plan(table)
-        native_table = table.copy({'scan.native-plan.enabled': 'true'})
-        scan = native_table.new_read_builder().new_scan()
-        with patch('pypaimon.read.native_plan.native_plan', wraps=native_plan) 
as native_call:
-            with patch.object(scan.file_scanner, 'scan', 
wraps=scan.file_scanner.scan) as fallback:
-                plan = scan.plan()
-        assert native_call.call_count == 1
-        assert fallback.call_count == 1
-        result = 
native_table.new_read_builder().new_read().to_arrow(plan.splits())
-        assert sorted(result.column('k').to_pylist()) == [1, 2, 3]
-        effective_planner = 'python'
-    _read(table, effective_planner, 2, [1, 2, 3])
+    _read(table, planner, 2, [1, 2, 3])
     _delete(table, [1])
-    _read(table, effective_planner, 3, [2, 3])
-    _read(table, effective_planner, 2, [1, 2, 3])
+    _read(table, planner, 3, [2, 3])
+    _read(table, planner, 2, [1, 2, 3])
+
+
[email protected]('planner', _PLANNERS)
+def test_floating_bucket_dv_requires_current_java_directory(tmp_path, planner):
+    table = _table(tmp_path, 'bucket', 1e23, pa.float64())
+    _delete(table, [0])
+    entry = _entries(table, 2)[0]
+    canonical = (Path(table.table_path) / 'p=1.0E23' /
+                 ('bucket-' + str(entry.bucket)) / entry.index_file.file_name)
+    alternate = (Path(table.table_path) / 'p=9.999999999999999E22' /
+                 ('bucket-' + str(entry.bucket)) / entry.index_file.file_name)
+    alternate.parent.mkdir(parents=True, exist_ok=True)
+    canonical.rename(alternate)
+    expected = table.path_factory().bucket_index_path(
+        tuple(entry.partition.values), entry.bucket, entry.index_file)
+    assert expected == str(canonical)
+    with pytest.raises((FileNotFoundError, ValueError)) as error:
+        _read(table, planner, 2, [1, 2, 3])
+    assert entry.index_file.file_name in str(error.value)
+    assert alternate.is_file()
 
 
 @pytest.mark.parametrize('planner', _PLANNERS)
@@ -304,14 +304,11 @@ def 
test_bucket_dv_writes_java_typed_partition_directory(tmp_path, planner, part
     (-0.0, pa.float32(), '-0.0'),
     (1.4e-45, pa.float32(), '1.4E-45'),
     (3.4028234663852886e38, pa.float32(), '3.4028235E38'),
-    (1.17549435e-38, pa.float32(), '1.17549435E-38'),
     (1.17549435e-38, pa.float32(), '1.1754944E-38'),
-    (2.68873286e11, pa.float32(), '2.68873286E11'),
     (2.68873286e11, pa.float32(), '2.6887329E11'),
-    (9.64991956e24, pa.float32(), '9.6499195E24'),
-    (1e23, pa.float64(), '9.999999999999999E22'),
+    (9.64991956e24, pa.float32(), '9.6499196E24'),
     (1e23, pa.float64(), '1.0E23'),
-    (-2.3345394554987242e17, pa.float64(), '-2.33453945549872416E17'),
+    (-2.3345394554987242e17, pa.float64(), '-2.3345394554987242E17'),
     (5e-324, pa.float64(), '4.9E-324'),
     (datetime(2026, 9, 15, 12), pa.timestamp('s'), '2026-09-15 12%3A00%3A00'),
     (datetime(2026, 9, 15, 12), pa.timestamp('ms'), '2026-09-15 
12%3A00%3A00.000'),
@@ -411,7 +408,7 @@ def _check_java_bucket_dv(tmp_path, planner, value, 
partition_type, canonical_na
     # Model the persisted layout produced by Java: the bucket path is canonical
     # and no explicit index location is needed in its manifest metadata.
     old_path = table.path_factory().bucket_index_path(
-        tuple(old.partition.values), old.bucket, old.index_file, table.file_io)
+        tuple(old.partition.values), old.bucket, old.index_file)
     with table.file_io.new_input_stream(old_path) as stream:
         data = stream.read()
     with table.file_io.new_output_stream(canonical_path) as stream:
@@ -434,16 +431,7 @@ def _check_java_bucket_dv(tmp_path, planner, value, 
partition_type, canonical_na
                               old.index_file.file_name)
         with table.file_io.new_output_stream(other_partition) as stream:
             stream.write(b'not the requested floating partition')
-    if planner == 'native' and partition_type is not None and 
pa.types.is_floating(partition_type):
-        # Rust does not support floating partitions; exercise the real adapter
-        # fallback against the same Java-produced DV layout.
-        table = table.copy({'scan.native-plan.enabled': 'true'})
-        planner = 'python'
-        with patch('pypaimon.read.native_plan.native_plan', wraps=native_plan) 
as native_call:
-            plan = _read(table, planner, 3, [1, 2, 3])
-        assert native_call.call_count == 1
-    else:
-        plan = _read(table, planner, 3, [1, 2, 3])
+    plan = _read(table, planner, 3, [1, 2, 3])
     paths = [dv.dv_index_path for split in plan.splits()
              for dv in split.data_deletion_files or [] if dv is not None]
     assert paths == [canonical_path]
diff --git a/paimon-python/pypaimon/tests/file_store_commit_test.py 
b/paimon-python/pypaimon/tests/file_store_commit_test.py
index 39c0649c8c..e4c3d1d568 100644
--- a/paimon-python/pypaimon/tests/file_store_commit_test.py
+++ b/paimon-python/pypaimon/tests/file_store_commit_test.py
@@ -30,6 +30,7 @@ from pypaimon.manifest.schema.data_file_meta import 
DataFileMeta
 from pypaimon.manifest.schema.manifest_entry import ManifestEntry
 from pypaimon.manifest.schema.manifest_file_meta import ManifestFileMeta
 from pypaimon.manifest.schema.simple_stats import SimpleStats
+from pypaimon.schema.data_types import AtomicType, DataField
 from pypaimon.snapshot.snapshot_commit import PartitionStatistics
 from pypaimon.table.row.binary_row import BinaryRow
 from pypaimon.table.row.generic_row import GenericRow, GenericRowSerializer
@@ -43,6 +44,7 @@ from pypaimon.write.file_store_commit import (
     _reject_compact_increment,
     _row_id_check_from_messages,
 )
+from pypaimon.utils.file_store_path_factory import FileStorePathFactory
 
 
 class TestRowIdCheckFromMessages(unittest.TestCase):
@@ -132,6 +134,9 @@ class TestFileStoreCommitRowTracking(unittest.TestCase):
     def setUp(self):
         self.mock_table = Mock()
         self.mock_table.partition_keys = ['dt', 'region']
+        self.mock_table.path_factory.return_value = FileStorePathFactory(
+            '/test/table/path', ['dt', 'region'], '__DEFAULT_PARTITION__', 
'parquet',
+            'data-', 'changelog-', False, False, 'zstd')
         self.mock_table.current_branch.return_value = 'main'
         self.mock_table.table_path = '/test/table/path'
         self.mock_table.file_io = Mock()
@@ -351,6 +356,13 @@ class TestFileStoreCommit(unittest.TestCase):
         # Mock table with required attributes
         self.mock_table = Mock()
         self.mock_table.partition_keys = ['dt', 'region']
+        self.mock_table.partition_keys_fields = [
+            DataField(0, 'dt', AtomicType('STRING')),
+            DataField(1, 'region', AtomicType('STRING')),
+        ]
+        self.mock_table.path_factory.return_value = FileStorePathFactory(
+            '/test/table/path', ['dt', 'region'], '__DEFAULT_PARTITION__', 
'parquet',
+            'data-', 'changelog-', False, False, 'zstd')
         self.mock_table.current_branch.return_value = 'main'
         self.mock_table.table_path = '/test/table/path'
         self.mock_table.file_io = Mock()
@@ -724,7 +736,7 @@ class TestFileStoreCommit(unittest.TestCase):
 
         for old_buckets, new_buckets in [(-2, 2), (2, 3)]:
             with self.subTest(old=old_buckets, new=new_buckets):
-                partition = GenericRow(['2024-01-15', 'us-east-1'], None)
+                partition = GenericRow(['2024-01-15', 'us-east-1'], 
self.mock_table.partition_keys_fields)
                 entries = [
                     ManifestEntry(
                         kind=1,
@@ -839,6 +851,7 @@ class TestFileStoreCommit(unittest.TestCase):
         """Test partition statistics generation for unpartitioned table."""
         # Update mock table to have no partition keys
         self.mock_table.partition_keys = []
+        self.mock_table.partition_keys_fields = []
 
         # Create FileStoreCommit instance
         file_store_commit = self._create_file_store_commit()
@@ -954,19 +967,8 @@ class TestFileStoreCommit(unittest.TestCase):
             new_files=[file_meta]
         )
 
-        # Test method
-        statistics = 
file_store_commit._generate_partition_statistics(self._to_entries([commit_message]))
-
-        # Verify results - should fallback to index-based naming
-        self.assertEqual(len(statistics), 1)
-
-        stat = statistics[0]
-        expected_spec = {
-            'partition_0': '2024-01-15',
-            'partition_1': 'us-east-1',
-            'partition_2': 'extra-value'
-        }
-        self.assertEqual(stat.spec, expected_spec)
+        with self.assertRaisesRegex(ValueError, 'Partition row does not 
match'):
+            
file_store_commit._generate_partition_statistics(self._to_entries([commit_message]))
 
     def test_generate_partition_statistics_empty_commit_messages(
             self, mock_manifest_list_manager, mock_manifest_file_manager):
@@ -1111,11 +1113,10 @@ class TestFileStoreCommit(unittest.TestCase):
         self.assertEqual(2, file_store_commit._try_commit_once.call_count)
         file_store_commit._commit_retry_wait.assert_called_once_with(0)
 
-    @staticmethod
-    def _to_entries(commit_messages):
+    def _to_entries(self, commit_messages):
         commit_entries = []
         for msg in commit_messages:
-            partition = GenericRow(list(msg.partition), None)
+            partition = GenericRow(list(msg.partition), 
self.mock_table.partition_keys_fields)
             for file in msg.new_files:
                 commit_entries.append(ManifestEntry(
                     kind=0,
diff --git a/paimon-python/pypaimon/tests/native_dynamic_write_test.py 
b/paimon-python/pypaimon/tests/native_dynamic_write_test.py
index ef84c1b73b..47dd8205a9 100644
--- a/paimon-python/pypaimon/tests/native_dynamic_write_test.py
+++ b/paimon-python/pypaimon/tests/native_dynamic_write_test.py
@@ -118,7 +118,7 @@ def test_dynamic_cross_backend_restart(tmp_path, 
bucket_local, first_native):
         assert len(indexes) == 1
         assert indexes[0].index_file.external_path is None
         path = table.path_factory().bucket_index_path(
-            tuple(indexes[0].partition.values), 0, indexes[0].index_file, 
table.file_io)
+            tuple(indexes[0].partition.values), 0, indexes[0].index_file)
         assert table.file_io.exists(path)
     assert _indexes(table)[0].index_file.row_count == 5
     expected = [{'id': i, 'p': 'a/b', 'v': v} for i, v in
diff --git a/paimon-python/pypaimon/tests/native_merge_into_test.py 
b/paimon-python/pypaimon/tests/native_merge_into_test.py
index 1272f129ae..fe15bac7ca 100644
--- a/paimon-python/pypaimon/tests/native_merge_into_test.py
+++ b/paimon-python/pypaimon/tests/native_merge_into_test.py
@@ -508,7 +508,7 @@ def 
test_python_written_partition_source_uses_java_paths_for_native_merge(tmp_pa
 @pytest.mark.parametrize('native', [False, True])
 @pytest.mark.parametrize('stream', [False, True])
 @pytest.mark.parametrize('partition_type', [pa.float32(), pa.float64()])
-def test_float_partition_source_selects_python_before_native_merge(tmp_path, 
native, stream, partition_type):
+def test_float_partition_source_uses_native_merge(tmp_path, native, stream, 
partition_type):
     target = _table(tmp_path / 'target', [dict(id=1, value=10, 
name='old')]).copy({
         'write.native.enabled': str(native).lower()})
     schema = pa.schema(list(_SCHEMA) + [pa.field('part', partition_type)])
@@ -522,8 +522,11 @@ def 
test_float_partition_source_selects_python_before_native_merge(tmp_path, nat
                                  dict(id=2, value=22, name='insert', 
part=2.25)])
     builder = target.new_stream_write_builder() if stream else 
target.new_batch_write_builder()
     kwargs = dict(commit_identifier=57) if stream else {}
-    with 
patch('pypaimon.write.native_merge_into.NativeTableMergeInto.prepare_commit',
-               side_effect=AssertionError('Unsupported source entered native 
MERGE')):
+    from contextlib import ExitStack
+    with ExitStack() as stack:
+        if native:
+            
stack.enter_context(patch('pypaimon.table.data_evolution_merge_into._build_tables',
+                                      side_effect=AssertionError('Python 
source materialization')))
         messages = builder.new_update().merge_into(
             source, on=['id'], when_matched=[WhenMatched.update({'value': 
source_col('value')})],
             when_not_matched=[WhenNotMatched('*')], **kwargs)
diff --git a/paimon-python/pypaimon/tests/native_partition_types_test.py 
b/paimon-python/pypaimon/tests/native_partition_types_test.py
new file mode 100644
index 0000000000..783f1540df
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_partition_types_test.py
@@ -0,0 +1,269 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Java partition values across native append, PK and data-evolution writes."""
+
+from datetime import datetime, timedelta, timezone
+from unittest.mock import Mock, patch
+from urllib.parse import urlparse
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.write.native_write import NativeTableWrite
+
+
+_PARTITIONS = [
+    (pa.float32(), [1.5, 42., 0.0001, None], ['1.5', '42.0', '1.0E-4', 
'__DEFAULT_PARTITION__']),
+    (pa.float64(), [2.25, 42., 1e23, None], ['2.25', '42.0', '1.0E23', 
'__DEFAULT_PARTITION__']),
+    (pa.binary(), [b'a/b', '\u6c49\u5b57'.encode(), b' \t', None],
+     ['a%2Fb', '\u6c49\u5b57', '__DEFAULT_PARTITION__', 
'__DEFAULT_PARTITION__']),
+    (pa.binary(3), [b'a/b', b'x=y', b' \t ', None],
+     ['a%2Fb', 'x%3Dy', '__DEFAULT_PARTITION__', '__DEFAULT_PARTITION__']),
+]
+
+
+def _table(tmp_path, partition_type, mode='append', native=True, 
placement='local', extra_options=None):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
+    catalog.create_database('db', True)
+    options = {'partition.legacy-name': 'false', 'write.native.enabled': 
str(native).lower(),
+               'scan.native-plan.enabled': 'true', 'read.native.enabled': 
'true'}
+    if mode == 'pk':
+        options['bucket'] = '1'
+    elif mode == 'evolution':
+        options.update({'data-evolution.enabled': 'true', 
'row-tracking.enabled': 'true'})
+    if placement == 'directory':
+        options['data-file.path-directory'] = 'data'
+    elif placement == 'external':
+        options['data-file.external-paths'] = (tmp_path / 'external').as_uri()
+        options['data-file.external-paths.strategy'] = 'round-robin'
+    options.update(extra_options or {})
+    arrow_schema = pa.schema([('id', pa.int32()), ('p', partition_type), 
('value', pa.int32())])
+    catalog.create_table('db.t', Schema.from_pyarrow_schema(
+        arrow_schema, partition_keys=['p'], primary_keys=['p', 'id'] if mode 
== 'pk' else [],
+        options=options), False)
+    return catalog.get_table('db.t'), arrow_schema
+
+
+def _read(table):
+    builder = table.new_read_builder()
+    return 
sorted(builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist(),
+                  key=lambda row: row['id'])
+
+
+def _write(table, schema, rows, stream=False):
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        if table.options.native_write_enabled():
+            assert isinstance(writer, NativeTableWrite)
+        writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+        messages = writer.prepare_commit(71) if stream else 
writer.prepare_commit()
+        commit = builder.new_commit()
+        try:
+            commit.commit(messages, 71) if stream else commit.commit(messages)
+        finally:
+            commit.close()
+        return messages
+    finally:
+        writer.close()
+
+
[email protected]_plan
[email protected]('partition_type,values,names', _PARTITIONS)
[email protected]('mode', ['append', 'pk', 'evolution'])
[email protected]('placement', ['local', 'directory', 'external'])
+def test_native_partition_write_read_and_file_paths(tmp_path, partition_type, 
values, names, mode, placement):
+    table, schema = _table(tmp_path, partition_type, mode, placement=placement)
+    rows = [dict(id=i, p=value, value=10 + i) for i, value in 
enumerate(values)]
+    if mode == 'pk':
+        rows = [row for row in rows if row['p'] is not None]
+        names = [name for value, name in zip(values, names) if value is not 
None]
+    messages = _write(table, schema, rows)
+    assert _read(table) == pa.Table.from_pylist(rows, 
schema=schema).to_pylist()
+    paths = []
+    for message in messages:
+        for file in message.new_files:
+            assert table.file_io.exists(file.file_path)
+            if placement == 'external':
+                assert file.external_path is not None
+                assert 
urlparse(file.external_path).path.startswith(str(tmp_path / 'external') + '/')
+            paths.append(file.file_path)
+    for name in names:
+        assert any('/p=' + name + '/' in path for path in paths)
+
+
[email protected]_plan
[email protected]('partition_type,values,names', _PARTITIONS)
[email protected]('stream', [False, True])
+def test_native_evolution_updates_use_typed_partition_values(tmp_path, 
partition_type, values, names, stream):
+    table, schema = _table(tmp_path, partition_type, 'evolution')
+    rows = [dict(id=i, p=value, value=i) for i, value in enumerate(values)]
+    _write(table, schema, rows, stream)
+    # Fail if the Python update planner or file writer takes over the 
operation.
+    with 
patch('pypaimon.write.table_update.BatchTableUpdate._build_predicate_update_table',
+               side_effect=AssertionError('Python row-ID update')):
+        builder = table.new_batch_write_builder()
+        messages = builder.new_update().update_by_predicate(None, {'value': 
99})
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages)
+    finally:
+        commit.close()
+    expected = pa.Table.from_pylist(rows, schema=schema).to_pylist()
+    assert _read(table) == [dict(row, value=99) for row in expected]
+
+
[email protected]_plan
[email protected]_plan
[email protected]_read
[email protected]('partition_type', [pa.float32(), pa.float64()])
[email protected]('legacy', [False, True])
+def 
test_native_non_finite_partitions_use_java_names_for_python_reads(tmp_path, 
partition_type, legacy):
+    table, schema = _table(tmp_path, partition_type, extra_options={
+        'partition.legacy-name': str(legacy).lower(),
+        'scan.native-plan.enabled': 'false', 'read.native.enabled': 'false'})
+    rows = [dict(id=i, p=value, value=i) for i, value in 
enumerate([float('inf'), float('-inf'), 1.5])]
+    messages = _write(table, schema, rows)
+    paths = [file.file_path for message in messages for file in 
message.new_files]
+    assert paths and all(table.file_io.exists(path) for path in paths)
+    for name in ['Infinity', '-Infinity', '1.5']:
+        assert any('/p=' + name + '/' in path for path in paths)
+    assert _read(table) == rows
+
+
[email protected]_write
[email protected]_plan
[email protected]('partition_type,values,names', _PARTITIONS[2:])
+def test_python_binary_partitions_use_java_paths_and_native_reads(tmp_path, 
partition_type, values, names):
+    table, schema = _table(tmp_path, partition_type, native=False)
+    rows = [dict(id=i, p=value, value=i) for i, value in enumerate(values)]
+    messages = _write(table, schema, rows)
+    for message in messages:
+        for file in message.new_files:
+            assert table.file_io.exists(file.file_path)
+    assert _read(table) == rows
+
+
[email protected]_plan
+def test_native_deletion_vector_restore_uses_java_partition_path(tmp_path):
+    table, schema = _table(tmp_path, pa.float64(), 'evolution', extra_options={
+        'deletion-vectors.enabled': 'true', 'index-file-in-data-file-dir': 
'true'})
+    _write(table, schema, [dict(id=i, p=1e23, value=i) for i in range(3)])
+
+    def delete(row_id):
+        builder = table.new_batch_write_builder()
+        messages = builder.new_update().delete_by_row_id([row_id])
+        commit = builder.new_commit()
+        try:
+            commit.commit(messages)
+        finally:
+            commit.close()
+
+    delete(0)
+    assert [row['id'] for row in _read(table)] == [1, 2]
+    delete(1)
+    assert [row['id'] for row in _read(table)] == [2]
+
+
[email protected]_plan
[email protected]('action', ['DELETE', 'UPDATE SET value = 99'])
+def test_native_cow_merge_uses_java_partition_path(tmp_path, action):
+    from pypaimon_rust.datafusion import SQLContext
+
+    table, schema = _table(tmp_path, pa.float64())
+    _write(table, schema, [dict(id=i, p=1e23, value=i) for i in range(3)])
+    context = SQLContext()
+    context.register_catalog('paimon', {'warehouse': str(tmp_path / 
'warehouse')})
+    context.sql('MERGE INTO paimon.db.t t USING (SELECT CAST(1 AS INT) AS id) 
s '
+                'ON t.id = s.id WHEN MATCHED THEN ' + action)
+    expected = [dict(id=i, p=1e23, value=99 if action.startswith('UPDATE') and 
i == 1 else i)
+                for i in range(3) if action != 'DELETE' or i != 1]
+    assert _read(table) == expected
+
+
[email protected]('value,expected', [
+    (b'a/b=c', 'a%2Fb%3Dc'), (b'\xed\xa0\x80', '\ufffd'), (b'\xed\xa0A', 
'\ufffdA'),
+    (b'\xe2\x82', '\ufffd'), (b'\xe0\x80\x80', '\ufffd\ufffd\ufffd'),
+    (b'', '__DEFAULT_PARTITION__'), (b' \t\x1c', '__DEFAULT_PARTITION__'),
+    ('\u00a0'.encode(), '\u00a0'), ('\u0085'.encode(), '\u0085'),
+])
+def test_binary_partition_string_conversion_matches_java(tmp_path, value, 
expected):
+    table, _ = _table(tmp_path, pa.binary(), native=False)
+    assert table.path_factory().relative_bucket_path((value,), 0, 
canonical_partition=True) == (
+        'p=' + expected + '/bucket-0')
+
+
+def 
test_python_partition_statistics_keep_java_values_and_typed_identity(tmp_path):
+    from pypaimon.manifest.schema.manifest_entry import ManifestEntry
+    from pypaimon.table.row.generic_row import GenericRow
+    from pypaimon.write.file_store_commit import FileStoreCommit
+
+    table, _ = _table(tmp_path, pa.binary(), native=False)
+    commit = FileStoreCommit.__new__(FileStoreCommit)
+    commit.table = table
+    entries = [ManifestEntry(0, GenericRow([value], 
table.partition_keys_fields), 0, 1,
+                             Mock(row_count=2, file_size=10, 
creation_time=None))
+               for value in [None, b' \t', b'a/b', b'\xed\xa0\x80', 
bytearray(b'a/b'), memoryview(b'a/b')]]
+    statistics = commit._generate_partition_statistics(entries)
+    # NULL and whitespace name the same directory, but remain different typed
+    # partitions, as in Java PartitionEntry.merge and Rust BinaryRow grouping.
+    assert [stat.spec for stat in statistics] == [
+        {'p': '__DEFAULT_PARTITION__'}, {'p': '__DEFAULT_PARTITION__'}, {'p': 
'a/b'}, {'p': '\ufffd'}]
+    assert [stat.record_count for stat in statistics] == [2, 2, 6, 2]
+
+
[email protected]('partition_type,values,counts', [
+    (pa.float32(), [1.00000001, 1.0, 2.0], [4, 2]),
+    (pa.float64(), [1.00000001, 1.0, 2.0], [2, 2, 2]),
+    (pa.timestamp('ms', tz='UTC'), [
+        datetime(2024, 1, 1),
+        datetime(2024, 1, 1, 8, tzinfo=timezone(timedelta(hours=8))),
+        datetime(2024, 1, 2, tzinfo=timezone.utc)], [4, 2]),
+])
+def test_python_partition_statistics_group_by_schema_encoded_values(tmp_path, 
partition_type, values, counts):
+    from pypaimon.manifest.schema.manifest_entry import ManifestEntry
+    from pypaimon.table.row.generic_row import GenericRow
+    from pypaimon.write.file_store_commit import FileStoreCommit
+
+    table, _ = _table(tmp_path, partition_type, native=False)
+    commit = FileStoreCommit.__new__(FileStoreCommit)
+    commit.table = table
+    entries = [ManifestEntry(0, GenericRow([value], 
table.partition_keys_fields), 0, 1,
+                             Mock(row_count=2, file_size=10, 
creation_time=None)) for value in values]
+    statistics = commit._generate_partition_statistics(entries)
+    assert [stat.record_count for stat in statistics] == counts
+    assert [stat.file_count for stat in statistics] == [count // 2 for count 
in counts]
+    assert [stat.file_size_in_bytes for stat in statistics] == [count * 5 for 
count in counts]
+
+
[email protected]('legacy', [False, True])
+def 
test_native_commit_binary_partition_capability_precedes_execution(tmp_path, 
legacy):
+    from pypaimon.write.native_commit import create_native_commit
+
+    table, _ = _table(tmp_path, pa.binary(), native=False,
+                      extra_options={'partition.legacy-name': 
str(legacy).lower()})
+    with patch('pypaimon.write.native_commit._rest_catalog_supported', 
return_value=True), \
+            patch('pypaimon.write.native_commit.native_commit_available', 
return_value=True), \
+            patch('pypaimon.write.native_commit.create_native_write_table') as 
native:
+        committer = create_native_commit(table, 'user')
+        if legacy:
+            assert committer is None
+            native.assert_not_called()
+        else:
+            assert committer is not None
+            native.assert_called_once_with(table)
diff --git a/paimon-python/pypaimon/tests/native_update_test.py 
b/paimon-python/pypaimon/tests/native_update_test.py
index e59e81813a..8ed01dbf62 100644
--- a/paimon-python/pypaimon/tests/native_update_test.py
+++ b/paimon-python/pypaimon/tests/native_update_test.py
@@ -1027,5 +1027,5 @@ def 
test_update_dispatch_preserves_existing_partition_paths(tmp_path, layout, op
             read = table.new_read_builder()
             actual = 
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
             assert actual.to_pydict() == {'id': [1, 2], 'part': parts, 
'value': [value, value]}
-    expected_calls = 2 if layout in ('python-escaped', 'canonical', 
'ordinary', 'directory', 'external') else 0
+    expected_calls = 0 if layout == 'external-binary' else 2
     assert len(native_calls) == expected_calls
diff --git a/paimon-python/pypaimon/tests/py36/rest_ao_read_write_test.py 
b/paimon-python/pypaimon/tests/py36/rest_ao_read_write_test.py
index 03fd763a9e..889c2e125b 100644
--- a/paimon-python/pypaimon/tests/py36/rest_ao_read_write_test.py
+++ b/paimon-python/pypaimon/tests/py36/rest_ao_read_write_test.py
@@ -283,7 +283,7 @@ class RESTAOReadWritePy36Test(RESTBaseTest):
             ('region', pa.string()),
             ('city', pa.string())
         ])
-        schema = Schema.from_pyarrow_schema(pa_schema)
+        schema = Schema.from_pyarrow_schema(pa_schema, 
partition_keys=['region', 'city'])
         self.rest_catalog.create_table('default.tb', schema, False)
         table = self.rest_catalog.get_table('default.tb')
         partition_fields = [
@@ -341,7 +341,7 @@ class RESTAOReadWritePy36Test(RESTBaseTest):
             ('region', pa.string()),
             ('city', pa.string())
         ])
-        schema = Schema.from_pyarrow_schema(pa_schema)
+        schema = Schema.from_pyarrow_schema(pa_schema, 
partition_keys=['region', 'city'])
         self.rest_catalog.create_table('default.tb1', schema, False)
         table = self.rest_catalog.get_table('default.tb1')
         partition_fields = [
diff --git a/paimon-python/pypaimon/tests/reader_base_test.py 
b/paimon-python/pypaimon/tests/reader_base_test.py
index a54540a27e..b6a0ce47a8 100644
--- a/paimon-python/pypaimon/tests/reader_base_test.py
+++ b/paimon-python/pypaimon/tests/reader_base_test.py
@@ -413,7 +413,7 @@ class ReaderBasicTest(unittest.TestCase):
             ('region', pa.string()),
             ('city', pa.string())
         ])
-        schema = Schema.from_pyarrow_schema(pa_schema)
+        schema = Schema.from_pyarrow_schema(pa_schema, 
partition_keys=['region', 'city'])
         self.catalog.create_table('default.tb', schema, False)
         table = self.catalog.get_table('default.tb')
         partition_fields = [
@@ -472,7 +472,7 @@ class ReaderBasicTest(unittest.TestCase):
             ('region', pa.string()),
             ('city', pa.string())
         ])
-        schema = Schema.from_pyarrow_schema(pa_schema)
+        schema = Schema.from_pyarrow_schema(pa_schema, 
partition_keys=['region', 'city'])
         self.catalog.create_table('default.tb1', schema, False)
         table = self.catalog.get_table('default.tb1')
         partition_fields = [
diff --git a/paimon-python/pypaimon/tests/table_commit_test.py 
b/paimon-python/pypaimon/tests/table_commit_test.py
index d606a94331..8a213fd10d 100644
--- a/paimon-python/pypaimon/tests/table_commit_test.py
+++ b/paimon-python/pypaimon/tests/table_commit_test.py
@@ -65,7 +65,7 @@ class TestTableCommit(unittest.TestCase):
                 if mode == 'dynamic':
                     self.assertTrue(any(message.index_adds for message in 
messages))
                     paths.extend(table.path_factory().bucket_index_path(
-                        tuple(entry.partition.values), entry.bucket, 
entry.index_file, table.file_io)
+                        tuple(entry.partition.values), entry.bucket, 
entry.index_file)
                         for message in messages for entry in 
message.index_adds)
                 self.assertTrue(paths)
                 publish = commit.file_store_commit.commit
diff --git a/paimon-python/pypaimon/utils/file_store_path_factory.py 
b/paimon-python/pypaimon/utils/file_store_path_factory.py
index 1536ffd93c..8da0b11a72 100644
--- a/paimon-python/pypaimon/utils/file_store_path_factory.py
+++ b/paimon-python/pypaimon/utils/file_store_path_factory.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import math
 import struct
 from datetime import date, timezone
 from decimal import Decimal
@@ -32,7 +33,29 @@ def _is_null_or_whitespace_only(value) -> bool:
     if value is None:
         return True
     s = str(value)
-    return len(s) == 0 or s.isspace()
+    # Character.isWhitespace, as used by Java StringUtils. Python isspace also
+    # folds NBSP, NEL and other characters Java keeps in a partition name.
+    return all(char in '\t\n\v\f\r\x1c\x1d\x1e\x1f 
\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006'
+               '\u2008\u2009\u200a\u2028\u2029\u205f\u3000' for char in s)
+
+
+def _decode_java_utf8(value: bytes) -> str:
+    """BinaryString.toString replaces a malformed surrogate as one sequence."""
+    value = bytes(value)
+    parts = []
+    while value:
+        try:
+            parts.append(value.decode('utf-8'))
+            break
+        except UnicodeDecodeError as error:
+            parts.append(value[:error.start].decode('utf-8'))
+            value = value[error.start:]
+            length = error.end - error.start
+            if len(value) >= 2 and value[0] == 0xed and 0xa0 <= value[1] <= 
0xbf:
+                length = 3 if len(value) >= 3 and value[2] & 0xc0 == 0x80 else 
2
+            parts.append('\ufffd')
+            value = value[length:]
+    return ''.join(parts)
 
 
 def _escape_partition_component(value: str) -> str:
@@ -60,11 +83,15 @@ def canonical_data_file_path(table, partition, bucket, 
file_name):
 
 
 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.
+    # Java's JDK 19+ contract selects a shortest round-tripping form with at
+    # least two significant digits before applying its notation thresholds.
     encoding = '>f' if single_precision else '>d'
     bits = struct.pack(encoding, value)
     value = struct.unpack(encoding, bits)[0]
+    if math.isnan(value):
+        return 'NaN'
+    if math.isinf(value):
+        return '-Infinity' if value < 0 else 'Infinity'
     for precision in range(2, 10 if single_precision else 18):
         text = format(value, '.{}g'.format(precision))
         try:
@@ -74,8 +101,6 @@ def _floating_partition_string(value, single_precision: 
bool) -> str:
         if rounded != bits:
             continue
         decimal = Decimal(text)
-        if not decimal.is_finite():
-            return str(value)
         if decimal.is_zero() or Decimal('0.001') <= abs(decimal) < 
Decimal('1e7'):
             text = format(decimal, 'f')
             if '.' in text:
@@ -175,6 +200,8 @@ class FileStorePathFactory:
         for i, value in enumerate(partition):
             data_type = self.partition_types[i] if self.partition_types is not 
None else None
             type_name = str(data_type).split('(', 1)[0].split()[0]
+            if value is not None and type_name in ('BINARY', 'VARBINARY', 
'BYTES') and not self.legacy_partition_name:
+                value = _decode_java_utf8(value)
             if value is not None and _is_ltz_type(str(data_type).upper()):
                 # Legacy Timestamp.toString() uses UTC fields. Java's 
non-legacy
                 # cast uses TimeZone.getDefault(); neither includes an offset.
@@ -199,6 +226,8 @@ class FileStorePathFactory:
             elif self.legacy_partition_name and type_name.startswith('TIME'):
                 text = str(((value.hour * 60 + value.minute) * 60 + 
value.second) * 1000
                            + value.microsecond // 1000)
+            elif type_name in ('BINARY', 'VARBINARY', 'BYTES') and not 
self.legacy_partition_name:
+                text = value
             elif data_type is not None and not _is_unsupported(data_type):
                 text = cast_value_to_string(value, data_type)
             else:
@@ -272,67 +301,19 @@ class FileStorePathFactory:
             if external is not None:
                 return external.get_next_external_data_path(file_name), True
             path = f"{self.bucket_path(partition, bucket, 
canonical_partition=True)}/{file_name}"
-            # Java Float/Double.toString differs across JDK versions. Record
-            # the actual Java-style directory so every Java reader resolves
-            # the same file through IndexFileMeta.externalPath.
-            floating = any(value is not None and str(data_type).split()[0] in 
('FLOAT', 'REAL', 'DOUBLE')
-                           for value, data_type in zip(partition, 
self.partition_types or []))
-            return to_file_io_path(path), floating
+            # Java IndexInDataFileDirPathFactory marks a path external only
+            # when a configured ExternalPathProvider selected it.
+            return to_file_io_path(path), False
         factory = self.global_index_path_factory()
         return factory.to_path(file_name), factory.is_external_path()
 
-    def bucket_index_path(self, partition: Tuple, bucket: int, index_file, 
file_io=None) -> str:
+    def bucket_index_path(self, partition: Tuple, bucket: int, index_file) -> 
str:
         """Resolve a bucket index using Java partition paths and explicit 
locations."""
         if index_file.external_path:
             return to_file_io_path(index_file.external_path)
         if not self.index_file_in_data_file_dir:
             return f"{self.index_path()}/{index_file.file_name}"
-        path = to_file_io_path(f"{self.bucket_path(partition, bucket, 
True)}/{index_file.file_name}")
-        # Java Float/Double.toString can produce different digits across JDK
-        # versions. Only these floating partition spellings need a lookup.
-        if file_io is not None and index_file.index_type in 
('DELETION_VECTORS', 'HASH') and not file_io.exists(path):
-            python_path = to_file_io_path(f"{self.bucket_path(partition, 
bucket)}/{index_file.file_name}")
-            alternate = self._find_floating_bucket_index(partition, bucket, 
index_file.file_name, file_io, python_path)
-            if alternate is not None:
-                return alternate
-        return path
-
-    def _find_floating_bucket_index(self, partition, bucket, file_name, 
file_io, python_path):
-        floating = [str(data_type).split()[0] in ('FLOAT', 'REAL', 'DOUBLE')
-                    for data_type in self.partition_types or []]
-        if not any(is_float and value is not None for is_float, value in 
zip(floating, partition)):
-            return None
-        # Float/Double.toString changed across JDK releases. Only if the usual
-        # path is missing, inspect floating partition components and compare
-        # their exact encoded values (including the sign of zero).
-        paths = [to_file_io_path(resolve_path(self.data_file_path(), '.'))]
-        for i, text in enumerate(self._canonical_partition(partition)):
-            prefix = _escape_partition_component(self.partition_keys[i]) + '='
-            if not floating[i] or partition[i] is None:
-                paths = [path + '/' + prefix + 
_escape_partition_component(text) for path in paths]
-                continue
-            encoding = '>d' if str(self.partition_types[i]).split()[0] == 
'DOUBLE' else '>f'
-            expected = struct.pack(encoding, partition[i])
-            matched = []
-            for path in paths:
-                if not file_io.exists(path):
-                    continue
-                for status in file_io.list_status(path):
-                    name = status.base_name
-                    if not name.startswith(prefix):
-                        continue
-                    try:
-                        if struct.pack(encoding, float(name[len(prefix):])) == 
expected:
-                            matched.append(path + '/' + name)
-                    except (ValueError, OverflowError):
-                        continue
-            paths = sorted(matched)
-        bucket_name = 'postpone' if bucket == BucketMode.POSTPONE_BUCKET.value 
else str(bucket)
-        for path in paths:
-            candidate = '{}/{}{}/{}'.format(path, self.BUCKET_PATH_PREFIX, 
bucket_name, file_name)
-            if candidate != python_path and file_io.exists(candidate):
-                return candidate
-        return None
+        return to_file_io_path(f"{self.bucket_path(partition, bucket, 
True)}/{index_file.file_name}")
 
 
 class IndexPathFactory:
diff --git a/paimon-python/pypaimon/write/file_store_commit.py 
b/paimon-python/pypaimon/write/file_store_commit.py
index 10d8ce23c6..c00f138921 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -33,7 +33,7 @@ from pypaimon.manifest.schema.manifest_entry import 
ManifestEntry
 from pypaimon.snapshot.snapshot import Snapshot
 from pypaimon.snapshot.snapshot_commit import (PartitionStatistics,
                                                SnapshotCommit)
-from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.table.row.generic_row import GenericRow, GenericRowSerializer
 from pypaimon.table.row.offset_row import OffsetRow
 from pypaimon.utils.file_store_path_factory import canonical_data_file_path
 from pypaimon.write.commit.commit_rollback import CommitRollback
@@ -1046,7 +1046,7 @@ class FileStoreCommit:
                     file_name = index_file.file_name
                     if index_file.index_type in ('DELETION_VECTORS', 'HASH'):
                         path = self.table.path_factory().bucket_index_path(
-                            tuple(entry.partition.values), entry.bucket, 
index_file, self.table.file_io)
+                            tuple(entry.partition.values), entry.bucket, 
index_file)
                     else:
                         path = (
                             index_file.external_path
@@ -1087,31 +1087,17 @@ class FileStoreCommit:
             List of PartitionStatistics for each unique partition
         """
         partition_stats = {}
+        path_factory = self.table.path_factory()
 
         for entry in commit_entries:
-            # Convert partition tuple to dictionary for PartitionStatistics
-            partition_value = tuple(entry.partition.values)  # Call the method 
to get partition value
-            if partition_value:
-                # Assuming partition is a tuple and we need to convert it to a 
dict
-                # This may need adjustment based on actual partition format
-                if isinstance(partition_value, tuple):
-                    # Create partition spec from partition tuple and table 
partition keys
-                    partition_spec = {}
-                    if len(partition_value) == len(self.table.partition_keys):
-                        for i, key in enumerate(self.table.partition_keys):
-                            partition_spec[key] = str(partition_value[i])
-                    else:
-                        # Fallback: use indices as keys
-                        for i, value in enumerate(partition_value):
-                            partition_spec[f"partition_{i}"] = str(value)
-                else:
-                    # If partition is already a dict or other format
-                    partition_spec = dict(partition_value) if partition_value 
else {}
-            else:
-                # Default partition for unpartitioned tables
-                partition_spec = {}
-
-            partition_key = tuple(sorted(partition_spec.items()))
+            partition_value = tuple(entry.partition.values)
+            if len(partition_value) != len(self.table.partition_keys):
+                raise ValueError('Partition row does not match the table 
partition keys')
+            # Java PartitionEntry.toPartitionStatistics reuses the path value
+            # computer, without path escaping. Group by serialized partition 
rows:
+            # NULL and whitespace remain distinct even if their names coincide.
+            partition_spec = dict(zip(self.table.partition_keys, 
path_factory._canonical_partition(partition_value)))
+            partition_key = GenericRowSerializer.to_bytes(entry.partition)
 
             if partition_key not in partition_stats:
                 partition_stats[partition_key] = {
diff --git a/paimon-python/pypaimon/write/native_commit.py 
b/paimon-python/pypaimon/write/native_commit.py
index 91716cc357..2abcdd7f4e 100644
--- a/paimon-python/pypaimon/write/native_commit.py
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -60,6 +60,13 @@ def create_native_commit(table, commit_user, 
overwrite_partition=None):
     """Return a native committer only when its publication protocol matches 
Python."""
     if not _rest_catalog_supported(table) or not native_commit_available():
         return None
+    from pypaimon.common.options.core_options import CoreOptions
+    from pypaimon.schema.data_types import PyarrowFieldParser
+    from pypaimon.write.native_write import _native_partition_types_supported
+    if not _native_partition_types_supported(
+            PyarrowFieldParser.from_paimon_schema(table.table_schema.fields), 
table.partition_keys,
+            
table.options.options.get(CoreOptions.PARTITION_GENERATE_LEGACY_NAME)):
+        return None
     native_table = create_native_write_table(table)
     if native_table is None:
         return None
diff --git a/paimon-python/pypaimon/write/native_merge_into.py 
b/paimon-python/pypaimon/write/native_merge_into.py
index 8652079f15..7999c00d72 100644
--- a/paimon-python/pypaimon/write/native_merge_into.py
+++ b/paimon-python/pypaimon/write/native_merge_into.py
@@ -19,6 +19,8 @@
 
 import pyarrow as pa
 
+from pypaimon.common.options.core_options import CoreOptions
+
 from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
 from pypaimon.write.native_commit import create_native_write_table, 
from_native_commit_messages
 from pypaimon.write.native_update import (
@@ -62,7 +64,9 @@ def create_native_merge_into(table, source, on, matched, 
not_matched, commit_use
             return None
         from pypaimon.schema.data_types import PyarrowFieldParser
         source_schema = 
PyarrowFieldParser.from_paimon_schema(source.table_schema.fields)
-        if not _native_partition_types_supported(source_schema, 
source.partition_keys):
+        if not _native_partition_types_supported(
+                source_schema, source.partition_keys,
+                
source.options.options.get(CoreOptions.PARTITION_GENERATE_LEGACY_NAME)):
             return None
         native_source = create_native_write_table(source)
         if native_source is None:
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
index 1abf1ba4f9..f421a42e7d 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -20,7 +20,7 @@
 import pyarrow as pa
 
 from pypaimon.schema.data_types import MapType, PyarrowFieldParser, 
is_blob_file_field
-from pypaimon.common.options.core_options import ChangelogProducer
+from pypaimon.common.options.core_options import ChangelogProducer, CoreOptions
 from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
 from pypaimon.snapshot.time_travel_util import SCAN_KEYS
 from pypaimon.table.file_store_table import FileStoreTable
@@ -46,7 +46,8 @@ def _native_row_id_table(table):
             or not native_write_available()):
         return None
     schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
-    if not _native_partition_types_supported(schema, table.partition_keys):
+    if not _native_partition_types_supported(
+            schema, table.partition_keys, 
table.options.options.get(CoreOptions.PARTITION_GENERATE_LEGACY_NAME)):
         return None
     return create_native_write_table(table)
 
diff --git a/paimon-python/pypaimon/write/native_write.py 
b/paimon-python/pypaimon/write/native_write.py
index 9a353f60a9..7bbccd3ebf 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -20,7 +20,7 @@ from importlib import import_module
 
 import pyarrow as pa
 
-from pypaimon.common.options.core_options import MergeEngine
+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
@@ -39,11 +39,13 @@ def native_write_available() -> bool:
     return True
 
 
-def _native_partition_types_supported(schema, partition_keys):
+def _native_partition_types_supported(schema, partition_keys, 
legacy_partition_name=True):
     """Partition keys which Rust can encode and use to locate existing 
files."""
-    return not any(
+    # Java's legacy byte[].toString() contains an allocation identity, so 
binary
+    # partitions only have portable names with the cast-based option.
+    return not legacy_partition_name or not any(
         pa.types.is_binary(data_type) or pa.types.is_large_binary(data_type)
-        or pa.types.is_fixed_size_binary(data_type) or 
pa.types.is_floating(data_type)
+        or pa.types.is_fixed_size_binary(data_type)
         for data_type in (schema.field(name).type for name in partition_keys))
 
 
@@ -62,8 +64,8 @@ def create_native_write(table, commit_user, 
static_partition=None, stream=False,
                                                      MergeEngine.AGGREGATE))
             or table.options.changelog_file_format() not in (None, 'parquet')
             or table.options.file_format() != 'parquet'
-            # Rust cannot encode these partition keys yet.
-            or not _native_partition_types_supported(schema, 
table.partition_keys)
+            or not _native_partition_types_supported(
+                schema, table.partition_keys, 
table.options.options.get(CoreOptions.PARTITION_GENERATE_LEGACY_NAME))
             or table.options.video_frame_fields()):
         return None
     native_table = create_native_write_table(table)
diff --git a/paimon-python/pypaimon/write/table_delete.py 
b/paimon-python/pypaimon/write/table_delete.py
index 18dff8b910..00ea6b1cec 100644
--- a/paimon-python/pypaimon/write/table_delete.py
+++ b/paimon-python/pypaimon/write/table_delete.py
@@ -327,4 +327,4 @@ class TableDeleteByRowId:
 
     def _index_file_path(self, index_file: IndexFileMeta, partition: 
GenericRow, bucket: int) -> str:
         return self.table.path_factory().bucket_index_path(
-            tuple(partition.values), bucket, index_file, self.file_io)
+            tuple(partition.values), bucket, index_file)

Reply via email to