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 3b9a9e55a4 [python] Run Paimon table-source MERGE in native core 
(#10419)
3b9a9e55a4 is described below

commit 3b9a9e55a4a79bdb2f2f94f1130f1f4cc2839fed
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 8 13:22:41 2026 +0800

    [python] Run Paimon table-source MERGE in native core (#10419)
---
 paimon-python/README.md                            |  19 +-
 paimon-python/pypaimon/read/native_plan.py         |  43 +--
 .../pypaimon/read/scanner/split_generator.py       |  22 +-
 paimon-python/pypaimon/read/table_read.py          |  14 +-
 .../pypaimon/table/data_evolution_merge_into.py    |  11 +-
 .../pypaimon/table/system/file_key_ranges_table.py |   7 +-
 .../tests/data_evolution_group_stats_test.py       |   6 +
 .../tests/data_evolution_split_generator_test.py   |   6 +
 .../pypaimon/tests/interval_partition_test.py      |   1 +
 .../pypaimon/tests/mosaic_writer_options_test.py   |  15 +-
 paimon-python/pypaimon/tests/native_commit_test.py |  17 +-
 .../pypaimon/tests/native_merge_into_test.py       | 302 +++++++++++++++++++++
 .../pypaimon/tests/native_plan_integration_test.py |   2 +-
 paimon-python/pypaimon/tests/native_plan_test.py   |  82 ------
 .../tests/native_precomputed_bucket_test.py        |  11 +-
 .../pypaimon/tests/native_row_sidecar_test.py      |  61 ++++-
 paimon-python/pypaimon/tests/native_update_test.py |   4 +-
 .../pypaimon/tests/write/commit_callback_test.py   |   6 +-
 paimon-python/pypaimon/write/file_store_commit.py  |  18 +-
 paimon-python/pypaimon/write/native_commit.py      |   7 -
 paimon-python/pypaimon/write/native_merge_into.py  |  37 ++-
 paimon-python/pypaimon/write/native_update.py      |  30 --
 paimon-python/pypaimon/write/writer/data_writer.py |   8 +-
 23 files changed, 491 insertions(+), 238 deletions(-)

diff --git a/paimon-python/README.md b/paimon-python/README.md
index a6cbfcbd75..dc2459e3c0 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -170,7 +170,8 @@ propagate without retrying that batch through Python.
 
 Batch and stream `merge_into` also use Rust core for eligible data-evolution
 Parquet tables when `write.native.enabled=true`. Existing `WhenMatched` and
-`WhenNotMatched` clauses accept Arrow/pandas input or self-merge on `_ROW_ID`.
+`WhenNotMatched` clauses accept Arrow/pandas input, Paimon table sources, or
+self-merge on `_ROW_ID`.
 Core pins the target snapshot, matches keys, selects the first satisfied clause
 and prepares updates, deletion vectors and inserts. SQL conditions, including
 subqueries, execute in the Rust DataFusion adapter; Python transports the
@@ -180,9 +181,19 @@ DataFusion and `pypaimon-rust`. Shared clause validation 
checks that dependency
 before native execution. NULL keys do not match.
 Multiple source rows matching a target are rejected before action conditions,
 except for a sole unconditional DELETE, following Paimon Spark MERGE.
-Non-self table sources and packed-video inserts use the Python path selected
-before native execution starts. Native failures propagate without Python retry
-or deleting files from earlier prepared actions.
+For table sources, core independently pins the selected source branch/snapshot
+and reads only keys and referenced columns. Append and primary-key sources do
+not require data evolution. Source scan options select the same point in time
+on Python and native paths. Incremental source scans, non-REST Blob views
+requiring catalog resolution, and packed-video inserts use the Python path
+selected before native execution starts. Native failures propagate without
+Python retry or deleting files from earlier prepared actions.
+
+Python and native writers use Java's escaped partition directories for data,
+changelog, Blob and row sidecar files, including external locations. Both
+planners, file-range metadata, commit callbacks and explicit aborts use the 
same
+path rules. Row sidecar reads resolve that path even when the primary file is
+unavailable.
 
 MAP columns configured with `fields.<name>.map.storage-layout=shared-shredding`
 also use native Parquet writes and data-evolution updates, including predicate
diff --git a/paimon-python/pypaimon/read/native_plan.py 
b/paimon-python/pypaimon/read/native_plan.py
index fbad1d8c37..60ece775c9 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -34,7 +34,6 @@ from pypaimon.common.options.config import CatalogOptions, 
OssOptions
 from pypaimon.common.options.options_utils import OptionsUtils
 from pypaimon.common.predicate import Predicate
 from pypaimon.read.plan import Plan
-from pypaimon.read.split import Split
 from pypaimon.read.split_serializer import (
     deserialize_split_v1, serialize_split_v1)
 from pypaimon.schema.data_types import DataField, RowType
@@ -77,6 +76,16 @@ def native_reader_available() -> bool:
             and native_method_available('TableRead', 'read'))
 
 
+def _native_blob_view_supported(table, read_names) -> bool:
+    """Resolve Blob views natively only with the REST catalog environment."""
+    if (not (table.options.blob_view_fields() & set(read_names))
+            or not table.options.blob_view_resolve_enabled()):
+        return True
+    loader = getattr(getattr(table, 'catalog_environment', None), 
'catalog_loader', None)
+    # Python also leaves view structs unresolved without a loader.
+    return loader is None or _catalog_metastore(loader) == 'rest'
+
+
 def native_split_bridge_available() -> bool:
     """Whether Rust accepts Java-compatible split bytes from Python plans."""
     return native_reader_available() and native_method_available(
@@ -241,37 +250,6 @@ def _predicate_to_native(predicate: Predicate) -> dict:
     }
 
 
-def _restore_python_partition_paths(table, splits: List[Split]) -> None:
-    """Restore legacy PyPaimon paths with one listing per bucket."""
-    if not table.partition_keys:
-        return
-    path_factory = table.path_factory()
-    bucket_files = {}
-    for split in splits:
-        bucket_path = path_factory.bucket_path(
-            tuple(split.partition.values), split.bucket)
-        candidates = []
-        for data_file in split.files:
-            python_path = "%s/%s" % (
-                bucket_path.rstrip('/'), data_file.file_name)
-            if (not data_file.external_path
-                    and python_path != data_file.file_path):
-                candidates.append((data_file, python_path))
-        if not candidates:
-            continue
-        if bucket_path not in bucket_files:
-            bucket_files[bucket_path] = {
-                status.base_name
-                for status in table.file_io.list_status(bucket_path)
-            }
-        for data_file, python_path in candidates:
-            if data_file.file_name in bucket_files[bucket_path]:
-                data_file.file_path = python_path
-                # The retained Rust split still points at its canonical path.
-                # Invalidate it so native reading cannot bypass this repair.
-                split._native_split = None
-
-
 def _resolved_rest_table_response(table):
     """Reuse REST metadata only when the standard loader can be reproduced."""
     from pypaimon.catalog.catalog_environment import CatalogEnvironment
@@ -488,7 +466,6 @@ def native_plan(
         # time from its current fields.
         for split, rust_split in zip(splits, rust_splits):
             split._native_split = rust_split
-    _restore_python_partition_paths(table, splits)
     snapshot_id = getattr(rust_plan, 'snapshot_id', None)
     if callable(snapshot_id):
         snapshot_id = snapshot_id()
diff --git a/paimon-python/pypaimon/read/scanner/split_generator.py 
b/paimon-python/pypaimon/read/scanner/split_generator.py
index adbccdf123..2aa8f2ffb9 100644
--- a/paimon-python/pypaimon/read/scanner/split_generator.py
+++ b/paimon-python/pypaimon/read/scanner/split_generator.py
@@ -130,23 +130,15 @@ class AbstractSplitGenerator(ABC):
         return splits
 
     def _set_data_file_paths(self, files, partition: GenericRow, bucket: int):
-        """Resolve Java/Rust escaped paths and legacy Python partition 
paths."""
+        """Resolve Java's partition paths without probing the primary file.
+
+        A row-sidecar read may not open the primary file at all. Its aligned
+        path must still be correct when that primary file is unavailable.
+        """
         values = tuple(partition.values)
-        escaped_partition = False
-        if values:
-            path_factory = self.table.path_factory()
-            escaped_partition = (
-                path_factory.bucket_path(values, bucket, 
canonical_partition=True)
-                != path_factory.bucket_path(values, bucket))
         for data_file in files:
-            data_file.set_file_path(
-                self.table.table_path, partition, bucket, 
self.default_part_value,
-                self.table.options.data_file_path_directory())
-            if escaped_partition and not data_file.external_path:
-                canonical_path = canonical_data_file_path(
-                    self.table, values, bucket, data_file.file_name)
-                if self.table.file_io.exists(canonical_path):
-                    data_file.file_path = canonical_path
+            data_file.file_path = data_file.physical_path() if 
data_file.external_path else canonical_data_file_path(
+                self.table, values, bucket, data_file.file_name)
 
     def _get_deletion_files_for_split(
         self,
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index ee8f8e9f8d..e0e683d973 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -976,18 +976,8 @@ class TableRead:
                 or self._deferred_blob_limit_may_prune(splits))
 
     def _native_blob_view_supported(self) -> bool:
-        """Use native view resolution only with the REST catalog 
environment."""
-        read_names = {field.name for field in self._scan_read_type}
-        view_fields = self.table.options.blob_view_fields() & read_names
-        if not view_fields or not 
self.table.options.blob_view_resolve_enabled():
-            return True
-        loader = getattr(getattr(self.table, 'catalog_environment', None),
-                         'catalog_loader', None)
-        if loader is None:
-            # Python also leaves view structs unresolved without a loader.
-            return True
-        from pypaimon.read.native_plan import _catalog_metastore
-        return _catalog_metastore(loader) == 'rest'
+        from pypaimon.read.native_plan import _native_blob_view_supported
+        return _native_blob_view_supported(self.table, (field.name for field 
in self._scan_read_type))
 
     def _native_resolved_blob_fields(self) -> set:
         """BLOB fields Rust resolves before emitting a batch."""
diff --git a/paimon-python/pypaimon/table/data_evolution_merge_into.py 
b/paimon-python/pypaimon/table/data_evolution_merge_into.py
index d5baff21bc..cdf4feeb01 100644
--- a/paimon-python/pypaimon/table/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/table/data_evolution_merge_into.py
@@ -136,6 +136,8 @@ def _prepare(
     when_matched,
     when_not_matched,
     on,
+    *,
+    source_schema=None,
 ):
     if not when_matched and not when_not_matched:
         raise ValueError(
@@ -249,7 +251,8 @@ def _prepare(
         source_table = None
         source_col_names = set(full_target_field_names) | set(source_on_cols)
     else:
-        source_table = _normalize_source(source)
+        # Native table input needs schema validation without Python I/O.
+        source_table = source_schema.empty_table() if source_schema is not 
None else _normalize_source(source)
         _validate_source_on_cols(source_table, source_on_cols)
         source_col_names = set(source_table.schema.names)
 
@@ -751,9 +754,9 @@ def _normalize_source(source: Any) -> pa.Table:
     if isinstance(source, pa.Table):
         return source
     if _is_table_like(source):
-        snapshot = source.snapshot_manager().get_latest_snapshot()
-        snapshot_id = snapshot.id if snapshot is not None else None
-        return _read_table(source, snapshot_id=snapshot_id)
+        # Let the read resolve its branch and point-in-time selectors once.
+        # Pinning the latest snapshot here would override the source's options.
+        return _read_table(source)
     try:
         import pandas as pd
     except ImportError:
diff --git a/paimon-python/pypaimon/table/system/file_key_ranges_table.py 
b/paimon-python/pypaimon/table/system/file_key_ranges_table.py
index 67b60ff5bd..290caae252 100644
--- a/paimon-python/pypaimon/table/system/file_key_ranges_table.py
+++ b/paimon-python/pypaimon/table/system/file_key_ranges_table.py
@@ -30,6 +30,7 @@ from pypaimon.table.system.files_table import (
     _stringify_path,
 )
 from pypaimon.table.system.system_table import SystemTable
+from pypaimon.utils.file_store_path_factory import canonical_data_file_path
 
 
 TABLE_TYPE = RowType(False, [
@@ -71,7 +72,6 @@ class FileKeyRangesTable(SystemTable):
             manifest_files, drop_stats=False)
 
         file_format = self.base_table.options.file_format()
-        path_factory = self.base_table.path_factory()
         rows = {
             "partition": [],
             "bucket": [],
@@ -88,14 +88,13 @@ class FileKeyRangesTable(SystemTable):
 
         for entry in entries:
             meta = entry.file
-            bucket_path = path_factory.bucket_path(
-                tuple(entry.partition.values), int(entry.bucket))
             rows["partition"].append(_render_partition(entry.partition))
             rows["bucket"].append(int(entry.bucket))
             rows["file_path"].append(_stringify_path(
                 meta.external_path
                 or meta.file_path
-                or "%s/%s" % (bucket_path, meta.file_name)))
+                or canonical_data_file_path(
+                    self.base_table, tuple(entry.partition.values), 
int(entry.bucket), meta.file_name)))
             rows["file_format"].append(file_format)
             rows["schema_id"].append(int(meta.schema_id))
             rows["level"].append(int(meta.level))
diff --git a/paimon-python/pypaimon/tests/data_evolution_group_stats_test.py 
b/paimon-python/pypaimon/tests/data_evolution_group_stats_test.py
index 80826a7f0d..a49d540872 100644
--- a/paimon-python/pypaimon/tests/data_evolution_group_stats_test.py
+++ b/paimon-python/pypaimon/tests/data_evolution_group_stats_test.py
@@ -32,6 +32,7 @@ from pypaimon.read.scanner.data_evolution_stats import \
 from pypaimon.schema.data_types import AtomicType, DataField
 from pypaimon.table.row.generic_row import GenericRow
 from pypaimon.table.special_fields import SpecialFields
+from pypaimon.utils.file_store_path_factory import FileStorePathFactory
 
 
 def _empty_stats():
@@ -399,6 +400,11 @@ class 
DataEvolutionGroupStatsPlanningTest(unittest.TestCase):
             table_path = '/tmp/table'
             options = _Options()
 
+            def path_factory(self):
+                return FileStorePathFactory(
+                    self.table_path, [], '__DEFAULT_PARTITION__', 'parquet',
+                    'data-', 'changelog-', False, False, 'zstd')
+
         predicate = PredicateBuilder(fields).equal('id', 5)
         group_filter = DataEvolutionGroupStatsFilter(
             predicate,
diff --git 
a/paimon-python/pypaimon/tests/data_evolution_split_generator_test.py 
b/paimon-python/pypaimon/tests/data_evolution_split_generator_test.py
index 4b3c739070..fe9b1edc30 100644
--- a/paimon-python/pypaimon/tests/data_evolution_split_generator_test.py
+++ b/paimon-python/pypaimon/tests/data_evolution_split_generator_test.py
@@ -23,6 +23,7 @@ from pypaimon.manifest.schema.manifest_entry import 
ManifestEntry
 from pypaimon.manifest.schema.simple_stats import SimpleStats
 from pypaimon.read.scanner.data_evolution_split_generator import 
DataEvolutionSplitGenerator
 from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.utils.file_store_path_factory import FileStorePathFactory
 from pypaimon.utils.range import Range
 
 
@@ -127,6 +128,11 @@ class SplitOrderTest(unittest.TestCase):
         table_path = '/table'
         options = None
 
+        def path_factory(self):
+            return FileStorePathFactory(
+                self.table_path, [], '__DEFAULT_PARTITION__', 'parquet',
+                'data-', 'changelog-', False, False, 'zstd')
+
     _Table.options = _Options()
 
     @staticmethod
diff --git a/paimon-python/pypaimon/tests/interval_partition_test.py 
b/paimon-python/pypaimon/tests/interval_partition_test.py
index 822d3e64d1..592ba589b3 100644
--- a/paimon-python/pypaimon/tests/interval_partition_test.py
+++ b/paimon-python/pypaimon/tests/interval_partition_test.py
@@ -63,6 +63,7 @@ def 
test_signed_zero_key_ranges_keep_versions_in_one_split(type_name):
 
     path_factory = Mock()
     path_factory.bucket_path.return_value = '/tmp/interval-test/bucket-0'
+    path_factory.data_file_path.return_value = '/tmp/interval-test'
     table = SimpleNamespace(table_path='/tmp/interval-test', 
options=CoreOptions(Options({})),
                             path_factory=lambda: path_factory)
     entries = [ManifestEntry(0, GenericRow([], []), 0, 1, file) for file in 
files]
diff --git a/paimon-python/pypaimon/tests/mosaic_writer_options_test.py 
b/paimon-python/pypaimon/tests/mosaic_writer_options_test.py
index 0eac7c3849..88b3e5ad2e 100644
--- a/paimon-python/pypaimon/tests/mosaic_writer_options_test.py
+++ b/paimon-python/pypaimon/tests/mosaic_writer_options_test.py
@@ -24,6 +24,7 @@ import pytest
 from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.filesystem.local_file_io import LocalFileIO
 from pypaimon.schema.data_types import AtomicType, DataField
+from pypaimon.utils.file_store_path_factory import FileStorePathFactory
 from pypaimon.write.writer.append_only_data_writer import AppendOnlyDataWriter
 from pypaimon.write.writer.mosaic_writer_options import 
create_mosaic_writer_options
 
@@ -33,17 +34,6 @@ class _FakeWriterOptions:
         self.kwargs = kwargs
 
 
-class _PathFactory:
-    def __init__(self, bucket_path):
-        self._bucket_path = bucket_path
-
-    def create_external_path_provider(self, partition, bucket):
-        return None
-
-    def bucket_path(self, partition, bucket):
-        return self._bucket_path
-
-
 class _FileIO:
     def __init__(self):
         self.mosaic_options = None
@@ -121,7 +111,8 @@ def 
test_data_writer_passes_mosaic_writer_options(monkeypatch, tmp_path):
         trimmed_primary_keys=["id"],
         trimmed_primary_keys_fields=[DataField(0, "id", AtomicType("INT"))],
         fields=[DataField(0, "id", AtomicType("INT"))],
-        path_factory=lambda: _PathFactory(str(tmp_path)),
+        path_factory=lambda: FileStorePathFactory(
+            str(tmp_path), [], '__DEFAULT_PARTITION__', 'mosaic', 'data-', 
'changelog-', False, False, 'zstd'),
     )
     writer = AppendOnlyDataWriter(table, (), 0, 0, options)
 
diff --git a/paimon-python/pypaimon/tests/native_commit_test.py 
b/paimon-python/pypaimon/tests/native_commit_test.py
index 85b72f3ec7..3b62aa0ac5 100644
--- a/paimon-python/pypaimon/tests/native_commit_test.py
+++ b/paimon-python/pypaimon/tests/native_commit_test.py
@@ -332,22 +332,29 @@ def test_native_abort_removes_uncommitted_files(tmp_path, 
native_rest_catalog, o
 
 
 @requires_native
-def test_python_file_in_legacy_partition_uses_python_abort(
-        tmp_path, native_rest_catalog):
[email protected]('serialized', [False, True])
+def test_python_written_escaped_partition_supports_native_abort(
+        tmp_path, native_rest_catalog, serialized):
+    from pypaimon.write.commit_message_serializer import 
deserialize_commit_message, serialize_commit_message
+
     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)
+    assert '/pt=a%2Fb/' in file.file_path
+    if serialized:
+        messages = 
[deserialize_commit_message(serialize_commit_message(message, 
table.partition_keys_fields),
+                                               table.partition_keys_fields) 
for message in messages]
+    assert 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:
+                          side_effect=AssertionError('Python fallback')):
             commit.abort(messages)
-        python_abort.assert_called_once_with(messages)
         assert not table.file_io.exists(file.file_path)
+        assert table.snapshot_manager().get_latest_snapshot() is None
     finally:
         commit.close()
 
diff --git a/paimon-python/pypaimon/tests/native_merge_into_test.py 
b/paimon-python/pypaimon/tests/native_merge_into_test.py
index d7713a70a5..1272f129ae 100644
--- a/paimon-python/pypaimon/tests/native_merge_into_test.py
+++ b/paimon-python/pypaimon/tests/native_merge_into_test.py
@@ -211,6 +211,7 @@ def 
test_native_self_merge_does_not_read_unreferenced_blob(tmp_path, condition):
     catalog.create_table('db.t', Schema.from_pyarrow_schema(schema, options={
         'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
         'blob-descriptor-field': 'payload', 'blob-as-descriptor': 'false', 
'write.native.enabled': 'true',
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
     }), False)
     table = catalog.get_table('db.t')
     builder = table.new_batch_write_builder()
@@ -228,3 +229,304 @@ def 
test_native_self_merge_does_not_read_unreferenced_blob(tmp_path, condition):
     builder.new_commit().commit(messages)
     reader = table.new_read_builder().with_projection(['id', 'name'])
     assert 
reader.new_read().to_arrow(reader.new_scan().plan().splits()).to_pylist() == 
[dict(id=1, name='after')]
+
+
[email protected]('stream', [False, True])
+def test_native_merge_reads_paimon_source_in_core(tmp_path, stream):
+    target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
+    source = _table(tmp_path / 'source', [dict(id=1, value=11, name='new'), 
dict(id=2, value=22, name='insert')])
+    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.table.data_evolution_merge_into._normalize_source',
+               side_effect=AssertionError('Python source materialization')), \
+            patch('pypaimon.table.data_evolution_merge_into._build_tables',
+                  side_effect=AssertionError('Python merge orchestration')):
+        messages = builder.new_update().merge_into(source, on=['id'], 
when_matched=[
+            WhenMatched.update({'value': source_col('value')}, 
condition='s.value > t.value')],
+            when_not_matched=[WhenNotMatched('*')], **kwargs)
+    commit = builder.new_commit()
+    commit.commit(messages, 57) if stream else commit.commit(messages)
+    assert _rows(target) == [dict(id=1, value=11, name='old'), dict(id=2, 
value=22, name='insert')]
+    assert _rows(source) == [dict(id=1, value=11, name='new'), dict(id=2, 
value=22, name='insert')]
+
+
[email protected]('native', [False, True])
+def test_table_source_respects_point_in_time_read_options(tmp_path, native):
+    target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
+    source = _table(tmp_path / 'source', [dict(id=1, value=11, name='new')])
+    snapshot = source.snapshot_manager().get_latest_snapshot().id
+    builder = source.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        writer.write_arrow(pa.Table.from_pylist([dict(id=2, value=22, 
name='later')], schema=_SCHEMA))
+        builder.new_commit().commit(writer.prepare_commit())
+    finally:
+        writer.close()
+    target = target.copy({'write.native.enabled': str(native).lower()})
+    historical = source.copy({'scan.snapshot-id': str(snapshot)})
+    builder = target.new_batch_write_builder()
+    messages = builder.new_update().merge_into(historical, on=['id'], 
when_matched=[
+        WhenMatched.update({'value': source_col('value')})], 
when_not_matched=[WhenNotMatched('*')])
+    builder.new_commit().commit(messages)
+    assert _rows(target) == [dict(id=1, value=11, name='old')]
+    assert _rows(source) == [dict(id=1, value=11, name='new'), dict(id=2, 
value=22, name='later')]
+
+
+def test_native_table_source_projects_away_unreadable_blob(tmp_path):
+    from pypaimon.table.row.blob import BlobDescriptor
+
+    target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
+    schema = pa.schema(list(_SCHEMA) + [pa.field('payload', 
pa.large_binary())])
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'source')})
+    catalog.create_database('db', True)
+    catalog.create_table('db.source', Schema.from_pyarrow_schema(schema, 
options={
+        'blob-descriptor-field': 'payload', 'blob-as-descriptor': 'false', 
'write.native.enabled': 'true',
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+    }), False)
+    source = catalog.get_table('db.source')
+    builder = source.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        values = dict(id=1, value=11, name='new',
+                      payload=BlobDescriptor(str(tmp_path / 'unreadable.bin'), 
0, 1).serialize())
+        writer.write_arrow(pa.Table.from_pylist([values], schema=schema))
+        builder.new_commit().commit(writer.prepare_commit())
+    finally:
+        writer.close()
+    builder = target.new_batch_write_builder()
+    with patch('pypaimon.table.data_evolution_merge_into._normalize_source',
+               side_effect=AssertionError('Python source materialization')):
+        messages = builder.new_update().merge_into(source, on=['id'], 
when_matched=[
+            WhenMatched.update({'value': source_col('value')}, 
condition='s.value > t.value')])
+    builder.new_commit().commit(messages)
+    assert _rows(target) == [dict(id=1, value=11, name='old')]
+
+
+def test_native_table_source_pk_reads_logical_rows_and_needs_no_de(tmp_path):
+    target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'source')})
+    catalog.create_database('db', True)
+    source_schema = Schema.from_pyarrow_schema(
+        _SCHEMA, primary_keys=['id'], options={'bucket': '1', 
'write.native.enabled': 'true'})
+    catalog.create_table('db.source', source_schema, False)
+    source = catalog.get_table('db.source')
+    for values in [dict(id=1, value=11, name='first'), dict(id=1, value=12, 
name='latest'),
+                   dict(id=2, value=22, name='new')]:
+        builder = source.new_batch_write_builder()
+        writer = builder.new_write()
+        try:
+            writer.write_arrow(pa.Table.from_pylist([values], schema=_SCHEMA))
+            builder.new_commit().commit(writer.prepare_commit())
+        finally:
+            writer.close()
+    builder = target.new_batch_write_builder()
+    with patch('pypaimon.table.data_evolution_merge_into._normalize_source',
+               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('*')])
+    builder.new_commit().commit(messages)
+    assert _rows(target) == [dict(id=1, value=12, name='old'), dict(id=2, 
value=22, name='new')]
+
+
+def _write_rows(table, schema, rows):
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+        builder.new_commit().commit(writer.prepare_commit())
+    finally:
+        writer.close()
+
+
+def 
test_native_table_source_preserves_nested_values_and_null_keys_across_files(tmp_path):
+    schema = pa.schema([
+        ('id', pa.int32()), ('items', pa.list_(pa.int32())), ('lookup', 
pa.map_(pa.string(), pa.int32())),
+    ])
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('db', True)
+    common = {'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+              'write.native.enabled': 'true'}
+    for name in ['source', 'target']:
+        catalog.create_table('db.' + name, Schema.from_pyarrow_schema(schema, 
options=common), False)
+    source, target = catalog.get_table('db.source'), 
catalog.get_table('db.target')
+    _write_rows(target, schema, [dict(id=1, items=[0], lookup=[('old', 0)]),
+                                 dict(id=None, items=[9], lookup=[])])
+    values = [dict(id=2, items=[], lookup=[('two', None)]),
+              dict(id=1, items=[1, None], lookup=[('one', 1)]),
+              dict(id=None, items=None, lookup=None)]
+    for value in values:
+        _write_rows(source, schema, [value])
+    builder = target.new_batch_write_builder()
+    with patch('pypaimon.table.data_evolution_merge_into._normalize_source',
+               side_effect=AssertionError('Python source materialization')):
+        messages = builder.new_update().merge_into(
+            source, on=['id'], when_matched=[WhenMatched.update('*')],
+            when_not_matched=[WhenNotMatched('*')])
+    builder.new_commit().commit(messages)
+    reader = target.new_read_builder()
+    actual = 
reader.new_read().to_arrow(reader.new_scan().plan().splits()).to_pylist()
+    assert sorted([row for row in actual if row['id'] is not None], key=lambda 
row: row['id']) == sorted(
+        [row for row in values if row['id'] is not None], key=lambda row: 
row['id'])
+    assert sorted([row for row in actual if row['id'] is None], key=lambda 
row: row['items'] is None) == [
+        dict(id=None, items=[9], lookup=[]), dict(id=None, items=None, 
lookup=None)]
+
+
[email protected]('native', [False, True])
+def test_table_source_resolves_filesystem_blob_views_before_merge(tmp_path, 
native):
+    from pypaimon.table.row.blob import BlobViewStruct
+
+    schema = pa.schema([('id', pa.int32()), ('payload', pa.large_binary())])
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('db', True)
+    common = {'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'}
+    for name, extra in [('upstream', {}), ('source', {'blob-view-field': 
'payload'}),
+                        ('target', {'write.native.enabled': 
str(native).lower()})]:
+        catalog.create_table('db.' + name, Schema.from_pyarrow_schema(schema, 
options=dict(common, **extra)), False)
+    upstream, source = catalog.get_table('db.upstream'), 
catalog.get_table('db.source')
+    _write_rows(upstream, schema, [dict(id=1, payload=b'actual-payload')])
+    reference = BlobViewStruct('db.upstream', 
upstream.field_dict['payload'].id, 0).serialize()
+    _write_rows(source, schema, [dict(id=1, payload=reference)])
+    target = catalog.get_table('db.target')
+    builder = target.new_batch_write_builder()
+    messages = builder.new_update().merge_into(source, on=['id'], 
when_not_matched=[WhenNotMatched('*')])
+    builder.new_commit().commit(messages)
+    reader = target.new_read_builder()
+    assert 
reader.new_read().to_arrow(reader.new_scan().plan().splits()).to_pylist() == [
+        dict(id=1, payload=b'actual-payload')]
+
+
+def test_native_table_source_rejects_invalid_scan_options(tmp_path):
+    target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
+    source = _table(tmp_path / 'source', [dict(id=1, value=11, name='new')])
+    source = source.copy({'scan.mode': 'default', 
'scan.file-creation-time-millis': '1'})
+    before = set(tmp_path.rglob('*.parquet'))
+    with pytest.raises(ValueError, match='not yet supported|conflicts'):
+        target.new_batch_write_builder().new_update().merge_into(
+            source, on=['id'], when_matched=[WhenMatched.update({'value': 
source_col('value')})])
+    assert set(tmp_path.rglob('*.parquet')) == before
+    assert _rows(target) == [dict(id=1, value=10, name='old')]
+
+
+def test_native_table_source_keeps_its_selected_branch(tmp_path):
+    from pypaimon.common.identifier import Identifier
+
+    target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
+    source = _table(tmp_path / 'source', [dict(id=1, value=11, name='base')])
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'source')})
+    catalog.create_tag(source.identifier, 'base')
+    catalog.create_branch(source.identifier, 'dev', tag_name='base')
+    branch = catalog.get_table(Identifier('db', 't', branch='dev'))
+    _write_rows(branch, _SCHEMA, [dict(id=2, value=22, name='branch')])
+    _write_rows(source, _SCHEMA, [dict(id=3, value=33, name='main')])
+    builder = target.new_batch_write_builder()
+    with patch('pypaimon.table.data_evolution_merge_into._normalize_source',
+               side_effect=AssertionError('Python source materialization')):
+        messages = builder.new_update().merge_into(
+            branch, on=['id'], when_matched=[WhenMatched.update({'value': 
source_col('value')})],
+            when_not_matched=[WhenNotMatched('*')])
+    builder.new_commit().commit(messages)
+    assert _rows(target) == [dict(id=1, value=11, name='old'), dict(id=2, 
value=22, name='branch')]
+    assert _rows(source) == [dict(id=1, value=11, name='base'), dict(id=3, 
value=33, name='main')]
+
+
[email protected]_write
[email protected]('stream', [False, True])
[email protected]('source_mode', ['append', 'pk', 'evolution'])
[email protected]('location', ['local', 'file-uri', 'directory', 
'external'])
+def 
test_python_written_partition_source_uses_java_paths_for_native_merge(tmp_path, 
stream, source_mode, location):
+    from pathlib import Path
+    from pypaimon.write.native_write import NativeTableWrite
+
+    target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
+    warehouse = tmp_path / 'source'
+    catalog = CatalogFactory.create({'warehouse': warehouse.as_uri() if 
location == 'file-uri' else str(warehouse)})
+    catalog.create_database('db', True)
+    schema = pa.schema(list(_SCHEMA) + [pa.field('pa#rt', pa.string())])
+    options = {'write.native.enabled': 'false'}
+    if source_mode == 'pk':
+        options.update({'bucket': '1', 'changelog-producer': 'input'})
+    if source_mode == 'evolution':
+        schema = pa.schema(list(schema) + [pa.field('payload', 
pa.large_binary())])
+        options.update({'row-tracking.enabled': 'true', 
'data-evolution.enabled': 'true',
+                        'data-evolution.row-sidecar.enabled': 'true'})
+    if location == 'directory':
+        options['data-file.path-directory'] = 'data/nested'
+    if location == 'external':
+        options.update({'data-file.external-paths': (tmp_path / 
'external').as_uri(),
+                        'data-file.external-paths.strategy': 'round-robin'})
+    catalog.create_table('db.source', Schema.from_pyarrow_schema(
+        schema, primary_keys=['id', 'pa#rt'] if source_mode == 'pk' else [],
+        partition_keys=['pa#rt'], options=options), False)
+    source = catalog.get_table('db.source')
+    builder = source.new_batch_write_builder()
+    writer = builder.new_write()
+    assert not isinstance(writer, NativeTableWrite)
+    values = [dict(id=1, value=11, name='new', **{'pa#rt': 'a/b'}),
+              dict(id=2, value=22, name='insert', **{'pa#rt': 'a%b'})]
+    if source_mode == 'evolution':
+        for value in values:
+            value['payload'] = b'blob-payload'
+    try:
+        writer.write_arrow(pa.Table.from_pylist(values, schema=schema))
+        messages = writer.prepare_commit()
+        for message in messages:
+            component = 'pa%23rt=' + ('a%2Fb' if message.partition == ('a/b',) 
else 'a%25b')
+            for file in message.new_files + message.changelog_files:
+                assert component in file.file_path.split('/'), file.file_path
+                assert Path(file.file_path).is_file(), file.file_path
+                for extra_file in file.extra_files:
+                    assert Path(file.file_path).with_name(extra_file).is_file()
+        builder.new_commit().commit(messages)
+    finally:
+        writer.close()
+    # Java's path wins even if an incorrectly laid-out file has the same name.
+    for message in messages:
+        wrong_bucket = 
source.path_factory().bucket_path(tuple(message.partition), message.bucket)
+        for file in message.new_files:
+            if not file.external_path:
+                with source.file_io.new_output_stream(wrong_bucket + '/' + 
file.file_name) as output:
+                    output.write(b'wrong partition directory')
+    assert _rows(source) == values
+    ranges = catalog.get_table('db.source$file_key_ranges').new_read_builder()
+    reported = 
ranges.new_read().to_arrow(ranges.new_scan().plan().splits())['file_path'].to_pylist()
+    assert reported and all(source.file_io.exists(path) for path in reported)
+    assert all('pa%23rt=' in path for path in reported)
+    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.table.data_evolution_merge_into._normalize_source',
+               side_effect=AssertionError('Python source materialization')), \
+            patch('pypaimon.table.data_evolution_merge_into._build_tables',
+                  side_effect=AssertionError('Python merge orchestration')):
+        messages = builder.new_update().merge_into(
+            source, on=['id'], when_matched=[WhenMatched.update({'value': 
source_col('value')})],
+            when_not_matched=[WhenNotMatched('*')], **kwargs)
+    commit = builder.new_commit()
+    commit.commit(messages, 57) if stream else commit.commit(messages)
+    assert _rows(target) == [dict(id=1, value=11, name='old'), dict(id=2, 
value=22, name='insert')]
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
[email protected]('partition_type', [pa.float32(), pa.float64()])
+def test_float_partition_source_selects_python_before_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)])
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'source')})
+    catalog.create_database('db', True)
+    catalog.create_table('db.source', Schema.from_pyarrow_schema(schema, 
partition_keys=['part'], options={
+        'write.native.enabled': 'false', 'scan.native-plan.enabled': 'false', 
'read.native.enabled': 'false',
+    }), False)
+    source = catalog.get_table('db.source')
+    _write_rows(source, schema, [dict(id=1, value=11, name='new', part=1.5),
+                                 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')):
+        messages = builder.new_update().merge_into(
+            source, on=['id'], when_matched=[WhenMatched.update({'value': 
source_col('value')})],
+            when_not_matched=[WhenNotMatched('*')], **kwargs)
+    commit = builder.new_commit()
+    commit.commit(messages, 57) if stream else commit.commit(messages)
+    assert _rows(target) == [dict(id=1, value=11, name='old'), dict(id=2, 
value=22, name='insert')]
diff --git a/paimon-python/pypaimon/tests/native_plan_integration_test.py 
b/paimon-python/pypaimon/tests/native_plan_integration_test.py
index 3f6cf52fe1..7f0647b492 100644
--- a/paimon-python/pypaimon/tests/native_plan_integration_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_integration_test.py
@@ -1501,7 +1501,7 @@ class NativePlanIntegrationTest(unittest.TestCase):
 
     @pytest.mark.python_write
     def test_partitioned_table_matches_normal_plan(self):
-        # Native decoding restores PyPaimon's legacy unescaped partition path.
+        # Python writes and both planners use Java's escaped partition path.
         schema = pa.schema([('k', pa.int64()), ('p', pa.string())])
         self.cat.create_table('default.pt_t', Schema.from_pyarrow_schema(
             schema, partition_keys=['p']), False)
diff --git a/paimon-python/pypaimon/tests/native_plan_test.py 
b/paimon-python/pypaimon/tests/native_plan_test.py
index 3621bfdbae..63075bb1b7 100644
--- a/paimon-python/pypaimon/tests/native_plan_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_test.py
@@ -38,7 +38,6 @@ from pypaimon.read.native_plan import (
     _native_read_builder,
     _predicate_to_native,
     _resolved_schema_json,
-    _restore_python_partition_paths,
     native_family_search_modes_available,
     native_plan,
     native_version_at_least,
@@ -812,87 +811,6 @@ class NativePlanTest(unittest.TestCase):
                 self.assertEqual(
                     native_family_search_modes_available(), expected)
 
-    def test_partition_path_prefers_existing_python_legacy_path(self):
-        table = Mock(partition_keys=['p'])
-        table.path_factory.return_value.bucket_path.return_value = (
-            '/warehouse/t/p=a/b/bucket-0')
-        table.file_io.list_status.return_value = 
[Mock(base_name='data.parquet')]
-        data_file = Mock(
-            external_path=None,
-            file_name='data.parquet',
-            file_path='/warehouse/t/p=a%2Fb/bucket-0/data.parquet',
-        )
-        split = Mock(
-            partition=Mock(values=['a/b']), bucket=0, files=[data_file])
-        split._native_split = object()
-
-        _restore_python_partition_paths(table, [split])
-
-        self.assertEqual(
-            data_file.file_path,
-            '/warehouse/t/p=a/b/bucket-0/data.parquet',
-        )
-        self.assertIsNone(split._native_split)
-
-    def test_partition_path_keeps_existing_rust_path(self):
-        table = Mock(partition_keys=['p'])
-        table.path_factory.return_value.bucket_path.return_value = (
-            '/warehouse/t/p=a/b/bucket-0')
-        table.file_io.list_status.return_value = []
-        rust_path = '/warehouse/t/p=a%2Fb/bucket-0/data.parquet'
-        data_file = Mock(
-            external_path=None,
-            file_name='data.parquet',
-            file_path=rust_path,
-        )
-        split = Mock(
-            partition=Mock(values=['a/b']), bucket=0, files=[data_file])
-
-        _restore_python_partition_paths(table, [split])
-
-        self.assertEqual(data_file.file_path, rust_path)
-
-    def test_partition_path_lists_each_bucket_once(self):
-        table = Mock(partition_keys=['p'])
-        table.path_factory.return_value.bucket_path.return_value = (
-            '/warehouse/t/p=a/b/bucket-0')
-        table.file_io.list_status.return_value = [
-            Mock(base_name='a.parquet'), Mock(base_name='b.parquet')]
-        splits = [
-            Mock(partition=Mock(values=['a/b']), bucket=0, files=[Mock(
-                external_path=None,
-                file_name=name,
-                file_path='/warehouse/t/p=a%%2Fb/bucket-0/%s' % name,
-            )])
-            for name in ('a.parquet', 'b.parquet')
-        ]
-
-        _restore_python_partition_paths(table, splits)
-
-        table.file_io.list_status.assert_called_once_with(
-            '/warehouse/t/p=a/b/bucket-0')
-        self.assertEqual(
-            [split.files[0].file_path for split in splits],
-            [
-                '/warehouse/t/p=a/b/bucket-0/a.parquet',
-                '/warehouse/t/p=a/b/bucket-0/b.parquet',
-            ],
-        )
-
-    def test_partition_path_listing_failure_is_not_hidden(self):
-        table = Mock(partition_keys=['p'])
-        table.path_factory.return_value.bucket_path.return_value = (
-            '/warehouse/t/p=a/b/bucket-0')
-        table.file_io.list_status.side_effect = PermissionError('denied')
-        split = Mock(partition=Mock(values=['a/b']), bucket=0, files=[Mock(
-            external_path=None,
-            file_name='data.parquet',
-            file_path='/warehouse/t/p=a%2Fb/bucket-0/data.parquet',
-        )])
-
-        with self.assertRaises(PermissionError):
-            _restore_python_partition_paths(table, [split])
-
     def test_rest_catalog_retains_response_for_native_reads(self):
         from pypaimon.api.api_response import GetTableResponse
         from pypaimon.catalog.rest.rest_catalog import RESTCatalog
diff --git a/paimon-python/pypaimon/tests/native_precomputed_bucket_test.py 
b/paimon-python/pypaimon/tests/native_precomputed_bucket_test.py
index 8b62c6b221..5d070bccec 100644
--- a/paimon-python/pypaimon/tests/native_precomputed_bucket_test.py
+++ b/paimon-python/pypaimon/tests/native_precomputed_bucket_test.py
@@ -201,8 +201,9 @@ def 
test_pinned_dynamic_configuration_survives_early_python_selection(tmp_path,
         second.write_arrow(_batch([2], ['later']), bucket=0)
         later.new_commit().commit(second.prepare_commit())
         second.close()
-        # This existing advanced API still selects Python before any write.
-        writer.with_blob_consumer(lambda field, blob: blob)
+        # BlobConsumer is native now; explicitly select the Python branch whose
+        # pinned snapshot transport this test exercises before any write.
+        writer._switch_to_python()
         assert writer._python_writer.row_key_extractor.base_snapshot_id == 
pinned_id
         writer.write_arrow(_batch([1], ['restored']), bucket=0)
         messages = writer.prepare_commit()
@@ -362,7 +363,7 @@ def 
test_empty_restore_ignores_existing_sequences_and_hashes(tmp_path, python_fa
     writer = builder.new_write()
     try:
         if python_fallback:
-            writer.with_blob_consumer(lambda field, blob: blob)
+            writer._switch_to_python()
         writer.write_arrow(_batch([3], ['empty-base']), bucket=0)
         messages = writer.prepare_commit()
         assert messages[0].new_files[0].min_sequence_number == 0
@@ -386,7 +387,7 @@ def 
test_empty_restore_preserves_python_write_authorization(tmp_path, overwrite)
         builder.overwrite()
     writer = builder.new_write()
     # Exercise the ordinary writer selected before any Native data is staged.
-    writer.with_blob_consumer(lambda field, blob: blob)
+    writer._switch_to_python()
     auth = TableQueryAuthResult(None, {'value': '{"name":"NULL"}'})
     try:
         with patch.object(type(table.catalog_environment), 'table_query_auth',
@@ -405,7 +406,7 @@ def 
test_python_pk_file_sequence_range_matches_deduplicated_rows(tmp_path):
     builder = table.new_batch_write_builder().with_restore_snapshot(0)
     writer = builder.new_write()
     try:
-        writer.with_blob_consumer(lambda field, blob: blob)
+        writer._switch_to_python()
         writer.write_arrow(_batch([1, 1], ['old', 'new']), bucket=0)
         messages = writer.prepare_commit()
         file = messages[0].new_files[0]
diff --git a/paimon-python/pypaimon/tests/native_row_sidecar_test.py 
b/paimon-python/pypaimon/tests/native_row_sidecar_test.py
index 510bd6bd05..28dc2f19ec 100644
--- a/paimon-python/pypaimon/tests/native_row_sidecar_test.py
+++ b/paimon-python/pypaimon/tests/native_row_sidecar_test.py
@@ -62,7 +62,7 @@ def 
test_row_sidecar_options_keep_batch_and_stream_writes_native(tmp_path, strea
         writer.close()
 
 
-def _table(tmp_path, schema=None, options=None):
+def _table(tmp_path, schema=None, options=None, partition_keys=None):
     catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
     catalog.create_database('default', True)
     if schema is None:
@@ -72,7 +72,8 @@ def _table(tmp_path, schema=None, options=None):
                 'data-evolution.enabled': 'true', 'deletion-vectors.enabled': 
'true',
                 'data-evolution.row-sidecar.enabled': 'true'}
     settings.update(options or {})
-    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options=settings), False)
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        schema, partition_keys=partition_keys or [], options=settings), False)
     return catalog.get_table('default.t')
 
 
@@ -132,6 +133,62 @@ def 
test_row_sidecars_interoperate_between_python_and_rust(tmp_path, native_writ
         'id': [5], 'value': ['v5'], '_ROW_ID': [25]}
 
 
[email protected]_write
[email protected]('native_read', [False, True])
+def 
test_escaped_partition_sidecar_reads_do_not_require_the_primary_file(tmp_path, 
native_read):
+    schema = pa.schema([('id', pa.int32()), ('value', pa.string()), ('p', 
pa.string())])
+    table = _table(tmp_path, schema=schema, partition_keys=['p'], options={
+        'write.native.enabled': 'false', 'read.native.enabled': 
str(native_read).lower(),
+        'scan.native-plan.enabled': 'false'})
+    builder = table.new_batch_write_builder()
+    writer = builder.new_write()
+    try:
+        writer.write_arrow(pa.table({'id': list(range(100)), 'value': ['v%d' % 
i for i in range(100)],
+                                    'p': ['a/b'] * 100}, schema=schema))
+        messages = writer.prepare_commit()
+        _commit(builder, messages)
+    finally:
+        writer.close()
+    _remove_primaries(messages)
+    assert _read(table, ['id', 'value', '_ROW_ID'], 25, 
native_read).to_pydict() == {
+        'id': [25], 'value': ['v25'], '_ROW_ID': [25]}
+
+
[email protected]_write
[email protected]('file_uri', [False, True])
+def 
test_serialized_abort_deletes_escaped_data_and_sidecars_but_preserves_committed_files(tmp_path,
 file_uri):
+    from pypaimon.write.commit_message_serializer import 
deserialize_commit_message, serialize_commit_message
+
+    schema = pa.schema([('id', pa.int32()), ('value', pa.string()), ('p', 
pa.string())])
+    table = _table(tmp_path.as_uri() if file_uri else tmp_path, schema=schema, 
partition_keys=['p'], options={
+        'write.native.enabled': 'false', 'commit.native.enabled': 'false'})
+    paths = []
+    for row_id in [1, 2]:
+        builder = table.new_batch_write_builder()
+        writer = builder.new_write()
+        try:
+            writer.write_arrow(pa.table({'id': [row_id], 'value': ['v'], 'p': 
['a/b']}, schema=schema))
+            messages = writer.prepare_commit()
+            paths.append([path for message in messages for file in 
message.new_files for path in file.collect_files()])
+            if row_id == 1:
+                _commit(builder, messages)
+        finally:
+            writer.close()
+    partition_fields = [table.field_dict[name] for name in 
table.partition_keys]
+    restored = [deserialize_commit_message(serialize_commit_message(message, 
partition_fields), partition_fields)
+                for message in messages]
+    assert all(file.file_path is None for message in restored for file in 
message.new_files)
+    assert all(table.file_io.exists(path) for path in paths[0] + paths[1])
+    commit = builder.new_commit()
+    try:
+        # Only these never-submitted messages are explicitly abandoned.
+        commit.abort(restored)
+    finally:
+        commit.close()
+    assert all(table.file_io.exists(path) for path in paths[0])
+    assert all(not table.file_io.exists(path) for path in paths[1])
+
+
 @pytest.mark.parametrize('stream', [False, True])
 @pytest.mark.parametrize('incremental', [False, True])
 def test_sidecars_keep_updates_native_and_apply_deletion_vectors(tmp_path, 
stream, incremental):
diff --git a/paimon-python/pypaimon/tests/native_update_test.py 
b/paimon-python/pypaimon/tests/native_update_test.py
index e0d6ceca12..e59e81813a 100644
--- a/paimon-python/pypaimon/tests/native_update_test.py
+++ b/paimon-python/pypaimon/tests/native_update_test.py
@@ -967,7 +967,7 @@ def 
test_core_nested_row_update_uses_whole_column_constructor_fallback(tmp_path,
 
 
 @pytest.mark.parametrize('layout', [
-    'legacy', 'canonical', 'ordinary', 'directory', 'external', 'float', 
'double', 'external-binary',
+    'python-escaped', 'canonical', 'ordinary', 'directory', 'external', 
'float', 'double', 'external-binary',
 ])
 @pytest.mark.parametrize('operation', ['row', 'predicate'])
 def test_update_dispatch_preserves_existing_partition_paths(tmp_path, layout, 
operation):
@@ -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 ('canonical', 'ordinary', 'external') else 0
+    expected_calls = 2 if layout in ('python-escaped', 'canonical', 
'ordinary', 'directory', 'external') else 0
     assert len(native_calls) == expected_calls
diff --git a/paimon-python/pypaimon/tests/write/commit_callback_test.py 
b/paimon-python/pypaimon/tests/write/commit_callback_test.py
index 55ba65dd4d..033dec989a 100644
--- a/paimon-python/pypaimon/tests/write/commit_callback_test.py
+++ b/paimon-python/pypaimon/tests/write/commit_callback_test.py
@@ -135,6 +135,7 @@ class CommitCallbackTest(unittest.TestCase):
     def test_callback_invoked_after_lost_commit_response(self):
         table = self._create_table(
             'test_callback_response_loss',
+            partition_keys=['dt'],
             options={
                 'row-tracking.enabled': 'true',
                 'data-evolution.enabled': 'true',
@@ -150,7 +151,7 @@ class CommitCallbackTest(unittest.TestCase):
         table_write.write_arrow(pa.Table.from_pydict({
             'id': [1, 2],
             'name': ['a', 'b'],
-            'dt': ['p1', 'p1'],
+            'dt': ['a/b', 'a%b'],
         }, schema=self.pa_schema))
         messages = table_write.prepare_commit()
         expected_paths = sorted(
@@ -171,6 +172,9 @@ class CommitCallbackTest(unittest.TestCase):
             for entry in callback.contexts[0].commit_entries
             if entry.kind == 0
         ))
+        self.assertTrue(all(table.file_io.exists(path) for path in 
expected_paths))
+        self.assertTrue(any('/dt=a%2Fb/' in path for path in expected_paths))
+        self.assertTrue(any('/dt=a%25b/' in path for path in expected_paths))
         table_write.close()
         table_commit.close()
 
diff --git a/paimon-python/pypaimon/write/file_store_commit.py 
b/paimon-python/pypaimon/write/file_store_commit.py
index 03e718e707..10d8ce23c6 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -35,6 +35,7 @@ from pypaimon.snapshot.snapshot_commit import 
(PartitionStatistics,
                                                SnapshotCommit)
 from pypaimon.table.row.generic_row import GenericRow
 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
 from pypaimon.write.commit.commit_scanner import CommitScanner
 from pypaimon.write.commit.conflict_detection import (
@@ -861,16 +862,10 @@ class FileStoreCommit:
                             entries.extend(self.manifest_file_manager.read(
                                 manifest.file_name, drop_stats=False,
                                 file_size=manifest.file_size))
-                        path_factory = self.table.path_factory()
                         for entry in entries:
                             file = entry.file
-                            file.file_path = file.physical_path() if 
file.external_path else "%s/%s" % (
-                                path_factory.bucket_path(
-                                    tuple(entry.partition.values),
-                                    entry.bucket,
-                                ).rstrip("/"),
-                                file.file_name,
-                            )
+                            file.file_path = file.physical_path() if 
file.external_path else canonical_data_file_path(
+                                self.table, tuple(entry.partition.values), 
entry.bucket, file.file_name)
                         self._notify_commit_callbacks(
                             snapshot, entries, commit_identifier)
                     return True
@@ -1033,9 +1028,10 @@ class FileStoreCommit:
                          + list(message.compact_changelog_files)):
                 path = None
                 try:
-                    bucket_path = None if file.physical_path() else 
self.table.path_factory().bucket_path(
-                        tuple(message.partition), message.bucket)
-                    for path in file.collect_files(bucket_path):
+                    if not file.physical_path():
+                        file.file_path = canonical_data_file_path(
+                            self.table, tuple(message.partition), 
message.bucket, file.file_name)
+                    for path in file.collect_files():
                         self.table.file_io.delete_quietly(path)
                 except Exception as error:
                     logger.warning(
diff --git a/paimon-python/pypaimon/write/native_commit.py 
b/paimon-python/pypaimon/write/native_commit.py
index acbb1d2081..91716cc357 100644
--- a/paimon-python/pypaimon/write/native_commit.py
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -39,15 +39,12 @@ def native_commit_available() -> bool:
 
 
 def native_messages_supported(table, messages) -> bool:
-    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
         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
@@ -56,10 +53,6 @@ def native_messages_supported(table, messages) -> bool:
             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
 
 
diff --git a/paimon-python/pypaimon/write/native_merge_into.py 
b/paimon-python/pypaimon/write/native_merge_into.py
index 1e12185f9e..7d535ce1dd 100644
--- a/paimon-python/pypaimon/write/native_merge_into.py
+++ b/paimon-python/pypaimon/write/native_merge_into.py
@@ -20,10 +20,11 @@
 import pyarrow as pa
 
 from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
-from pypaimon.write.native_commit import from_native_commit_messages
+from pypaimon.write.native_commit import create_native_write_table, 
from_native_commit_messages
 from pypaimon.write.native_update import (
     _native_row_id_table, _native_update_columns_supported, 
_supported_upsert_key_type,
 )
+from pypaimon.write.native_write import _native_partition_types_supported
 
 
 def create_native_merge_into(table, source, on, matched, not_matched, 
commit_user, commit_identifier):
@@ -36,12 +37,37 @@ def create_native_merge_into(table, source, on, matched, 
not_matched, commit_use
     self_merge = _is_self_merge(table, source, target_keys, source_keys)
     if not_matched and table.options.video_frame_fields():
         return None
-    if _is_table_like(source) and not self_merge:
-        return None
     native_table = _native_row_id_table(table)
     if native_table is None:
         return None
-    source, matched, not_matched, context = _prepare(table, source, 
list(matched), list(not_matched), on)
+    source_schema = None
+    native_source = None
+    if _is_table_like(source) and not self_merge:
+        # The source can be an append or PK table; only the target requires DE.
+        from pypaimon.read.native_plan import _native_blob_view_supported
+        from pypaimon.table.file_store_table import FileStoreTable
+        if (type(source) is not FileStoreTable
+                or source.options.file_format() != 'parquet'
+                or source.options.video_frame_fields() or 
source.options.with_vector_format()
+                or (source.is_primary_key_table and not 
source.trimmed_primary_keys)):
+            return None
+        source.new_read_builder().new_scan()._validate_scan_mode()
+        if not _native_blob_view_supported(source, source.field_names):
+            return None
+        # This operation reads one full source snapshot. Incremental scans stay
+        # on the Python path, whose scanner applies the requested delta range.
+        if (source.options.scan_mode() not in ('default', 'from-snapshot', 
'from-timestamp')
+                or any(str(key).startswith('incremental-') for key in 
source.table_schema.options)):
+            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):
+            return None
+        native_source = create_native_write_table(source)
+        if native_source is None:
+            return None
+    source, matched, not_matched, context = _prepare(
+        table, source, list(matched), list(not_matched), on, 
source_schema=source_schema)
     if any(callable(value) for clause in matched + not_matched for value in 
clause.spec.values()):
         return None
     if not _native_update_columns_supported(table, 
_union_update_cols(matched)):
@@ -74,7 +100,8 @@ def create_native_merge_into(table, source, on, matched, 
not_matched, commit_use
                if commit_identifier == BATCH_COMMIT_IDENTIFIER else
                
native_table.new_stream_write_builder().with_commit_user(commit_user))
     writer = builder.new_update()
-    return NativeTableMergeInto(table, writer, source, list(zip(target_keys, 
source_keys)),
+    return NativeTableMergeInto(table, writer, native_source if native_source 
is not None else source,
+                                list(zip(target_keys, source_keys)),
                                 encoded_matched, encoded_not_matched, 
commit_identifier)
 
 
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
index 9b19e49b1f..3175d30226 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -48,39 +48,9 @@ def _native_row_id_table(table):
     schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
     if not _native_partition_types_supported(schema, table.partition_keys):
         return None
-    if not _native_update_paths_supported(table):
-        return None
     return create_native_write_table(table)
 
 
-def _native_update_paths_supported(table):
-    """Keep legacy Python partition directories on the path-aware Python 
updater."""
-    if not table.partition_keys:
-        return True
-    factory = table.path_factory()
-    # Core update scans for itself, unlike native reads which receive splits
-    # with repaired file paths. Decide before callbacks or staging can start.
-    splits = table.new_read_builder().new_scan().plan_for_write().splits()
-    bucket_files = {}
-    for split in splits:
-        partition = tuple(split.partition.values)
-        bucket_path = factory.bucket_path(partition, split.bucket)
-        if bucket_path == factory.bucket_path(partition, split.bucket, 
canonical_partition=True):
-            continue
-        candidates = [file for file in split.files if not file.external_path]
-        if not candidates:
-            continue
-        if bucket_path not in bucket_files:
-            bucket_files[bucket_path] = {
-                status.base_name for status in 
table.file_io.list_status(bucket_path)
-            }
-        # Python reads prefer an existing legacy path even if a canonical copy
-        # also exists. Canonical-only files and explicit external paths are 
safe.
-        if any(file.file_name in bucket_files[bucket_path] for file in 
candidates):
-            return False
-    return True
-
-
 def create_native_update(table, commit_user, columns):
     """Use the public core updater for direct and grouped row-ID updates."""
     if not _native_update_columns_supported(table, columns):
diff --git a/paimon-python/pypaimon/write/writer/data_writer.py 
b/paimon-python/pypaimon/write/writer/data_writer.py
index 8c7e7fa45a..57d1dbbbe7 100644
--- a/paimon-python/pypaimon/write/writer/data_writer.py
+++ b/paimon-python/pypaimon/write/writer/data_writer.py
@@ -29,6 +29,7 @@ from pypaimon.manifest.schema.simple_stats import SimpleStats
 from pypaimon.schema.data_types import PyarrowFieldParser
 from pypaimon.table.bucket_mode import BucketMode
 from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.utils.file_store_path_factory import canonical_data_file_path
 from pypaimon.write.map_shared_shredding_writer import MapSharedShreddingWriter
 from pypaimon.write.writer.mosaic_writer_options import 
create_mosaic_writer_options
 from pypaimon.write.writer.parquet_writer_options import 
create_parquet_writer_options
@@ -95,7 +96,7 @@ class DataWriter(ABC):
 
         self.path_factory = self.table.path_factory()
         self.external_path_provider: Optional[ExternalPathProvider] = 
self.path_factory.create_external_path_provider(
-            self.partition, self.bucket
+            self.partition, self.bucket, canonical_partition=True
         )
         # Variant shredding (static mode) — col_name → (obj_fields, 
target_arrow_type)
         self._variant_shredding: Dict[str, Tuple] = {}
@@ -498,8 +499,9 @@ class DataWriter(ABC):
         if self.external_path_provider:
             return 
self.external_path_provider.get_next_external_data_path(file_name)
 
-        bucket_path = self.path_factory.bucket_path(self.partition, 
self.bucket)
-        return f"{bucket_path.rstrip('/')}/{file_name}"
+        # New files use Java's escaped partition names, including on the pure
+        # Python writer path. Never introduce another Python-specific layout.
+        return canonical_data_file_path(self.table, self.partition, 
self.bucket, file_name)
 
     def _should_write_row_sidecar(self) -> bool:
         return (

Reply via email to